Maps tab with sharing, walls/doors and strict line of sight

Maps page (list, upload, edit, share, copy), wall/room/door tools in the
map editor, server-side visibility polygon: the image is cut per player
and other tokens are filtered, DM can view as a player, open doors and
switch sight on/off.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
Claude
2026-10-23 14:23:17 +00:00
parent e8755582d5
commit 82e4ac9458
8 changed files with 632 additions and 58 deletions
+92
View File
@@ -0,0 +1,92 @@
import { useCallback, useEffect, useState } from 'react'
import { MapEditor } from './MapView.jsx'
const J = { 'Content-Type': 'application/json' }
const errText = async (r, fallback) => (await r.json().catch(() => ({}))).detail || fallback
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 r = await fetch(`/api/maps?name=${encodeURIComponent(f.name.replace(/\.[^.]+$/, ''))}`, { method: 'POST', headers: { 'Content-Type': f.type || 'application/octet-stream' }, body: f }).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>
)
}