Files
AWCharacterCreator/frontend/src/Maps.jsx
T

111 lines
5.8 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useState } from 'react'
import { MapEditor } from './MapView.jsx'
const J = { 'Content-Type': 'application/json' }
const errText = async (r, fallback) => {
if (r.status === 413) return 'The file is too big for the web server (nginx client_max_body_size). Use a smaller image or ask the server admin to raise the limit.'
return (await r.json().catch(() => ({}))).detail || fallback
}
// Big images are scaled down and re-encoded in the browser first (the server does it again anyway), so they fit through proxies with small upload limits.
async function shrink(f) {
if (f.size < 900_000) return f
try {
const bmp = await createImageBitmap(f)
const k = Math.min(1, 4096 / Math.max(bmp.width, bmp.height))
const c = document.createElement('canvas')
c.width = Math.max(1, Math.round(bmp.width * k)); c.height = Math.max(1, Math.round(bmp.height * k))
c.getContext('2d').drawImage(bmp, 0, 0, c.width, c.height)
const blob = await new Promise((res) => c.toBlob(res, 'image/webp', 0.9))
return blob && blob.size < f.size ? blob : f
} catch { return f }
}
function SharePanel({ m, reload }) {
const [name, setName] = useState('')
const [err, setErr] = useState('')
const add = async (e) => {
e.preventDefault()
setErr('')
const r = await fetch(`/api/maps/${m.id}/shares`, { method: 'POST', headers: J, body: JSON.stringify({ username: name }) })
if (!r.ok) return setErr(await errText(r, 'Could not share.'))
setName(''); reload()
}
const remove = async (u) => { await fetch(`/api/maps/${m.id}/shares/${encodeURIComponent(u)}`, { method: 'DELETE' }).catch(() => {}); reload() }
return (
<div className="sharepanel">
<div className="small muted">Shared with</div>
<div className="chips">
{m.shared_with.length === 0 && <span className="small muted">Nobody yet.</span>}
{m.shared_with.map((u) => <span key={u} className="chip">{u} <button className="ghost" aria-label={`Stop sharing with ${u}`} onClick={() => remove(u)}>×</button></span>)}
</div>
<form className="row" onSubmit={add}>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Username" aria-label="Username to share with" />
<button type="submit" disabled={!name.trim()}>Share</button>
</form>
{err && <p className="warn small">{err}</p>}
</div>
)
}
// All maps of the user (prepare, share, copy) plus the maps other users shared with them
export default function Maps() {
const [list, setList] = useState(null)
const [editing, setEditing] = useState(null)
const [open, setOpen] = useState(null)
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const load = useCallback(() => fetch('/api/maps').then((r) => (r.ok ? r.json() : [])).then(setList).catch(() => setList([])), [])
useEffect(() => { load() }, [load, editing])
const upload = async (e) => {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
setErr(''); setBusy(true)
const body = await shrink(f)
const r = await fetch(`/api/maps?name=${encodeURIComponent(f.name.replace(/\.[^.]+$/, ''))}`, { method: 'POST', headers: { 'Content-Type': body.type || 'application/octet-stream' }, body }).catch(() => null)
setBusy(false)
if (!r || !r.ok) return setErr(r ? await errText(r, 'Upload failed') : 'Upload failed')
load()
}
const copy = async (m) => {
const r = await fetch(`/api/maps/${m.id}/copy`, { method: 'POST' })
if (!r.ok) return setErr(await errText(r, 'Could not copy'))
load()
}
const del = async (m) => {
if (!window.confirm(`Delete the map "${m.name}"? Lobbies that show it get an empty table, shared users lose it.`)) return
await fetch(`/api/maps/${m.id}`, { method: 'DELETE' }); load()
}
const mine = (list || []).filter((m) => m.mine), shared = (list || []).filter((m) => !m.mine)
const card = (m) => (
<li key={m.id} className="mapcard card">
<img src={`/api/maps/${m.id}/thumb`} alt="" loading="lazy" />
<div className="mapcardbody">
<strong>{m.name}</strong>
<span className="small muted">{m.w}×{m.h} · {m.tokens} token{m.tokens === 1 ? '' : 's'} · {m.walls} wall{m.walls === 1 ? '' : 's'}{!m.mine && ` · from ${m.author}`}{m.mine && m.shared_with.length > 0 && ` · shared with ${m.shared_with.join(', ')}`}</span>
<span className="row">
{m.mine && <button className="on" onClick={() => setEditing(m.id)}>Edit</button>}
{m.mine && <button className="ghost" onClick={() => setOpen(open === m.id ? null : m.id)}>Share</button>}
<button className="ghost" onClick={() => copy(m)} title="Make your own editable copy">{m.mine ? 'Duplicate' : 'Copy to my maps'}</button>
{m.mine && <button className="ghost danger" onClick={() => del(m)}>Delete</button>}
</span>
{open === m.id && m.mine && <SharePanel m={m} reload={load} />}
</div>
</li>
)
return (
<section className="page">
<h1>Maps</h1>
<p className="muted">Prepare maps with grid, enemies, NPCs, walls and doors. The DM puts one on the table from a lobby (<em>Lobby actions</em>). Shared maps can be used on your table or copied, but not changed.</p>
<label className="uploadbtn">{busy ? 'Uploading …' : '+ Upload map (PNG, JPEG, WebP, up to 12 MB)'}<input type="file" accept="image/png,image/jpeg,image/webp" onChange={upload} disabled={busy} hidden /></label>
{err && <p className="warn">{err}</p>}
{list === null && <p className="muted">Loading …</p>}
{list && !mine.length && <p className="muted">You have no maps yet.</p>}
<ul className="mapcards">{mine.map(card)}</ul>
{shared.length > 0 && <><h2>Shared with me</h2><ul className="mapcards">{shared.map(card)}</ul></>}
{editing && <MapEditor id={editing} onClose={() => setEditing(null)} />}
</section>
)
}