- 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
126 lines
5.6 KiB
React
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>
|
|
)
|
|
}
|