- NPC library (own + shared) built on the character wizard with a free NPC preset - NPC tokens in map editor and lobby, DM sheet on token click, DM checks for NPCs - Admins can promote/demote other users Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
392 lines
24 KiB
React
392 lines
24 KiB
React
import { useCallback, useEffect, useRef, useState } from 'react'
|
||
import { createPortal } from 'react-dom'
|
||
|
||
const COLORS = ['#c0392b', '#8e44ad', '#16a085', '#7f8c8d', '#d35400', '#2c3e50', '#27ae60', '#b9770e']
|
||
const clamp = (v, a, b) => Math.min(b, Math.max(a, v))
|
||
|
||
// Map with pan (drag the background), zoom (wheel / pinch) and draggable tokens. Coordinates are image pixels.
|
||
// Everybody pans and zooms for themselves; token moves go out through onMove(id, x, y) which returns a promise.
|
||
export function MapView({ map, image, portraitUrl, tokens, onMove, selected, onSelect, grid: showGrid, tool, onClick, onHover, overlay, children }) {
|
||
const box = useRef(null)
|
||
const [view, setView] = useState({ tx: 0, ty: 0, s: 1 })
|
||
const [drag, setDrag] = useState(null)
|
||
const viewRef = useRef(view)
|
||
viewRef.current = view
|
||
const pointers = useRef(new Map())
|
||
const fitted = useRef('')
|
||
|
||
const fit = useCallback(() => {
|
||
const r = box.current?.getBoundingClientRect()
|
||
if (!r || !r.width || !map) return
|
||
const s = Math.min(r.width / map.w, r.height / map.h) * 0.96
|
||
setView({ s, tx: (r.width - map.w * s) / 2, ty: (r.height - map.h * s) / 2 })
|
||
}, [map?.id, map?.w, map?.h]) // eslint-disable-line
|
||
|
||
useEffect(() => { // fit once per map, as soon as the box has a size
|
||
if (!map || fitted.current === map.id) return
|
||
const t = setInterval(() => { if (box.current?.getBoundingClientRect().width) { fit(); fitted.current = map.id; clearInterval(t) } }, 50)
|
||
return () => clearInterval(t)
|
||
}, [map?.id, fit]) // eslint-disable-line
|
||
|
||
const zoomAt = (mx, my, f) => setView((v) => {
|
||
const s = clamp(v.s * f, 0.05, 8)
|
||
return { s, tx: mx - (mx - v.tx) * (s / v.s), ty: my - (my - v.ty) * (s / v.s) }
|
||
})
|
||
|
||
useEffect(() => {
|
||
const el = box.current
|
||
if (!el) return
|
||
const wheel = (e) => {
|
||
e.preventDefault()
|
||
const r = el.getBoundingClientRect()
|
||
zoomAt(e.clientX - r.left, e.clientY - r.top, Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015)))
|
||
}
|
||
el.addEventListener('wheel', wheel, { passive: false })
|
||
return () => el.removeEventListener('wheel', wheel)
|
||
}, [])
|
||
|
||
const down = (e) => {
|
||
if (e.target.closest('.maptoken, .mapui, .mapdoor')) return
|
||
box.current.setPointerCapture(e.pointerId)
|
||
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY })
|
||
if (pointers.current.size === 1) onSelect?.(null)
|
||
else pointers.current.forEach((p, k) => pointers.current.set(k, { ...p, sx: -1e9 })) // two fingers: pinch, not a click
|
||
}
|
||
const move = (e) => {
|
||
if (onHover) onHover(toWorld(e), viewRef.current.s)
|
||
const p = pointers.current.get(e.pointerId)
|
||
if (!p) return
|
||
const r = box.current.getBoundingClientRect()
|
||
if (pointers.current.size === 2) { // pinch
|
||
const [a, b] = [...pointers.current.values()]
|
||
const before = Math.hypot(a.x - b.x, a.y - b.y)
|
||
const o = pointers.current.get(e.pointerId)
|
||
const now = { ...o, x: e.clientX, y: e.clientY, sx: -1e9 }
|
||
pointers.current.set(e.pointerId, now)
|
||
const [c, d] = [...pointers.current.values()]
|
||
const after = Math.hypot(c.x - d.x, c.y - d.y)
|
||
if (before > 0) zoomAt((c.x + d.x) / 2 - r.left, (c.y + d.y) / 2 - r.top, after / before)
|
||
return
|
||
}
|
||
const dx = e.clientX - p.x, dy = e.clientY - p.y
|
||
pointers.current.set(e.pointerId, { ...p, x: e.clientX, y: e.clientY })
|
||
setView((v) => ({ ...v, tx: v.tx + dx, ty: v.ty + dy }))
|
||
}
|
||
const up = (e) => {
|
||
const p = pointers.current.get(e.pointerId)
|
||
pointers.current.delete(e.pointerId)
|
||
// a press that barely moved is a click on the map (used by the drawing tools)
|
||
if (p && onClick && e.type === 'pointerup' && Math.hypot(e.clientX - p.sx, e.clientY - p.sy) < 5) onClick(toWorld(e), viewRef.current.s, e)
|
||
}
|
||
|
||
const toWorld = (ev) => {
|
||
const r = box.current.getBoundingClientRect(), v = viewRef.current
|
||
return { x: (ev.clientX - r.left - v.tx) / v.s, y: (ev.clientY - r.top - v.ty) / v.s,
|
||
inside: ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom }
|
||
}
|
||
const startToken = (e, t) => {
|
||
e.stopPropagation()
|
||
if (!t.can_move) { onSelect?.(t.id); return }
|
||
e.preventDefault()
|
||
const from = { x: e.clientX, y: e.clientY }
|
||
let moved = false, last = 0
|
||
const clampPos = (p) => ({ x: clamp(p.x, 0, map.w), y: clamp(p.y, 0, map.h) })
|
||
const mv = (ev) => {
|
||
if (!moved && Math.hypot(ev.clientX - from.x, ev.clientY - from.y) < 4) return
|
||
moved = true
|
||
const p = toWorld(ev)
|
||
setDrag({ id: t.id, ...clampPos(p), out: !p.inside })
|
||
if (t.x != null && p.inside && Date.now() - last > 120) { last = Date.now(); const c = clampPos(p); onMove(t.id, c.x, c.y) }
|
||
}
|
||
const end = (ev) => {
|
||
window.removeEventListener('pointermove', mv); window.removeEventListener('pointerup', end); window.removeEventListener('pointercancel', end)
|
||
if (!moved) { setDrag(null); onSelect?.(t.id); return }
|
||
const p = toWorld(ev)
|
||
if (!p.inside) { setDrag(null); return }
|
||
const c = clampPos(p)
|
||
Promise.resolve(onMove(t.id, c.x, c.y)).finally(() => setDrag(null)) // keep the local position until the server has it
|
||
}
|
||
window.addEventListener('pointermove', mv); window.addEventListener('pointerup', end); window.addEventListener('pointercancel', end)
|
||
}
|
||
|
||
const unplaced = tokens.filter((t) => t.x == null && t.can_move)
|
||
const d = (t) => t.size * map.grid
|
||
return (
|
||
<div className={`mapbox ${tool && tool !== 'select' ? 'drawing' : ''}`} ref={box} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up}>
|
||
<div className="mapworld" style={{ width: map.w, height: map.h, transform: `translate(${view.tx}px, ${view.ty}px) scale(${view.s})` }}>
|
||
{image && <img src={image.src} style={{ position: 'absolute', left: image.x, top: image.y }} width={image.w} height={image.h} alt="" draggable={false} />}
|
||
{showGrid && <div className="mapgrid" style={{ backgroundSize: `${map.grid}px ${map.grid}px` }} />}
|
||
{overlay}
|
||
{tokens.map((t) => {
|
||
const dr = drag?.id === t.id ? drag : null
|
||
const x = dr ? dr.x : t.x, y = dr ? dr.y : t.y
|
||
if (x == null || (dr && dr.out)) return null
|
||
return (
|
||
<div key={t.id} className={`maptoken ${t.kind} ${t.hidden ? 'hid' : ''} ${selected === t.id ? 'sel' : ''} ${t.can_move ? 'mov' : ''}`}
|
||
style={{ left: x - d(t) / 2, top: y - d(t) / 2, width: d(t), height: d(t), '--tc': t.color, fontSize: Math.max(8, d(t) * 0.45) }}
|
||
onPointerDown={(e) => startToken(e, t)} title={t.name + (t.hidden ? ' (hidden from players)' : '')}>
|
||
{portraitUrl?.(t) ? <img className="tokpic" src={portraitUrl(t)} alt="" draggable={false} /> : <span>{(t.name || '?').trim().slice(0, 2)}</span>}
|
||
<em style={{ fontSize: Math.max(9, map.grid * 0.28) }}>{t.name}</em>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
{unplaced.length > 0 && (
|
||
<div className="maptray mapui" role="group" aria-label="Tokens not on the map yet">
|
||
<span className="small muted">Drag onto the map:</span>
|
||
{unplaced.map((t) => (
|
||
<button key={t.id} className="traytoken" style={{ '--tc': t.color }} onPointerDown={(e) => startToken(e, t)} title="Drag onto the map">{t.name}</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="mapzoom mapui">
|
||
<button className="ghost" onClick={() => { const r = box.current.getBoundingClientRect(); zoomAt(r.width / 2, r.height / 2, 1.3) }} aria-label="Zoom in">+</button>
|
||
<button className="ghost" onClick={() => { const r = box.current.getBoundingClientRect(); zoomAt(r.width / 2, r.height / 2, 1 / 1.3) }} aria-label="Zoom out">-</button>
|
||
<button className="ghost" onClick={fit} aria-label="Fit the map to the window" title="Fit">⤢</button>
|
||
</div>
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Name, size, colour and visibility of one token (used in the live table and in the map editor)
|
||
export function TokenForm({ t, onChange, onDelete, onDuplicate }) {
|
||
return (
|
||
<div className="tokenform">
|
||
<input value={t.name} maxLength={40} aria-label="Token name" onChange={(e) => onChange({ name: e.target.value })} />
|
||
<label className="small chk">Size <input type="number" min="0.25" max="8" step="0.25" value={t.size} onChange={(e) => onChange({ size: Number(e.target.value) || 1 })} /> sq.</label>
|
||
<span className="swatches">{COLORS.map((c) => <button key={c} className={`swatch ${t.color === c ? 'on' : ''}`} style={{ background: c }} aria-label={`Colour ${c}`} onClick={() => onChange({ color: c })} />)}</span>
|
||
<label className="small chk"><input type="checkbox" checked={!!t.hidden} onChange={(e) => onChange({ hidden: e.target.checked })} /> Hidden from players</label>
|
||
<span className="row">
|
||
{onDuplicate && <button className="ghost" onClick={onDuplicate}>Duplicate</button>}
|
||
<button className="ghost danger" onClick={onDelete}>Remove</button>
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Picks an NPC from the library (own and shared). onPick(npc) gets {id, name, kind, size, portrait}.
|
||
export function NpcPicker({ onPick, onClose }) {
|
||
const [list, setList] = useState(null)
|
||
const [q, setQ] = useState('')
|
||
useEffect(() => { fetch('/api/characters?npc=1').then((r) => r.json()).then((d) => setList([...d.mine, ...d.shared])).catch(() => setList([])) }, [])
|
||
const shown = (list || []).filter((n) => n.name.toLowerCase().includes(q.toLowerCase()))
|
||
return (
|
||
<div className="npcpicker card" role="dialog" aria-label="NPC library">
|
||
<div className="row"><strong>NPC library</strong><button className="ghost" onClick={onClose} aria-label="Close">×</button></div>
|
||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search" aria-label="Search NPCs" />
|
||
<ul className="npcpick">
|
||
{list === null && <li className="muted small">Loading …</li>}
|
||
{list && !list.length && <li className="muted small">No NPCs yet. Create some on the <a href="#npcs">NPCs</a> page.</li>}
|
||
{shown.map((n) => (
|
||
<li key={n.id}><button className="ghost" onClick={() => onPick(n)}>
|
||
{n.portrait ? <img className="npcthumb" src={`/api/characters/${n.id}/portrait?v=${n.portrait}`} alt="" /> : <span className="npcthumb none" aria-hidden="true">?</span>}
|
||
<span>{n.name}{n.owner ? <span className="muted small"> · {n.owner}</span> : null}</span>
|
||
</button></li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const uid = () => Math.random().toString(36).slice(2, 12)
|
||
|
||
// Walls and doors as a line layer on top of the map (DM only). In the live view doors can be clicked to open or close them.
|
||
export function WallLayer({ map, walls, draft, hover, onDoor }) {
|
||
const line = (w, extra = {}) => (
|
||
<line key={w.id} x1={w.x1} y1={w.y1} x2={w.x2} y2={w.y2} vectorEffect="non-scaling-stroke"
|
||
className={`wl ${w.kind} ${w.open ? 'open' : ''}`} {...extra} />
|
||
)
|
||
return (
|
||
<svg className="walllayer" width={map.w} height={map.h} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible', pointerEvents: 'none' }}>
|
||
{walls.filter((w) => !(onDoor && w.kind === 'door')).map((w) => line(w))}
|
||
{walls.filter((w) => onDoor && w.kind === 'door').map((w) => (
|
||
<g key={w.id} className="mapdoor" onClick={() => onDoor(w)} style={{ pointerEvents: 'stroke', cursor: 'pointer' }}>
|
||
<title>{w.open ? 'Open door, click to close' : 'Closed door, click to open'}</title>
|
||
<line x1={w.x1} y1={w.y1} x2={w.x2} y2={w.y2} stroke="transparent" strokeWidth="16" vectorEffect="non-scaling-stroke" style={{ pointerEvents: 'stroke' }} />
|
||
{line(w, { style: { pointerEvents: 'none' } })}
|
||
</g>
|
||
))}
|
||
{draft && hover && <line x1={draft.x} y1={draft.y} x2={hover.x} y2={hover.y} vectorEffect="non-scaling-stroke" className="wl draft" />}
|
||
{draft && <circle cx={draft.x} cy={draft.y} r="4" vectorEffect="non-scaling-stroke" className="wldot" />}
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
const distToSeg = (p, w) => {
|
||
const dx = w.x2 - w.x1, dy = w.y2 - w.y1, L = dx * dx + dy * dy
|
||
const t = L === 0 ? 0 : clamp(((p.x - w.x1) * dx + (p.y - w.y1) * dy) / L, 0, 1)
|
||
return Math.hypot(w.x1 + t * dx - p.x, w.y1 + t * dy - p.y)
|
||
}
|
||
|
||
// Full-screen editor for a map in the DM's library: grid, enemies and NPCs, walls and doors for line of sight.
|
||
export function MapEditor({ id, onClose }) {
|
||
const [m, setM] = useState(null)
|
||
const [tokens, setTokens] = useState([])
|
||
const [walls, setWalls] = useState([])
|
||
const [name, setName] = useState('')
|
||
const [grid, setGrid] = useState(40)
|
||
const [sight, setSight] = useState(0)
|
||
const [showGrid, setShowGrid] = useState(true)
|
||
const [snap, setSnap] = useState(false)
|
||
const [tool, setTool] = useState('select')
|
||
const [draft, setDraft] = useState(null)
|
||
const [hover, setHover] = useState(null)
|
||
const [sel, setSel] = useState(null)
|
||
const [err, setErr] = useState('')
|
||
const [dirty, setDirty] = useState(false)
|
||
useEffect(() => {
|
||
fetch(`/api/maps/${id}`).then((r) => r.json()).then((d) => {
|
||
setM(d); setTokens(d.tokens.map((t) => ({ ...t, kind: 'npc', can_move: true }))); setWalls(d.walls); setName(d.name); setGrid(d.grid); setSight(d.sight || 0)
|
||
})
|
||
}, [id])
|
||
useEffect(() => {
|
||
const k = (e) => { if (e.key === 'Escape') { setDraft(null); if (tool !== 'select') setTool('select') } }
|
||
window.addEventListener('keydown', k)
|
||
return () => window.removeEventListener('keydown', k)
|
||
}, [tool])
|
||
// the full-size image, loaded once for the editor
|
||
const [image, setImage] = useState(null)
|
||
useEffect(() => { if (m) setImage({ src: `/api/maps/${id}/image`, x: 0, y: 0, w: m.w, h: m.h }) }, [m?.id]) // eslint-disable-line
|
||
if (!m) return null
|
||
const gridNum = Number(grid) >= 4 ? Number(grid) : m.grid
|
||
const cur = tokens.find((t) => t.id === sel)
|
||
const patch = (tid, p) => { setTokens((ts) => ts.map((t) => (t.id === tid ? { ...t, ...p } : t))); setDirty(true) }
|
||
const addWalls = (list) => { setWalls((w) => [...w, ...list.map((x) => ({ id: uid(), ...x }))]); setDirty(true) }
|
||
const [picker, setPicker] = useState(false)
|
||
const addNpc = (n) => {
|
||
const k = tokens.length + 1
|
||
const t = { id: uid(), kind: 'npc', can_move: true, name: n.name, npc_id: n.id, portrait: n.portrait || '', x: m.w / 2 + (k % 5) * gridNum, y: m.h / 2 + (k % 3) * gridNum, size: n.size || 1, color: COLORS[0], hidden: false }
|
||
setTokens([...tokens, t]); setSel(t.id); setDirty(true); setPicker(false)
|
||
}
|
||
const add = () => {
|
||
const n = tokens.length + 1
|
||
const t = { id: uid(), kind: 'npc', can_move: true, name: `Enemy ${n}`, x: m.w / 2 + (n % 5) * gridNum, y: m.h / 2 + (n % 3) * gridNum, size: 1, color: COLORS[0], hidden: false }
|
||
setTokens([...tokens, t]); setSel(t.id); setDirty(true)
|
||
}
|
||
const snapPt = (p, s) => { // join onto existing corners first, then the grid
|
||
let best = null, bd = 12 / s
|
||
for (const w of walls) for (const [x, y] of [[w.x1, w.y1], [w.x2, w.y2]]) { const d = Math.hypot(x - p.x, y - p.y); if (d < bd) { bd = d; best = { x, y } } }
|
||
if (best) return best
|
||
if (snap) return { x: clamp(Math.round(p.x / gridNum) * gridNum, 0, m.w), y: clamp(Math.round(p.y / gridNum) * gridNum, 0, m.h) }
|
||
return { x: clamp(p.x, 0, m.w), y: clamp(p.y, 0, m.h) }
|
||
}
|
||
const click = (world, s) => {
|
||
if (tool === 'select') return
|
||
if (tool === 'erase') {
|
||
let best = null, bd = 10 / s
|
||
for (const w of walls) { const d = distToSeg(world, w); if (d < bd) { bd = d; best = w } }
|
||
if (best) { setWalls(walls.filter((w) => w.id !== best.id)); setDirty(true) }
|
||
return
|
||
}
|
||
const p = snapPt(world, s)
|
||
if (!draft) { setDraft(p); return }
|
||
if (Math.hypot(p.x - draft.x, p.y - draft.y) < 1) { setDraft(null); return } // click the same point again: finish
|
||
if (tool === 'wall') { addWalls([{ x1: draft.x, y1: draft.y, x2: p.x, y2: p.y, kind: 'wall' }]); setDraft(p) }
|
||
else if (tool === 'door') { addWalls([{ x1: draft.x, y1: draft.y, x2: p.x, y2: p.y, kind: 'door' }]); setDraft(null) }
|
||
else if (tool === 'rect') {
|
||
addWalls([[draft.x, draft.y, p.x, draft.y], [p.x, draft.y, p.x, p.y], [p.x, p.y, draft.x, p.y], [draft.x, p.y, draft.x, draft.y]].map(([x1, y1, x2, y2]) => ({ x1, y1, x2, y2, kind: 'wall' })))
|
||
setDraft(null)
|
||
}
|
||
}
|
||
const save = async () => {
|
||
setErr('')
|
||
const r = await fetch(`/api/maps/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name, grid: Number(grid), sight: Number(sight) || 0, walls,
|
||
tokens: tokens.map(({ id: i, name: n, x, y, size, color, hidden, npc_id }) => ({ id: i, name: n, x, y, size, color, hidden, npc_id })) }) })
|
||
if (!r.ok) { setErr((await r.json().catch(() => ({}))).detail || 'Could not save'); return false }
|
||
setDirty(false); return true
|
||
}
|
||
const close = async () => { if (!dirty || window.confirm('Close without saving your changes?')) onClose() }
|
||
const tools = [['select', 'Move tokens'], ['wall', 'Wall'], ['rect', 'Room'], ['door', 'Door'], ['erase', 'Erase']]
|
||
return createPortal(
|
||
<div className="mapeditor" role="dialog" aria-label="Map editor">
|
||
<aside className="card">
|
||
<h3>Map editor</h3>
|
||
<label className="small">Name <input value={name} maxLength={60} onChange={(e) => { setName(e.target.value); setDirty(true) }} /></label>
|
||
<label className="small">Grid: pixels per square <input type="number" min="4" value={grid} onChange={(e) => { setGrid(e.target.value); setDirty(true) }} /></label>
|
||
<label className="small chk"><input type="checkbox" checked={showGrid} onChange={(e) => setShowGrid(e.target.checked)} /> Show grid</label>
|
||
<p className="small muted">Match the grid to the squares on your image. Token sizes are counted in squares.</p>
|
||
<h4>Walls and doors</h4>
|
||
<div className="toolrow" role="group" aria-label="Tool">
|
||
{tools.map(([k, label]) => <button key={k} className={tool === k ? 'on' : 'ghost'} aria-pressed={tool === k} onClick={() => { setTool(k); setDraft(null) }}>{label}</button>)}
|
||
</div>
|
||
<label className="small chk"><input type="checkbox" checked={snap} onChange={(e) => setSnap(e.target.checked)} /> Snap to grid corners</label>
|
||
<p className="small muted">
|
||
{tool === 'wall' && 'Click to set points, the line continues from the last one. Click the same point twice or press Esc to stop.'}
|
||
{tool === 'rect' && 'Click two opposite corners of a room.'}
|
||
{tool === 'door' && 'Click both ends of the door. The DM opens and closes it during play.'}
|
||
{tool === 'erase' && 'Click a wall or door to remove it.'}
|
||
{tool === 'select' && `${walls.length} wall segment${walls.length === 1 ? '' : 's'}. Walls end at existing corners automatically. Players only see what their token can see past them.`}
|
||
</p>
|
||
<div className="row">
|
||
<button className="ghost" disabled={!walls.length} onClick={() => { setWalls(walls.slice(0, -1)); setDirty(true) }}>Undo last</button>
|
||
<button className="ghost danger" disabled={!walls.length} onClick={() => window.confirm('Remove all walls and doors of this map?') && (setWalls([]), setDirty(true))}>Remove all</button>
|
||
</div>
|
||
<label className="small">Sight range in squares (0 = unlimited)
|
||
<input type="number" min="0" max="1000" value={sight} onChange={(e) => { setSight(e.target.value); setDirty(true) }} /></label>
|
||
<h4>Enemies and NPCs</h4>
|
||
<div className="row"><button className="on" onClick={add}>+ Add token</button><button className="ghost" onClick={() => setPicker(!picker)}>+ From NPC library</button></div>
|
||
{picker && <NpcPicker onPick={addNpc} onClose={() => setPicker(false)} />}
|
||
<ul className="tokenlist">
|
||
{tokens.map((t) => <li key={t.id}><button className={sel === t.id ? 'on' : 'ghost'} onClick={() => setSel(t.id)}><i style={{ background: t.color }} /> {t.name}{t.npc_id ? ' ★' : ''}{t.hidden ? ' (hidden)' : ''}</button></li>)}
|
||
{!tokens.length && <li className="muted small">None yet. Add a token, then drag it onto the map.</li>}
|
||
</ul>
|
||
{cur && <TokenForm t={cur} onChange={(p) => patch(cur.id, p)}
|
||
onDelete={() => { setTokens(tokens.filter((t) => t.id !== cur.id)); setSel(null); setDirty(true) }}
|
||
onDuplicate={() => { const c = { ...cur, id: uid(), name: cur.name.replace(/\s*\d*$/, '') + ' ' + (tokens.length + 1), x: cur.x + gridNum, y: cur.y + gridNum }; setTokens([...tokens, c]); setSel(c.id); setDirty(true) }} />}
|
||
{err && <p className="warn small">{err}</p>}
|
||
<div className="row editactions"><button className="on" disabled={!dirty} onClick={save}>Save</button><button className="ghost" onClick={close}>Close</button></div>
|
||
</aside>
|
||
<div className="mapstage">
|
||
<MapView map={{ ...m, grid: gridNum }} image={image} tokens={tokens} portraitUrl={(t) => (t.npc_id && t.portrait ? `/api/characters/${t.npc_id}/portrait?v=${t.portrait}` : null)} grid={showGrid} selected={sel} onSelect={setSel} tool={tool}
|
||
onClick={click} onHover={draft ? (p, s) => setHover(p) : undefined}
|
||
overlay={<WallLayer map={m} walls={walls} draft={draft} hover={draft ? hover : null} />}
|
||
onMove={(tid, x, y) => { patch(tid, { x, y }); return Promise.resolve() }} />
|
||
</div>
|
||
</div>, document.body)
|
||
}
|
||
|
||
// DM: the map library (in the "Lobby actions" tab)
|
||
export function MapLibrary({ activeId, lobbyId, act }) {
|
||
const [list, setList] = useState([])
|
||
const [err, setErr] = useState('')
|
||
const [editing, setEditing] = useState(null)
|
||
const [busy, setBusy] = useState(false)
|
||
const load = useCallback(() => fetch('/api/maps').then((r) => (r.ok ? r.json() : [])).then(setList).catch(() => {}), [])
|
||
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 })
|
||
setBusy(false)
|
||
if (!r.ok) { setErr((await r.json().catch(() => ({}))).detail || 'Upload failed'); return }
|
||
load()
|
||
}
|
||
return (
|
||
<div className="maplib">
|
||
<h4>Maps</h4>
|
||
<label className="uploadbtn">{busy ? 'Uploading…' : '+ Upload map (PNG, JPEG, WebP)'}<input type="file" accept="image/png,image/jpeg,image/webp" onChange={upload} disabled={busy} hidden /></label>
|
||
{err && <p className="warn small">{err}</p>}
|
||
<ul className="maplist">
|
||
{list.map((m) => (
|
||
<li key={m.id} className={m.id === activeId ? 'active' : ''}>
|
||
<strong>{m.name}</strong> <span className="muted small">{m.w}×{m.h}, {m.tokens} token{m.tokens === 1 ? '' : 's'}, {m.walls} wall{m.walls === 1 ? '' : 's'}{!m.mine && ` · from ${m.author}`}</span>
|
||
<span className="row">
|
||
{m.id === activeId
|
||
? <button className="ghost" onClick={() => act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: null })}>Clear table</button>
|
||
: <button className="on" onClick={() => (!activeId || window.confirm('Replace the current map? Tokens on it are reset to the prepared ones.')) && act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: m.id })}>Show on table</button>}
|
||
{m.mine && <button className="ghost" onClick={() => setEditing(m.id)}>Edit</button>}
|
||
{m.mine && <button className="ghost danger" onClick={() => window.confirm(`Delete the map "${m.name}"?`) && fetch(`/api/maps/${m.id}`, { method: 'DELETE' }).then(load)}>Delete</button>}
|
||
</span>
|
||
</li>
|
||
))}
|
||
{!list.length && <li className="muted small">No maps yet. Upload an image, then prepare enemies in the editor.</li>}
|
||
</ul>
|
||
{editing && <MapEditor id={editing} onClose={() => setEditing(null)} />}
|
||
</div>
|
||
)
|
||
}
|