Rules editor: admins can edit items, skills, professions (and categories/groups) in the app

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-22 14:48:55 +00:00
parent a8c9740a05
commit 78f0de5333
9 changed files with 453 additions and 6 deletions
+3 -1
View File
@@ -6,6 +6,7 @@ import Presets from './Presets.jsx'
import Lobbies from './Lobbies.jsx'
import Login from './Login.jsx'
import Account from './Account.jsx'
import Editor from './Editor.jsx'
import { claimLegacy } from './legacy.js'
const useHash = () => {
@@ -53,7 +54,7 @@ export default function App() {
if (!auth.user) return <main><Login registrationOpen={auth.registration_open} onDone={(u) => setAuth({ ...auth, user: u })} /></main>
const page = hash.split('/')[0]
const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters'], ['lobbies', 'Lobbies']]
const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters'], ['lobbies', 'Lobbies'], ...(auth.user.admin ? [['editor', 'Editor']] : [])]
return (
<>
@@ -81,6 +82,7 @@ export default function App() {
{rules && page === 'lobbies' && (
<Lobbies section={hash.split('/')[1]} rules={rules} user={username} onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} />
)}
{rules && page === 'editor' && auth.user.admin && <Editor rules={rules} onReload={loadRules} />}
{page === 'account' && <Account user={username} onLogout={logout} />}
</main>
</>
+185
View File
@@ -0,0 +1,185 @@
import { useEffect, useMemo, useState } from 'react'
const J = { 'Content-Type': 'application/json' }
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40)
// What can be edited: fields per entry and the columns of the list
const defs = (saved, attrs) => {
const cats = (saved.equipment_categories || []).map((c) => [c.id, c.name])
const groups = (saved.skill_groups || []).map((g) => [g.id, g.name])
const skills = (saved.skills || []).map((s) => [s.id, s.name])
const name = (list, id) => (list.find(([k]) => k === id) || [])[1] || id
return {
equipment: {
label: 'Items', noun: 'item',
fields: [['name', 'Name', 'text'], ['cat', 'Category', 'select', cats], ['stat', 'Stat (damage, armor ...)', 'text'], ['tier', 'Tier (optional, e.g. light)', 'text'],
['kg', 'Weight (kg)', 'number'], ['note', 'Note', 'text'], ['placeholder', 'Placeholder (still open in the brainstorm)', 'bool']],
blank: { cat: cats[0]?.[0] || '', stat: '', kg: 1, note: '' },
cols: [['Name', (e) => e.name], ['Category', (e) => name(cats, e.cat)], ['Stat', (e) => `${e.stat}${e.tier ? ` (${e.tier})` : ''}`], ['kg', (e) => e.kg]],
},
equipment_categories: {
label: 'Item categories', noun: 'category',
fields: [['name', 'Name', 'text'], ['stat', 'Name of the stat column', 'text']],
blank: { stat: '' }, cols: [['Name', (e) => e.name], ['Stat column', (e) => e.stat]],
},
skills: {
label: 'Skills', noun: 'skill',
fields: [['name', 'Name', 'text'], ['group', 'Group', 'select', groups], ['type', 'Type', 'select', [['basic', 'Basic'], ['advanced', 'Advanced']]],
['attrs', 'Attributes (the higher one counts)', 'multi', attrs], ['desc', 'Description', 'textarea'], ['placeholder', 'Placeholder (still open in the brainstorm)', 'bool']],
blank: { group: groups[0]?.[0] || '', type: 'basic', attrs: [], desc: '' },
cols: [['Name', (e) => e.name], ['Group', (e) => name(groups, e.group)], ['Type', (e) => e.type], ['Attributes', (e) => e.attrs.map((a) => name(attrs, a)).join(' / ')]],
},
skill_groups: {
label: 'Skill groups', noun: 'group', fields: [['name', 'Name', 'text']], blank: {}, cols: [['Name', (e) => e.name]],
},
professions: {
label: 'Professions', noun: 'profession',
fields: [['name', 'Name', 'text'], ['desc', 'Description', 'textarea'], ['bonus_attrs', 'Attributes that can get the profession bonus', 'multi', attrs],
['proficiencies', 'Skills with fixed proficiency', 'multi', skills], ['picks', 'Free proficiency picks', 'number'], ['note', 'Note', 'text'],
['placeholder', 'Placeholder (still open in the brainstorm)', 'bool']],
blank: { desc: '', bonus_attrs: [], proficiencies: [], picks: 0 },
cols: [['Name', (e) => e.name], ['Bonus', (e) => e.bonus_attrs.map((a) => name(attrs, a)).join(', ')], ['Proficiencies', (e) => e.proficiencies.map((s) => name(skills, s)).join(', ')], ['Picks', (e) => e.picks]],
},
}
}
function Field({ f, value, onChange }) {
const [key, label, type, opts] = f
if (type === 'bool') return <label className="small"><input type="checkbox" checked={!!value} onChange={(e) => onChange(e.target.checked)} style={{ width: 'auto' }} /> {label}</label>
return (
<label className="edfield">
<span className="small muted">{label}</span>
{type === 'text' && <input value={value ?? ''} onChange={(e) => onChange(e.target.value)} />}
{type === 'number' && <input type="number" step="any" value={value ?? ''} onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))} />}
{type === 'textarea' && <textarea rows={3} value={value ?? ''} onChange={(e) => onChange(e.target.value)} />}
{type === 'select' && <select value={value ?? ''} onChange={(e) => onChange(e.target.value)}>{opts.map(([k, n]) => <option key={k} value={k}>{n}</option>)}</select>}
{type === 'multi' && (
<span className="multi">
{opts.map(([k, n]) => (
<label key={k} className="small"><input type="checkbox" style={{ width: 'auto' }} checked={(value || []).includes(k)}
onChange={(e) => onChange(e.target.checked ? [...(value || []), k] : (value || []).filter((x) => x !== k))} /> {n}</label>
))}
</span>
)}
</label>
)
}
export default function Editor({ rules, onReload }) {
const [state, setState] = useState(null)
const [tab, setTab] = useState('equipment')
const [drafts, setDrafts] = useState({}) // section -> unsaved list
const [editing, setEditing] = useState('')
const [q, setQ] = useState('')
const [msg, setMsg] = useState({ text: '', bad: false })
const [busy, setBusy] = useState(false)
const load = () => fetch('/api/admin/rules').then(async (r) => {
if (!r.ok) throw new Error(r.status)
setState(await r.json())
}).catch((e) => setMsg({ text: String(e.message) === '403' ? 'Only admins can use the rules editor.' : 'Could not load the editor.', bad: true }))
useEffect(() => { load() }, [])
const D = useMemo(() => state && defs(state.sections, rules.attributes.map((a) => [a.id, a.name])), [state, rules.attributes])
if (!state) return <section className="page"><p className={msg.bad ? 'warn' : 'muted'}>{msg.text || 'Loading …'}</p></section>
const d = D[tab]
const saved = state.sections[tab]
const list = drafts[tab] || saved
const dirty = !!drafts[tab]
const setList = (l) => setDrafts((x) => ({ ...x, [tab]: l }))
const patch = (id, key, v) => setList(list.map((e) => {
if (e.id !== id) return e
const n = { ...e, [key]: v }
if (e._new && key === 'name') { // new entries get their id from the name
let base = slug(v) || 'entry', cand = base, i = 2
while (list.some((x) => x !== e && x.id === cand)) cand = `${base}_${i++}`
const old = e.id
n.id = cand
if (editing === old) setEditing(cand)
}
return n
}))
const add = () => {
let id = 'new_entry', i = 2
while (list.some((x) => x.id === id)) id = `new_entry_${i++}`
setList([...list, { id, name: '', ...d.blank, _new: true }])
setEditing(id); setQ('')
}
const del = (e) => { if (editing === e.id) setEditing(''); setList(list.filter((x) => x.id !== e.id)) }
const save = async (confirm = false) => {
setBusy(true); setMsg({ text: '', bad: false })
const items = list.map(({ _new, ...e }) => e)
const r = await fetch(`/api/admin/rules/${tab}`, { method: 'PUT', headers: J, body: JSON.stringify({ items, confirm }) }).catch(() => null)
const body = r ? await r.json().catch(() => ({})) : {}
setBusy(false)
if (r && r.status === 409 && window.confirm(`${body.detail}\n\nSave anyway?`)) return save(true)
if (!r || !r.ok) return setMsg({ text: typeof body.detail === 'string' ? body.detail : 'Could not save.', bad: true })
setDrafts((x) => { const n = { ...x }; delete n[tab]; return n })
setEditing(''); setMsg({ text: `Saved ${body.count} entries. Everybody sees the change after reloading the page.`, bad: false })
await load(); onReload?.()
}
const reset = async (confirm = false) => {
if (!confirm && !window.confirm(`Throw away all your changes to "${d.label}" and go back to the shipped rules?`)) return
const r = await fetch(`/api/admin/rules/${tab}?confirm=${confirm}`, { method: 'DELETE' }).catch(() => null)
const body = r ? await r.json().catch(() => ({})) : {}
if (r && r.status === 409 && window.confirm(`${body.detail}\n\nReset anyway?`)) return reset(true)
if (!r || !r.ok) return setMsg({ text: typeof body.detail === 'string' ? body.detail : 'Could not reset.', bad: true })
setDrafts((x) => { const n = { ...x }; delete n[tab]; return n })
setMsg({ text: 'Back to the shipped rules.', bad: false })
await load(); onReload?.()
}
const shown = list.filter((e) => !q || e.name.toLowerCase().includes(q.toLowerCase()) || e.id.includes(q.toLowerCase()))
const cur = list.find((e) => e.id === editing)
return (
<section className="page editor">
<h1>Rules editor</h1>
<p className="muted small">Changes are stored next to the database and replace the entries of rules.json for that list; the shipped file stays as it is.
Existing characters keep working. Talents, mutations and implants are still edited in rules.json.</p>
<div className="tabs" role="tablist">
{Object.entries(D).map(([id, x]) => (
<button key={id} role="tab" aria-selected={tab === id} className={tab === id ? 'on' : ''} onClick={() => { setTab(id); setEditing(''); setQ(''); setMsg({ text: '', bad: false }) }}>
{x.label}{drafts[id] ? ' *' : ''}{state.customized.includes(id) ? ' •' : ''}
</button>
))}
</div>
<div className="row wrapform">
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder={`Search ${d.label.toLowerCase()} …`} aria-label="Search" style={{ maxWidth: '18rem' }} />
<span className="muted small">{list.length} entries{state.customized.includes(tab) ? ' · customised (•)' : ' · as shipped'}</span>
<span className="actions">
<button onClick={add}>+ Add {d.noun}</button>
<button onClick={() => save()} disabled={!dirty || busy}>Save changes</button>
<button className="ghost" onClick={() => { setDrafts((x) => { const n = { ...x }; delete n[tab]; return n }); setEditing('') }} disabled={!dirty}>Discard</button>
<button className="ghost danger" onClick={() => reset()} disabled={!state.customized.includes(tab)}>Reset to shipped</button>
</span>
</div>
{msg.text && <p className={msg.bad ? 'warn' : 'small'} role={msg.bad ? 'alert' : 'status'}>{msg.text}</p>}
{cur && (
<div className="card edform">
<div className="row"><strong>{cur.name || `New ${d.noun}`}</strong><span className="muted small">id: {cur.id}{cur._new ? ' (from the name)' : ''}</span></div>
<div className="edgrid">
{d.fields.map((f) => <Field key={f[0]} f={f} value={cur[f[0]]} onChange={(v) => patch(cur.id, f[0], v)} />)}
</div>
<div className="actions"><button className="ghost" onClick={() => setEditing('')}>Done</button></div>
</div>
)}
<div className="tablewrap"><table className="edtable">
<thead><tr>{d.cols.map(([h]) => <th key={h}>{h}</th>)}<th /></tr></thead>
<tbody>
{shown.map((e) => (
<tr key={e.id} className={editing === e.id ? 'sel' : ''}>
{d.cols.map(([h, fn]) => <td key={h}>{fn(e)}{h === 'Name' && e.placeholder ? <span className="badge">Placeholder</span> : ''}</td>)}
<td className="actions"><button className="ghost" onClick={() => setEditing(e.id)}>Edit</button><button className="ghost danger" onClick={() => del(e)}>Delete</button></td>
</tr>
))}
{shown.length === 0 && <tr><td colSpan={d.cols.length + 1} className="muted">Nothing found.</td></tr>}
</tbody>
</table></div>
</section>
)
}
+9
View File
@@ -385,3 +385,12 @@ body:has(.lobbychat) main { padding: .5rem calc(var(--chatw) + .5rem) 0 .5rem; }
.tablelog .trayroll { padding: .2rem .6rem; border-radius: 8px; font-size: .85rem; background: color-mix(in srgb, var(--panel) 88%, transparent); border: 1px solid var(--line); border-top: 3px solid var(--line); box-shadow: none; }
@media (prefers-reduced-motion: reduce) { .thrown { animation: none; } }
.stepper .step5 { font-size: .75rem; padding: .25rem .4rem; min-width: 2.2rem; }
/* rules editor */
.editor .edform { margin: .8rem 0; display: grid; gap: .6rem; }
.edgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: .6rem .9rem; }
.edfield { display: grid; gap: .2rem; }
.edfield .multi { display: flex; flex-wrap: wrap; gap: .3rem .9rem; padding: .3rem 0; max-height: 11rem; overflow: auto; }
.edtable td.actions { text-align: right; white-space: nowrap; }
.edtable tr.sel { background: var(--panel2); }
.editor .tabs { margin-top: .6rem; }