Sheet: large portrait slot, writable notes in summary and lobby

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-24 18:10:12 +00:00
parent 7940fcfa3f
commit a2103566a0
8 changed files with 83 additions and 25 deletions
+2
View File
@@ -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. 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 ### 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. 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.
+2 -2
View File
@@ -99,7 +99,7 @@ def to_docx(ch: dict, rules: dict) -> bytes:
p = doc.add_paragraph() p = doc.add_paragraph()
p.add_run("Other: ").bold = True p.add_run("Other: ").bold = True
p.add_run(s["other"]) 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): if ch.get(key):
doc.add_heading(head, 1) doc.add_heading(head, 1)
doc.add_paragraph(ch[key]) doc.add_paragraph(ch[key])
@@ -171,7 +171,7 @@ def to_pdf(ch: dict, rules: dict) -> bytes:
el.append(Paragraph("-", body)) el.append(Paragraph("-", body))
if s["other"]: if s["other"]:
el.append(Paragraph(f"<b>Other:</b> {esc(s['other'])}", body)) el.append(Paragraph(f"<b>Other:</b> {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): if ch.get(key):
el += [Paragraph(head, h2), Paragraph(esc(ch[key]), body)] el += [Paragraph(head, h2), Paragraph(esc(ch[key]), body)]
+16
View File
@@ -494,6 +494,22 @@ def update_character(cid: str, ch: dict = Body(...), user: dict = Depends(curren
return {"ok": True} 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}") @api.delete("/characters/{cid}")
def delete_character(cid: str, user: dict = Depends(current_user)): def delete_character(cid: str, user: dict = Depends(current_user)):
with db() as con: with db() as con:
+1 -1
View File
@@ -461,7 +461,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
<> <>
<h2>Summary</h2> <h2>Summary</h2>
{warnings.length > 0 && <ul className="warn">{warnings.map((w) => <li key={w}>{w}</li>)}</ul>} {warnings.length > 0 && <ul className="warn">{warnings.map((w) => <li key={w}>{w}</li>)}</ul>}
<PaperSheet data={ch} baseRules={baseRules} /> <PaperSheet data={ch} baseRules={baseRules} onNotes={(p) => setBoth({ ...ch, ...p })} />
<div className="actions wrap"> <div className="actions wrap">
{!npc && <button onClick={() => download('pdf')}>Download as PDF</button>} {!npc && <button onClick={() => download('pdf')}>Download as PDF</button>}
{!npc && <button onClick={() => download('docx')}>Download as Word</button>} {!npc && <button onClick={() => download('docx')}>Download as Word</button>}
+14 -5
View File
@@ -524,23 +524,32 @@ function DifficultyBar({ lobby, onSet }) {
} }
// ---- sheet of one character, refreshed regularly so the DM sees changes the player saves // ---- 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 [data, setData] = useState(null)
const [err, setErr] = useState('') 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(() => { useEffect(() => {
setData(null); setErr('') 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() load()
const t = setInterval(() => !document.hidden && load(), 15000) const t = setInterval(() => !document.hidden && load(), 15000)
return () => clearInterval(t) return () => clearInterval(t)
}, [cid]) }, [cid])
useEffect(() => { // pushed by the server when the player saved the character 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 }, [tick]) // eslint-disable-line
return ( return (
<div> <div>
{err && <p className="warn">{err}</p>} {err && <p className="warn">{err}</p>}
{data && <PaperSheet data={data} baseRules={rules} bare onRoll={onRoll} />} {data && <PaperSheet data={data} baseRules={rules} bare onRoll={onRoll} onNotes={editable ? saveNotes : undefined} />}
</div> </div>
) )
} }
@@ -804,7 +813,7 @@ function LobbyView({ id, rules, user }) {
{lobby.is_dm && leftTab === 'lobby' {lobby.is_dm && leftTab === 'lobby'
? <><MapLibrary activeId={mapState.map?.id} lobbyId={id} act={act} /><SoundSettings lobby={lobby} id={id} act={act} sfx={sfx.current} /><Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} /></> ? <><MapLibrary activeId={mapState.map?.id} lobbyId={id} act={act} /><SoundSettings lobby={lobby} id={id} act={act} sfx={sfx.current} /><Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} /></>
: npcTok ? <CharTab key={`npc${npcTok.npc_id}`} cid={npcTok.npc_id} rules={rules} tick={sheetTick.cid === npcTok.npc_id ? sheetTick.n : 0} onRoll={(k, i, n) => check(k, i, n, npcTok.id)} /> : npcTok ? <CharTab key={`npc${npcTok.npc_id}`} cid={npcTok.npc_id} rules={rules} tick={sheetTick.cid === npcTok.npc_id ? sheetTick.n : 0} onRoll={(k, i, n) => check(k, i, n, npcTok.id)} />
: cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} tick={sheetTick.cid === cur.character_id ? sheetTick.n : 0} onRoll={cur.me && canCheck ? check : undefined} /> : cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} tick={sheetTick.cid === cur.character_id ? sheetTick.n : 0} onRoll={cur.me && canCheck ? check : undefined} editable={cur.me} />
: <p className="muted small">{lobby.is_dm ? 'Pick a character in the bar to look at its sheet.' : 'You have no character in this lobby.'}</p>} : <p className="muted small">{lobby.is_dm ? 'Pick a character in the bar to look at its sheet.' : 'You have no character in this lobby.'}</p>}
</div> </div>
)} )}
+36 -15
View File
@@ -31,6 +31,8 @@ export function printSheet(root, title) {
const win = window.open('', '_blank') const win = window.open('', '_blank')
if (!win) { window.alert('Please allow pop-ups for this site to print the sheet.'); return } if (!win) { window.alert('Please allow pop-ups for this site to print the sheet.'); return }
const clone = root.cloneNode(true) 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.style.zoom = ''
clone.classList.remove('dark') // printed pages are always light clone.classList.remove('dark') // printed pages are always light
const head = [...document.querySelectorAll('link[rel=stylesheet], style')].map((n) => n.outerHTML).join('') 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() }) }) 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 (
<div className="psec pnotes"><h3>{title}</h3>
{onChange
? <textarea className="plines pwrite" value={value || ''} maxLength={5000} onChange={(e) => onChange(e.target.value)} placeholder="Click here to write …" aria-label={title} />
: <div className="plines pwrite">{value}</div>}
</div>
)
}
// Scales the A4 pages down when the space is narrower than the paper // Scales the A4 pages down when the space is narrower than the paper
function Fit({ children, rootRef, dark }) { function Fit({ children, rootRef, dark }) {
const wrap = useRef(null) const wrap = useRef(null)
@@ -68,7 +81,7 @@ const Pips = ({ n, locked }) => (
) )
// The character sheet as A4 pages (page 1: attributes and skills, page 2: talents, mutations, equipment, notes). // The character sheet as A4 pages (page 1: attributes and skills, page 2: talents, mutations, equipment, notes).
export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) { export default function PaperSheet({ data, baseRules, tools, bare, onRoll, onNotes }) {
const ch = useMemo(() => normalize(data, baseRules), [data, baseRules]) const ch = useMemo(() => normalize(data, baseRules), [data, baseRules])
const rules = useMemo(() => effectiveRules(baseRules, ch), [baseRules, ch]) const rules = useMemo(() => effectiveRules(baseRules, ch), [baseRules, ch])
const c = useMemo(() => compute(ch, rules), [ch, rules]) const c = useMemo(() => compute(ch, rules), [ch, rules])
@@ -108,18 +121,26 @@ export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
)} )}
<Fit rootRef={root} dark={dark}> <Fit rootRef={root} dark={dark}>
<section className="paper"> <section className="paper">
<header className="phead"> {(() => {
<div className="pname">{name}</div> const head = (
{ch.portrait && <img className="pportrait" src={ch.portrait} alt="" />} <header className="phead">
<div className="pbrand">Arcane Wasteland<span>Character sheet</span></div> <div className="pname">{name}</div>
</header> <div className="pbrand">Arcane Wasteland<span>Character sheet</span></div>
<div className="pfields"> </header>
<Field label="Player">{ch.player}</Field> )
<Field label="Concept">{ch.concept}</Field> const fields = (
<Field label="Class">{cls && [cls.name, sub?.name].filter(Boolean).join(' / ')}</Field> <div className="pfields">
<Field label="Profession">{prof?.name}</Field> <Field label="Player">{ch.player}</Field>
<Field label="Rules">{preset}</Field> <Field label="Concept">{ch.concept}</Field>
</div> <Field label="Class">{cls && [cls.name, sub?.name].filter(Boolean).join(' / ')}</Field>
<Field label="Profession">{prof?.name}</Field>
<Field label="Rules">{preset}</Field>
</div>
)
return ch.portrait
? <div className="pidentity"><img className="pportrait" src={ch.portrait} alt="" /><div className="pidright">{head}{fields}</div></div>
: <>{head}{fields}</>
})()}
<div className="pattrs"> <div className="pattrs">
{rules.attributes.map((a) => ( {rules.attributes.map((a) => (
@@ -165,7 +186,7 @@ export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
{data.loot && <><h3>Loot</h3><p className="pre">{data.loot}</p></>} {data.loot && <><h3>Loot</h3><p className="pre">{data.loot}</p></>}
</div> </div>
)} )}
<div className="psec pnotes"><h3>Conditions &amp; notes</h3><div className="plines" /></div> <Notes title="Conditions &amp; notes" value={ch.conditions} onChange={onNotes && ((v) => onNotes({ conditions: v }))} />
<Foot n={1} /> <Foot n={1} />
</section> </section>
@@ -208,7 +229,7 @@ export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
{ch.background && <div className="psec"><h3>Background</h3><p className="pbg">{ch.background}</p></div>} {ch.background && <div className="psec"><h3>Background</h3><p className="pbg">{ch.background}</p></div>}
<div className="psec pnotes"><h3>Notes</h3><div className="plines" /></div> <Notes title="Notes" value={ch.notes} onChange={onNotes && ((v) => onNotes({ notes: v }))} />
<Foot n={2} /> <Foot n={2} />
</section> </section>
</Fit> </Fit>
+1 -1
View File
@@ -14,7 +14,7 @@ export function emptyCharacter(rules) {
talents: [], augments: [], talents: [], augments: [],
kit: { extras: {} }, // item id -> quantity kit: { extras: {} }, // item id -> quantity
gear: { other: '' }, gear: { other: '' },
notes: '', notes: '', conditions: '',
} }
} }
+11 -1
View File
@@ -476,7 +476,6 @@ label.chk input[type=checkbox] { width: auto; margin: 0; }
.cropwin { position: relative; overflow: hidden; border-radius: 10px; border: 2px solid var(--noxium, #7aa63c); background: #000; cursor: move; touch-action: none; flex: none; } .cropwin { position: relative; overflow: hidden; border-radius: 10px; border: 2px solid var(--noxium, #7aa63c); background: #000; cursor: move; touch-action: none; flex: none; }
.cropwin img { position: absolute; max-width: none; user-select: none; } .cropwin img { position: absolute; max-width: none; user-select: none; }
.cropctl { display: grid; gap: .5rem; } .cropctl { display: grid; gap: .5rem; }
.pportrait { width: 21mm; height: 21mm; object-fit: cover; border: 1.5px solid var(--pg); border-radius: 1.5mm; flex: none; margin-left: auto; }
.pavatar { overflow: hidden; border: 2px solid var(--pc); } .pavatar { overflow: hidden; border: 2px solid var(--pc); }
.pavatar img { width: 100%; height: 100%; object-fit: cover; display: block; } .pavatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.maptoken .tokpic { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 50%; object-fit: cover; pointer-events: none; } .maptoken .tokpic { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 50%; object-fit: cover; pointer-events: none; }
@@ -564,3 +563,14 @@ body.resizing { cursor: col-resize; user-select: none; }
.lobbygrid, .lobbygrid:not(.nosheet) { grid-template-columns: minmax(0, 1fr); } .lobbygrid, .lobbygrid:not(.nosheet) { grid-template-columns: minmax(0, 1fr); }
.sheetsplit, .chatsplit { display: none; } .sheetsplit, .chatsplit { display: none; }
} }
/* portrait next to name and identity fields */
.pidentity { display: flex; gap: 5mm; align-items: stretch; }
.pidright { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3mm; justify-content: flex-end; }
.pportrait { width: 36mm; height: 42mm; object-fit: cover; border: 1.5px solid var(--pg); border-radius: 1.5mm; flex: none; align-self: flex-start; }
.pidright .pfields { grid-template-columns: 1fr 1fr 1fr; }
.pidright .pfield:nth-child(2) { grid-column: span 2; }
.pwrite { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; line-height: calc(6.4mm + 1px); padding: 0; color: var(--pi); border: 0; resize: none; width: 100%; box-sizing: border-box; background-attachment: local; min-height: 18mm; }
textarea.pwrite { background-color: transparent; outline: none; field-sizing: content; }
textarea.pwrite:focus { box-shadow: 0 0 0 1px var(--pg); }
.pwrite::placeholder { color: var(--pm); opacity: .6; }