first commit

This commit is contained in:
DuOtto
2026-10-19 22:27:05 +02:00
commit afbdc4b139
28 changed files with 4242 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
import { useState } from 'react'
const KEY = 'aw_presets' // {presetId: editToken}, only for presets created in this browser
const loadTokens = () => { try { return JSON.parse(localStorage.getItem(KEY)) || {} } catch { return {} } }
const saveTokens = (t) => { try { localStorage.setItem(KEY, JSON.stringify(t)) } catch { /* storage blocked */ } }
const BLANK = { id: null, name: '', desc: '', creation: {} }
export default function Presets({ rules, reload }) {
const [tokens, setTokens] = useState(loadTokens)
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 || []
const current = draft || presets.find((p) => p.id === sel) || presets[0]
const editable = !!draft
const own = !!current?.id && !!tokens[current.id]
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', ...(draft.id ? { 'X-Edit-Token': tokens[draft.id] } : {}) },
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
const t = { ...tokens, [id]: data.token }
saveTokens(t); setTokens(t)
}
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', headers: { 'X-Edit-Token': tokens[current.id] } })
if (!res.ok) throw new Error('Deleting failed')
const t = { ...tokens }; delete t[current.id]
saveTokens(t); setTokens(t)
await reload(); setSel(rules.default_preset || 'standard')
} catch (e) { flash(String(e.message || e)) }
}
const kind = (p) => (p.builtin ? 'Built in' : tokens[p.id] ? 'Yours' : '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 browser 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>
)
}