Files
Claude 1db8a45cae 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
2026-10-23 21:36:09 +00:00

126 lines
5.6 KiB
React

import { useState } from 'react'
const BLANK = { id: null, name: '', desc: '', creation: {} }
export default function Presets({ rules, reload }) {
const [sel, setSel] = useState(rules.default_preset || 'standard')
const [draft, setDraft] = useState(null) // preset being edited/created, null = viewing
const [msg, setMsg] = useState('')
const presets = (rules.presets || []).filter((p) => !p.npc)
const current = draft || presets.find((p) => p.id === sel) || presets[0]
const editable = !!draft
const own = !!current?.mine
const base = rules.creation
const groups = [...new Set((rules.tunables || []).map((x) => x.group))]
const value = (k) => (current.creation?.[k] ?? base[k])
const flash = (t) => { setMsg(t); setTimeout(() => setMsg(''), 4000) }
const startNew = (from) => setMsg('') || setDraft({ id: null, name: from ? `${from.name} (copy)` : '', desc: from?.desc || '', creation: { ...(from?.creation || {}) } })
const edit = () => setMsg('') || setDraft({ id: current.id, name: current.name, desc: current.desc || '', creation: { ...current.creation } })
const setVal = (x, raw) => {
const v = parseInt(raw, 10)
if (!Number.isFinite(v)) return
setDraft((d) => ({ ...d, creation: { ...d.creation, [x.key]: Math.max(x.min, Math.min(x.max, v)) } }))
}
const save = async () => {
const body = { name: draft.name, desc: draft.desc, creation: draft.creation }
if (!draft.name.trim()) return flash('Give the preset a name.')
try {
const res = await fetch(draft.id ? `/api/presets/${draft.id}` : '/api/presets', {
method: draft.id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || 'Saving failed')
const data = await res.json()
let id = draft.id
if (!id) id = data.id
await reload()
setSel(id); setDraft(null); flash('Preset saved.')
} catch (e) { flash(String(e.message || e)) }
}
const remove = async () => {
if (!window.confirm(`Delete the preset "${current.name}"? Characters that already use it keep their numbers.`)) return
try {
const res = await fetch(`/api/presets/${current.id}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Deleting failed')
await reload(); setSel(rules.default_preset || 'standard')
} catch (e) { flash(String(e.message || e)) }
}
const kind = (p) => (p.builtin ? 'Built in' : p.mine ? 'Yours' : p.author ? `By ${p.author}` : 'Shared')
return (
<section className="page">
<h1>Rules presets</h1>
<p className="muted narrow">
A preset is a named set of creation numbers (points for attributes and skills, talents, proficiency ...). Pick one on the Concept page of the creator.
Presets are stored on the server, so everyone using this site can use them; only the account that created a preset can change or delete it.
Built-in presets come from <code>rules.json</code>. Duplicate one to make your own version.
</p>
<div className="presets-layout">
<aside>
<ul className="presetlist">
{presets.map((p) => (
<li key={p.id}>
<button className={`card choice ${!draft && current?.id === p.id ? 'on' : ''}`} onClick={() => { setMsg(''); setDraft(null); setSel(p.id) }}>
<strong>{p.name}</strong>
<span className="small muted">{kind(p)}</span>
</button>
</li>
))}
</ul>
<button className="ghost" onClick={() => startNew(null)}>+ New preset</button>
</aside>
<div className="card">
{editable ? (
<>
<h2>{draft.id ? 'Edit preset' : 'New preset'}</h2>
<label>Name<input value={draft.name} maxLength={60} onChange={(e) => setDraft({ ...draft, name: e.target.value })} /></label>
<label>Description<input value={draft.desc} maxLength={300} onChange={(e) => setDraft({ ...draft, desc: e.target.value })} /></label>
</>
) : (
<>
<h2>{current.name} <span className="badge">{kind(current)}</span></h2>
<p className="muted">{current.desc || 'No description.'}</p>
</>
)}
{groups.map((g) => (
<div key={g}><h4>{g}</h4>
<div className="tunables">
{rules.tunables.filter((x) => x.group === g).map((x) => (
<label key={x.key}>{x.label}
<input type="number" min={x.min} max={x.max} value={value(x.key)} readOnly={!editable} disabled={!editable}
onChange={(e) => setVal(x, e.target.value)} />
{value(x.key) !== base[x.key] && <span className="small muted"> Standard: {base[x.key]}</span>}
</label>
))}
</div>
</div>
))}
{msg && <p className="toast">{msg}</p>}
<div className="actions wrap">
{editable ? (
<>
<button onClick={save}>Save preset</button>
<button className="ghost" onClick={() => setDraft(null)}>Cancel</button>
</>
) : (
<>
{own && <button onClick={edit}>Edit</button>}
<button className="ghost" onClick={() => startNew(current)}>Duplicate</button>
{own && <button className="ghost danger" onClick={remove}>Delete</button>}
</>
)}
</div>
</div>
</div>
</section>
)
}