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
+2
View File
@@ -5,3 +5,5 @@ AW_REGISTRATION_CODE=
AW_TRUST_PROXY=0 AW_TRUST_PROXY=0
# Port on the host (bound to 127.0.0.1 only). nginx proxies to it. Pick a free one (check: ss -tlnp | grep :8090): # Port on the host (bound to 127.0.0.1 only). nginx proxies to it. Pick a free one (check: ss -tlnp | grep :8090):
AW_PORT=8090 AW_PORT=8090
# Who may use the rules editor (comma separated usernames). Empty = only the first account that was ever created:
AW_ADMINS=
+11 -1
View File
@@ -51,7 +51,7 @@ docker compose up -d --build
curl -sI http://127.0.0.1:8090 | head -1 # should print HTTP/1.1 200 OK curl -sI http://127.0.0.1:8090 | head -1 # should print HTTP/1.1 200 OK
``` ```
The container only listens on `127.0.0.1:8090` (change it with `AW_PORT` in `.env` if the port is taken, e.g. AMP uses 8080), so it is not reachable from outside until nginx forwards to it. The database is `./db/aw.db`. The container only listens on `127.0.0.1:8090` (change it with `AW_PORT` in `.env` if the port is taken, e.g. AMP uses 8080), so it is not reachable from outside until nginx forwards to it. The database is `./db/aw.db`; changes from the rules editor are in `./db/rules_overrides.json` (back up the whole `db` folder). Set `AW_ADMINS` in `.env` to choose who may use the editor (default: the first account).
## 4. nginx + HTTPS ## 4. nginx + HTTPS
@@ -118,3 +118,13 @@ docker compose down # stop
``` ```
If `docker` says it cannot connect to the daemon, check the two `export` lines from step 1 and `systemctl --user status docker`. If `docker` says it cannot connect to the daemon, check the two `export` lines from step 1 and `systemctl --user status docker`.
## Updating
```bash
cd ~/AWCharacterCreator
git pull
docker compose up -d --build
```
The `db` folder (database and rules editor changes) is kept. New settings from `.env.example` have to be added to your `.env` by hand.
+9
View File
@@ -86,6 +86,15 @@ Each wizard page has a **Reset this page** button that clears only that page's d
The Summary page and the sheet tabs in the lobbies show the character as **A4 pages**: page 1 has name, attributes, derived stats and all skills (proficiency dots, attribute, points, total), page 2 has talents, mutations and implants, equipment, background and a ruled notes area. On the Summary page, **Print / PDF (A4)** opens just the pages in a clean window; choose "Save as PDF" in the print dialog (margins none, background graphics on if your browser asks). Hover or tap a value to see how it is made up (not printed). **Light / Dark** (in the lobby it sits above the sheet) switches the colours of the sheet on screen (remembered in the browser; printed pages are always light). The Word and PDF downloads on the Summary page are separate exports with their own layout. The Summary page and the sheet tabs in the lobbies show the character as **A4 pages**: page 1 has name, attributes, derived stats and all skills (proficiency dots, attribute, points, total), page 2 has talents, mutations and implants, equipment, background and a ruled notes area. On the Summary page, **Print / PDF (A4)** opens just the pages in a clean window; choose "Save as PDF" in the print dialog (margins none, background graphics on if your browser asks). Hover or tap a value to see how it is made up (not printed). **Light / Dark** (in the lobby it sits above the sheet) switches the colours of the sheet on screen (remembered in the browser; printed pages are always light). The Word and PDF downloads on the Summary page are separate exports with their own layout.
### Rules editor
Admins get an **Editor** tab to add, change and delete **items, item categories, skills, skill groups and professions** with forms. Admins are the usernames in `AW_ADMINS` (comma separated, set in `.env`); without that setting only the first account ever created is admin.
- Changes are saved in `db/rules_overrides.json` (next to the database, so it is part of your backup). For each edited list it **replaces** the list from `rules.json`; the shipped file is not touched. A previous copy is kept as `rules_overrides.json.bak`. "Reset to shipped" removes the override of one list.
- New entries get their id from the name (lowercase, `_`). Ids of existing entries cannot be changed, because characters refer to them.
- The server checks every reference: an item needs an existing category, a skill a group and attributes, a profession existing skills. Removing a category, group or skill that is still used is refused. Removing an item, skill or profession that saved characters use asks for confirmation first (their data stays, the entry disappears from the sheets).
- Talents, mutations, implants, classes and the rules text are still edited in `rules.json`. Players see changes after reloading the page.
### Lobbies ### Lobbies
The **Lobbies** tab groups a campaign. Whoever creates a lobby is its DM and gets an 8-character join code. Players join with the code and pick one of their saved characters (they can switch it later or leave). The **Lobbies** tab groups a campaign. Whoever creates a lobby is its DM and gets an 8-character join code. Players join with the code and pick one of their saved characters (they can switch it later or leave).
+107 -4
View File
@@ -13,6 +13,7 @@ from fastapi.staticfiles import StaticFiles
from . import auth from . import auth
from .derive import compute from .derive import compute
from . import rules_edit
from .export import to_docx, to_pdf from .export import to_docx, to_pdf
BASE = Path(__file__).resolve().parent.parent BASE = Path(__file__).resolve().parent.parent
@@ -41,11 +42,30 @@ TRUST_PROXY = os.environ.get("AW_TRUST_PROXY", "") == "1" # behind a reverse pr
def load_rules() -> dict: # Changes made in the rules editor live next to the database (writable), the shipped rules.json stays untouched.
# Re-read on every request so rules.json can be edited without a restart. OVERRIDES_PATH = Path(DB_PATH).parent / "rules_overrides.json"
def base_rules() -> dict:
return json.loads(RULES_PATH.read_text(encoding="utf-8")) return json.loads(RULES_PATH.read_text(encoding="utf-8"))
def read_overrides() -> dict:
try:
return json.loads(OVERRIDES_PATH.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def load_rules() -> dict:
# Re-read on every request so rules.json can be edited without a restart; editor changes replace whole sections.
r = base_rules()
for sec, items in read_overrides().items():
if sec in rules_edit.SECTIONS:
r[sec] = items
return r
def db() -> sqlite3.Connection: def db() -> sqlite3.Connection:
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(DB_PATH) con = sqlite3.connect(DB_PATH)
@@ -154,6 +174,16 @@ async def security(request: Request, call_next):
return resp return resp
def is_admin(user: dict) -> bool:
"""Admins (rules editor): the names in AW_ADMINS, or without that setting the very first account."""
names = {n.strip().lower() for n in os.environ.get("AW_ADMINS", "").split(",") if n.strip()}
if names:
return user["username"].lower() in names
with db() as con:
first = con.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
return bool(first) and first["id"] == user["id"]
def current_user(request: Request) -> dict: def current_user(request: Request) -> dict:
with db() as con: with db() as con:
user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE)) user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE))
@@ -187,6 +217,8 @@ def check_password_rules(pw: str):
def me(request: Request): def me(request: Request):
with db() as con: with db() as con:
user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE)) user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE))
if user:
user = {**user, "admin": is_admin(user)}
return {"user": user, "registration_open": bool(auth.registration_code())} return {"user": user, "registration_open": bool(auth.registration_code())}
@@ -213,7 +245,8 @@ def register(request: Request, response: Response, body: dict = Body(...)):
(username, auth.hash_password(password), time.time())) (username, auth.hash_password(password), time.time()))
token = auth.create_session(con, cur.lastrowid) token = auth.create_session(con, cur.lastrowid)
set_cookie(request, response, token) set_cookie(request, response, token)
return {"user": {"id": cur.lastrowid, "username": username}} u = {"id": cur.lastrowid, "username": username}
return {"user": {**u, "admin": is_admin(u)}}
@app.post("/api/auth/login") @app.post("/api/auth/login")
@@ -235,7 +268,8 @@ def login(request: Request, response: Response, body: dict = Body(...)):
token = auth.create_session(con, row["id"]) token = auth.create_session(con, row["id"])
limiter.reset(keys[1]) limiter.reset(keys[1])
set_cookie(request, response, token) set_cookie(request, response, token)
return {"user": {"id": row["id"], "username": row["username"]}} u = {"id": row["id"], "username": row["username"]}
return {"user": {**u, "admin": is_admin(u)}}
@app.post("/api/auth/logout") @app.post("/api/auth/logout")
@@ -883,6 +917,75 @@ def export(fmt: str, ch: dict = Body(...), user: dict = Depends(current_user)):
headers={"Content-Disposition": f'attachment; filename="{_filename(ch, fmt)}"'}) headers={"Content-Disposition": f'attachment; filename="{_filename(ch, fmt)}"'})
# ---- rules editor (admins only)
def admin_user(user: dict = Depends(current_user)) -> dict:
if not is_admin(user):
raise HTTPException(403, "Only admins can edit the rules")
return user
@api.get("/admin/rules")
def editor_state(user: dict = Depends(admin_user)):
"""The editable sections as they are now, and which of them differ from the shipped rules.json."""
r = load_rules()
return {"sections": {k: r[k] for k in rules_edit.SECTIONS}, "customized": [k for k in read_overrides() if k in rules_edit.SECTIONS]}
def _all_characters(con) -> list:
return [json.loads(x["data"]) for x in con.execute("SELECT data FROM characters")]
def _save_overrides(data: dict):
OVERRIDES_PATH.parent.mkdir(parents=True, exist_ok=True)
if OVERRIDES_PATH.exists():
OVERRIDES_PATH.with_suffix(".json.bak").write_bytes(OVERRIDES_PATH.read_bytes())
tmp = OVERRIDES_PATH.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
os.replace(tmp, OVERRIDES_PATH)
def _apply(section: str, new: list, confirm: bool):
r = load_rules()
old_ids = {x["id"] for x in r[section]}
removed = old_ids - {x["id"] for x in new}
bad = rules_edit.dangling(section, new, r)
if bad:
raise HTTPException(422, "Cannot save: " + "; ".join(bad[:5]) + (" ..." if len(bad) > 5 else ""))
with db() as con:
used = rules_edit.usage(_all_characters(con), section, removed)
if used and not confirm:
raise HTTPException(409, f"{used} saved character{'s' if used != 1 else ''} use{'' if used != 1 else 's'} entries you are removing. "
"Their data stays in the characters but the removed entries disappear from the sheets.")
return used
@api.put("/admin/rules/{section}")
def editor_save(section: str, body: dict = Body(...), user: dict = Depends(admin_user)):
try:
new = rules_edit.clean(section, body.get("items"), load_rules())
except rules_edit.RuleError as e:
raise HTTPException(422, str(e))
_apply(section, new, bool(body.get("confirm")))
data = read_overrides()
data[section] = new
_save_overrides(data)
return {"ok": True, "count": len(new)}
@api.delete("/admin/rules/{section}")
def editor_reset(section: str, confirm: bool = False, user: dict = Depends(admin_user)):
"""Back to the shipped rules.json for this section."""
if section not in rules_edit.SECTIONS:
raise HTTPException(404, "Unknown section")
data = read_overrides()
if section in data:
_apply(section, base_rules()[section], confirm)
del data[section]
_save_overrides(data)
return {"ok": True}
app.include_router(api) app.include_router(api)
+125
View File
@@ -0,0 +1,125 @@
"""Validation for the rules editor (items, skills, professions and their categories).
Only whitelisted fields are kept and every reference (category, group, attribute, skill) has to exist."""
import re
SECTIONS = ("equipment", "equipment_categories", "skills", "skill_groups", "professions")
MAX_ENTRIES = 400
ID_RE = re.compile(r"^[a-z0-9_]{1,40}$")
class RuleError(ValueError):
pass
def _text(v, limit, label, required=False):
v = str(v if v is not None else "").strip()
if required and not v:
raise RuleError(f"{label} must not be empty")
if len(v) > limit:
raise RuleError(f"{label} is too long (max {limit} characters)")
return v
def _num(v, lo, hi, label, integer=False):
try:
n = float(v)
except (TypeError, ValueError):
raise RuleError(f"{label} must be a number")
if not lo <= n <= hi:
raise RuleError(f"{label} must be between {lo} and {hi}")
return int(n) if integer else round(n, 2)
def _ids(v, allowed, label, lo=0, hi=50):
if not isinstance(v, list) or not lo <= len(v) <= hi:
raise RuleError(f"{label}: choose {lo} to {hi}" if hi < 50 else f"{label} must be a list")
out = []
for x in v:
if x not in allowed:
raise RuleError(f"{label}: unknown entry '{x}'")
if x not in out:
out.append(x)
return out
def clean(section: str, items, rules: dict) -> list:
"""Returns the cleaned list for one section. `rules` is the effective rules (for the other sections' ids)."""
if section not in SECTIONS:
raise RuleError("Unknown section")
if not isinstance(items, list) or len(items) > MAX_ENTRIES:
raise RuleError(f"A list of at most {MAX_ENTRIES} entries is expected")
attrs = {a["id"] for a in rules["attributes"]}
out, seen = [], set()
cats = {c["id"] for c in (items if section == "equipment_categories" else rules["equipment_categories"])}
groups = {g["id"] for g in (items if section == "skill_groups" else rules["skill_groups"])}
skills = {s["id"] for s in (items if section == "skills" else rules["skills"])}
for it in items:
if not isinstance(it, dict):
raise RuleError("Every entry has to be an object")
iid = str(it.get("id", ""))
if not ID_RE.match(iid):
raise RuleError(f"Invalid id '{iid}' (lowercase letters, digits and _ only, up to 40)")
if iid in seen:
raise RuleError(f"The id '{iid}' is used twice")
seen.add(iid)
name = _text(it.get("name"), 60, f"Name of '{iid}'", True)
e = {"id": iid, "name": name}
label = f"'{name}'"
if section == "equipment":
if it.get("cat") not in cats:
raise RuleError(f"{label}: unknown category")
e.update(cat=it["cat"], stat=_text(it.get("stat"), 40, f"{label} stat"), kg=_num(it.get("kg", 0), 0, 1000, f"{label} weight"),
note=_text(it.get("note"), 200, f"{label} note"))
if it.get("tier"):
e["tier"] = _text(it["tier"], 20, f"{label} tier")
elif section == "equipment_categories":
e["stat"] = _text(it.get("stat"), 40, f"{label} stat column")
elif section == "skills":
if it.get("group") not in groups:
raise RuleError(f"{label}: unknown group")
if it.get("type") not in ("basic", "advanced"):
raise RuleError(f"{label}: type must be basic or advanced")
a = _ids(it.get("attrs"), attrs, f"{label} attributes", 1, 3)
e.update(group=it["group"], attrs=a, type=it["type"], desc=_text(it.get("desc"), 300, f"{label} description"))
elif section == "professions":
e.update(desc=_text(it.get("desc"), 500, f"{label} description"), bonus_attrs=_ids(it.get("bonus_attrs"), attrs, f"{label} bonus attributes", 0, 7),
proficiencies=_ids(it.get("proficiencies"), skills, f"{label} proficiencies", 0, 12),
picks=_num(it.get("picks", 0), 0, 10, f"{label} free picks", True), note=_text(it.get("note"), 200, f"{label} note"))
if not e["note"]:
del e["note"]
if it.get("placeholder"):
e["placeholder"] = True
out.append(e)
return out
def dangling(section: str, new: list, rules: dict) -> list:
"""Things in the other sections that would point to an entry that no longer exists."""
ids = {x["id"] for x in new}
msgs = []
if section == "equipment_categories":
msgs += [f"Item '{e['name']}' uses the removed category" for e in rules["equipment"] if e["cat"] not in ids]
if section == "skill_groups":
msgs += [f"Skill '{s['name']}' uses the removed group" for s in rules["skills"] if s["group"] not in ids]
if section == "skills":
for p in rules["professions"]:
gone = [i for i in p["proficiencies"] if i not in ids]
if gone:
msgs.append(f"Profession '{p['name']}' grants proficiency in a removed skill ({', '.join(gone)})")
return msgs
def character_refs(ch: dict) -> dict:
"""Ids a saved character points to, per section."""
kit = ch.get("kit") or {}
items = {i for i in [kit.get("weapon"), kit.get("armor"), kit.get("tool"), *(kit.get("resources") or []), *(kit.get("extras") or {})] if i}
sk = set((ch.get("skill_points") or {}).keys()) | set((ch.get("proficiencies") or {}).keys()) | set((ch.get("proficiency_picks") or {}).keys()) \
| set(ch.get("unlocked") or []) | set(ch.get("profession_picks") or [])
return {"equipment": items, "skills": sk, "professions": {ch["profession"]} if ch.get("profession") else set()}
def usage(chars: list, section: str, removed: set) -> int:
"""How many saved characters use one of the removed ids."""
if section not in ("equipment", "skills", "professions") or not removed:
return 0
return sum(1 for ch in chars if character_refs(ch)[section] & removed)
+2
View File
@@ -12,6 +12,8 @@ services:
# Code that new users must enter to create an account (set it in the .env file next to this file). # Code that new users must enter to create an account (set it in the .env file next to this file).
# Empty = registration is disabled. # Empty = registration is disabled.
AW_REGISTRATION_CODE: ${AW_REGISTRATION_CODE:-} AW_REGISTRATION_CODE: ${AW_REGISTRATION_CODE:-}
# Who may use the rules editor: comma separated usernames. Empty = only the first account that was ever created.
AW_ADMINS: ${AW_ADMINS:-}
# Set to 1 when a reverse proxy (nginx, Caddy, Traefik) in front sets X-Forwarded-* headers. # Set to 1 when a reverse proxy (nginx, Caddy, Traefik) in front sets X-Forwarded-* headers.
AW_TRUST_PROXY: ${AW_TRUST_PROXY:-0} AW_TRUST_PROXY: ${AW_TRUST_PROXY:-0}
volumes: volumes:
+3 -1
View File
@@ -6,6 +6,7 @@ import Presets from './Presets.jsx'
import Lobbies from './Lobbies.jsx' import Lobbies from './Lobbies.jsx'
import Login from './Login.jsx' import Login from './Login.jsx'
import Account from './Account.jsx' import Account from './Account.jsx'
import Editor from './Editor.jsx'
import { claimLegacy } from './legacy.js' import { claimLegacy } from './legacy.js'
const useHash = () => { 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> if (!auth.user) return <main><Login registrationOpen={auth.registration_open} onDone={(u) => setAuth({ ...auth, user: u })} /></main>
const page = hash.split('/')[0] 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 ( return (
<> <>
@@ -81,6 +82,7 @@ export default function App() {
{rules && page === 'lobbies' && ( {rules && page === 'lobbies' && (
<Lobbies section={hash.split('/')[1]} rules={rules} user={username} onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} /> <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} />} {page === 'account' && <Account user={username} onLogout={logout} />}
</main> </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; } .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; } } @media (prefers-reduced-motion: reduce) { .thrown { animation: none; } }
.stepper .step5 { font-size: .75rem; padding: .25rem .4rem; min-width: 2.2rem; } .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; }