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 (
Shared with
{m.shared_with.length === 0 && Nobody yet.} {m.shared_with.map((u) => {u} )}
setName(e.target.value)} placeholder="Username" aria-label="Username to share with" />
{err &&

{err}

}
) } // 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) => (
  • {m.name} {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(', ')}`} {m.mine && } {m.mine && } {m.mine && } {open === m.id && m.mine && }
  • ) return (

    Maps

    Prepare maps with grid, enemies, NPCs, walls and doors. The DM puts one on the table from a lobby (Lobby actions). Shared maps can be used on your table or copied, but not changed.

    {err &&

    {err}

    } {list === null &&

    Loading …

    } {list && !mine.length &&

    You have no maps yet.

    } {shared.length > 0 && <>

    Shared with me

    } {editing && setEditing(null)} />}
    ) }