Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
111 lines
5.8 KiB
React
111 lines
5.8 KiB
React
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>
|
||
)
|
||
}
|