- 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
223 lines
13 KiB
React
223 lines
13 KiB
React
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>
|
|
)
|
|
}
|
|
|
|
// Admins can make other accounts admins (and take it away again)
|
|
function AdminUsers() {
|
|
const [users, setUsers] = useState(null)
|
|
const [err, setErr] = useState('')
|
|
const [open, setOpen] = useState(false)
|
|
const load = () => fetch('/api/admin/users').then((r) => (r.ok ? r.json() : Promise.reject())).then(setUsers).catch(() => setErr('Could not load the users.'))
|
|
useEffect(() => { if (open && !users) load() }, [open]) // eslint-disable-line
|
|
const set = async (u, admin) => {
|
|
setErr('')
|
|
const r = await fetch(`/api/admin/users/${encodeURIComponent(u.username)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ admin }) })
|
|
if (!r.ok) setErr((await r.json().catch(() => ({}))).detail || 'That did not work.')
|
|
load()
|
|
}
|
|
return (
|
|
<div className="card adminusers">
|
|
<button className="linklike" aria-expanded={open} onClick={() => setOpen(!open)}>{open ? '▾' : '▸'} Users and admins</button>
|
|
{open && (
|
|
<>
|
|
<p className="small muted">Admins can use this editor and make other users admins. Admins named in <code>AW_ADMINS</code> on the server can only be changed there.</p>
|
|
{err && <p className="warn small">{err}</p>}
|
|
<ul className="userlist">
|
|
{(users || []).map((u) => (
|
|
<li key={u.username} className="row">
|
|
<span><strong>{u.username}</strong>{u.me ? ' (you)' : ''} {u.admin && <span className="chip on">admin</span>}{u.fixed && <span className="muted small"> set on the server</span>}</span>
|
|
{u.admin
|
|
? <button className="ghost" disabled={u.fixed} onClick={() => window.confirm(`Take admin rights from ${u.username}?`) && set(u, false)}>Remove admin</button>
|
|
: <button className="ghost" onClick={() => window.confirm(`Make ${u.username} an admin? Admins can change the rules and make others admins.`) && set(u, true)}>Make admin</button>}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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>
|
|
<AdminUsers />
|
|
<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>
|
|
)
|
|
}
|