NPC editor, NPC tokens from library, admin management

- 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
This commit is contained in:
Claude
2026-10-23 21:36:09 +00:00
parent 57f262b6aa
commit 1db8a45cae
13 changed files with 364 additions and 86 deletions
+35 -4
View File
@@ -165,6 +165,30 @@ export function TokenForm({ t, onChange, onDelete, onDuplicate }) {
)
}
// 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.
@@ -229,6 +253,12 @@ export function MapEditor({ id, onClose }) {
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 }
@@ -263,7 +293,7 @@ export function MapEditor({ id, onClose }) {
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 }) => ({ id: i, name: n, x, y, size, color, hidden })) }) })
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
}
@@ -296,9 +326,10 @@ export function MapEditor({ id, onClose }) {
<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>
<button className="on" onClick={add}>+ Add token</button>
<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.hidden ? ' (hidden)' : ''}</button></li>)}
{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)}
@@ -308,7 +339,7 @@ export function MapEditor({ id, onClose }) {
<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} grid={showGrid} selected={sel} onSelect={setSel} tool={tool}
<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() }} />