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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user