From a2103566a02c98d5a14320212874b0862051b0d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 18:10:12 +0000 Subject: [PATCH] Sheet: large portrait slot, writable notes in summary and lobby Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K --- README.md | 2 ++ backend/app/export.py | 4 +-- backend/app/main.py | 16 ++++++++++++ frontend/src/Creator.jsx | 2 +- frontend/src/Lobbies.jsx | 19 ++++++++++---- frontend/src/PaperSheet.jsx | 51 ++++++++++++++++++++++++++----------- frontend/src/calc.js | 2 +- frontend/src/styles.css | 12 ++++++++- 8 files changed, 83 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 457079f..52dc35f 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ The Summary page and the sheet tabs in the lobbies show the character as **A4 pa On the **Concept** page *Add picture* takes a PNG, JPEG or WebP: move and zoom it inside the square window, *Use this* stores a small 256 px picture inside the character (so it is saved, shared, exported as JSON and loaded with it). The server checks and re-encodes it. The picture appears in the header of the character sheet (also when printing), as icon in the player bar of the lobby and as the token of the character on the map (in the player's colour ring). Only members of the lobby get the picture through `/api/lobbies/{id}/portrait/{username}`. The PDF / Word export from the server does not contain it yet. +The portrait sits next to the name and identity fields on page 1 of the sheet. The two ruled note areas ("Conditions & notes" on page 1, "Notes" on page 2) can be typed into on the Summary page and, for your own character, in the lobby (saved automatically through `PUT /api/characters/{id}/notes`, which only touches those two fields). They are included in print, PDF and Word. + ### 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. Admins can also promote other users: the **Users and admins** card above the tabs lists all accounts with *Make admin* / *Remove admin*. Names in `AW_ADMINS` stay admin and can only be changed in `.env`; the last admin cannot be removed. diff --git a/backend/app/export.py b/backend/app/export.py index e037551..065bb6a 100644 --- a/backend/app/export.py +++ b/backend/app/export.py @@ -99,7 +99,7 @@ def to_docx(ch: dict, rules: dict) -> bytes: p = doc.add_paragraph() p.add_run("Other: ").bold = True p.add_run(s["other"]) - for key, head in (("background", "Background"), ("notes", "Notes")): + for key, head in (("background", "Background"), ("conditions", "Conditions"), ("notes", "Notes")): if ch.get(key): doc.add_heading(head, 1) doc.add_paragraph(ch[key]) @@ -171,7 +171,7 @@ def to_pdf(ch: dict, rules: dict) -> bytes: el.append(Paragraph("-", body)) if s["other"]: el.append(Paragraph(f"Other: {esc(s['other'])}", body)) - for key, head in (("background", "Background"), ("notes", "Notes")): + for key, head in (("background", "Background"), ("conditions", "Conditions"), ("notes", "Notes")): if ch.get(key): el += [Paragraph(head, h2), Paragraph(esc(ch[key]), body)] diff --git a/backend/app/main.py b/backend/app/main.py index 3c6c24f..f017ee5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -494,6 +494,22 @@ def update_character(cid: str, ch: dict = Body(...), user: dict = Depends(curren return {"ok": True} +@api.put("/characters/{cid}/notes") +def update_notes(cid: str, body: dict = Body(...), user: dict = Depends(current_user)): + """Only the two note fields, so typing in the lobby never overwrites other changes made elsewhere.""" + with db() as con: + _owned(con, "characters", cid, user) + ch = json.loads(con.execute("SELECT data FROM characters WHERE id=?", (cid,)).fetchone()["data"]) + for k in ("notes", "conditions"): + if k in body: + ch[k] = str(body[k] or "")[:5000] + con.execute("UPDATE characters SET data=?, updated=? WHERE id=?", (json.dumps(ch), time.time(), cid)) + lobbies = [r["lobby_id"] for r in con.execute("SELECT lobby_id FROM lobby_members WHERE character_id=?", (cid,))] + for lid in lobbies: + hub.notify(lid, "sheet", cid=cid) + return {"ok": True} + + @api.delete("/characters/{cid}") def delete_character(cid: str, user: dict = Depends(current_user)): with db() as con: diff --git a/frontend/src/Creator.jsx b/frontend/src/Creator.jsx index 0f0f721..55b84dd 100644 --- a/frontend/src/Creator.jsx +++ b/frontend/src/Creator.jsx @@ -461,7 +461,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad <>

Summary

{warnings.length > 0 && } - + setBoth({ ...ch, ...p })} />
{!npc && } {!npc && } diff --git a/frontend/src/Lobbies.jsx b/frontend/src/Lobbies.jsx index 18ef5ba..d963a1d 100644 --- a/frontend/src/Lobbies.jsx +++ b/frontend/src/Lobbies.jsx @@ -524,23 +524,32 @@ function DifficultyBar({ lobby, onSet }) { } // ---- sheet of one character, refreshed regularly so the DM sees changes the player saves -function CharTab({ cid, rules, onRoll, tick }) { +function CharTab({ cid, rules, onRoll, tick, editable }) { const [data, setData] = useState(null) const [err, setErr] = useState('') + const typing = useRef(0) // time of the last keystroke: refreshes must not overwrite what is being typed + const timer = useRef(null) + const saveNotes = (patch) => { + typing.current = Date.now() + setData((d) => ({ ...d, ...patch })) + clearTimeout(timer.current) + timer.current = setTimeout(() => { send(`/api/characters/${cid}/notes`, 'PUT', patch).catch(() => setErr('Could not save the notes.')) }, 700) + } + useEffect(() => () => clearTimeout(timer.current), []) useEffect(() => { setData(null); setErr('') - const load = () => fetch(`/api/characters/${cid}`).then((r) => { if (!r.ok) throw new Error(); return r.json() }).then(setData).catch(() => setErr('Could not load this character.')) + const load = () => fetch(`/api/characters/${cid}`).then((r) => { if (!r.ok) throw new Error(); return r.json() }).then((d) => { if (Date.now() - typing.current > 4000) setData(d) }).catch(() => setErr('Could not load this character.')) load() const t = setInterval(() => !document.hidden && load(), 15000) return () => clearInterval(t) }, [cid]) useEffect(() => { // pushed by the server when the player saved the character - if (tick) fetch(`/api/characters/${cid}`).then((r) => (r.ok ? r.json() : null)).then((d) => d && setData(d)).catch(() => {}) + if (tick) fetch(`/api/characters/${cid}`).then((r) => (r.ok ? r.json() : null)).then((d) => d && Date.now() - typing.current > 4000 && setData(d)).catch(() => {}) }, [tick]) // eslint-disable-line return (
{err &&

{err}

} - {data && } + {data && }
) } @@ -804,7 +813,7 @@ function LobbyView({ id, rules, user }) { {lobby.is_dm && leftTab === 'lobby' ? <> : npcTok ? check(k, i, n, npcTok.id)} /> - : cur ? + : cur ? :

{lobby.is_dm ? 'Pick a character in the bar to look at its sheet.' : 'You have no character in this lobby.'}

}
)} diff --git a/frontend/src/PaperSheet.jsx b/frontend/src/PaperSheet.jsx index 5275a17..c4d9baa 100644 --- a/frontend/src/PaperSheet.jsx +++ b/frontend/src/PaperSheet.jsx @@ -31,6 +31,8 @@ export function printSheet(root, title) { const win = window.open('', '_blank') if (!win) { window.alert('Please allow pop-ups for this site to print the sheet.'); return } const clone = root.cloneNode(true) + const src = root.querySelectorAll('textarea'), dst = clone.querySelectorAll('textarea') + dst.forEach((t, i) => { t.textContent = src[i].value }) // typed text does not survive cloning on its own clone.style.zoom = '' clone.classList.remove('dark') // printed pages are always light const head = [...document.querySelectorAll('link[rel=stylesheet], style')].map((n) => n.outerHTML).join('') @@ -47,6 +49,17 @@ export function printSheet(root, title) { links.forEach((l) => { l.addEventListener('load', () => { if (--pending === 0) setTimeout(go, 150) }); l.addEventListener('error', () => { if (--pending === 0) go() }) }) } +// Ruled note area. With onChange (own sheet in the summary or the lobby) it can be typed into, otherwise it just shows the text. +function Notes({ title, value, onChange }) { + return ( +

{title}

+ {onChange + ?