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:
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user