Compact lobby top bar, scrollable initiative cards, hidden NPC names, resizable panels

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 17:05:38 +00:00
parent 72d8113ece
commit 7940fcfa3f
5 changed files with 123 additions and 32 deletions
+2
View File
@@ -132,6 +132,8 @@ The DM presses **⚔ Start fight** in the lobby. Every player with a character j
The bar shows the order, the round and whose turn it is (the token pulses on the map). The DM can overwrite every number, reroll, remove entries, step **◀ / Next turn ▶** and **End fight**. Hidden NPCs are not shown to players. Fight start, joins and the end are logged in the chat feed. The bar shows the order, the round and whose turn it is (the token pulses on the map). The DM can overwrite every number, reroll, remove entries, step **◀ / Next turn ▶** and **End fight**. Hidden NPCs are not shown to players. Fight start, joins and the end are logged in the chat feed.
The top of the lobby is one compact row: small buttons on the left (start fight, previous/next turn, end fight, ▾ to expand players and difficulty), the player bar – replaced by a scrollable initiative bar during a fight – and the lobby settings button. Players only see the name of whoever's turn it is; NPC names are not sent to players otherwise, and token names show on hover, selection or turn. The sheet and the chat can be resized by dragging the thin lines next to them (remembered per browser).
### Maps, tokens and line of sight ### Maps, tokens and line of sight
- **Maps tab** (top navigation): all your maps with preview. Upload PNG, JPEG or WebP (up to 12 MB, 30 maps each; the server checks and re-encodes every image as WebP, max 4096 px per side, into `db/maps/`). **Edit** opens the map editor, **Share** gives other users access by username (like characters: they can put the map on their own table or **Copy** it, but not change it), **Duplicate** copies a map. - **Maps tab** (top navigation): all your maps with preview. Upload PNG, JPEG or WebP (up to 12 MB, 30 maps each; the server checks and re-encodes every image as WebP, max 4096 px per side, into `db/maps/`). **Edit** opens the map editor, **Share** gives other users access by username (like characters: they can put the map on their own table or **Copy** it, but not change it), **Duplicate** copies a map.
+2
View File
@@ -58,6 +58,8 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, load_rules):
continue continue
if toks[e["token"]] and not is_dm: if toks[e["token"]] and not is_dm:
continue continue
if e["kind"] == "npc" and not is_dm and e["id"] != c.get("current"):
e = {**e, "name": ""} # players only learn an NPC's name when it is its turn
out.append({k: e[k] for k in ("id", "kind", "name", "init", "base", "roll") if k in e} | {k: e[k] for k in ("user", "token") if k in e}) out.append({k: e[k] for k in ("id", "kind", "name", "init", "base", "roll") if k in e} | {k: e[k] for k in ("user", "token") if k in e})
cur = c.get("current") cur = c.get("current")
return {"round": c["round"], "current": cur if any(e["id"] == cur for e in out) else None, "entries": out} return {"round": c["round"], "current": cur if any(e["id"] == cur for e in out) else None, "entries": out}
+9
View File
@@ -352,6 +352,13 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
LEFT JOIN characters c ON c.id = m.character_id LEFT JOIN characters c ON c.id = m.character_id
WHERE t.lobby_id=? ORDER BY t.rowid""", (lobby["id"],)).fetchall() WHERE t.lobby_id=? ORDER BY t.rowid""", (lobby["id"],)).fetchall()
full = ctx["viewer"] is None full = ctx["viewer"] is None
turn_tok = "" # players only learn an NPC's name while it is that NPC's turn
try:
cb = json.loads(lobby["combat"]) if lobby["combat"] else None
if cb:
turn_tok = next((e.get("token", "") for e in cb["entries"] if e["id"] == cb.get("current")), "")
except (ValueError, KeyError, TypeError):
pass
out = [] out = []
for r in rows: for r in rows:
player = r["user_id"] is not None player = r["user_id"] is not None
@@ -367,6 +374,8 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
name = (json.loads(r["data"]).get("name") if r["data"] else "") or r["username"] name = (json.loads(r["data"]).get("name") if r["data"] else "") or r["username"]
except ValueError: except ValueError:
name = r["username"] name = r["username"]
if not player and not full and r["id"] != turn_tok:
name = ""
pt = "" pt = ""
src = r["data"] if player else r["ndata"] src = r["data"] if player else r["ndata"]
if src: if src:
+65 -24
View File
@@ -424,7 +424,7 @@ function DiceTray({ rolls }) {
} }
// ---- character bar: one card per player, later also for initiative and enemies // ---- character bar: one card per player, later also for initiative and enemies
function CharBar({ lobby, sel, onSel, settings }) { function CharBar({ lobby, sel, onSel }) {
return ( return (
<div className="charbar" role="list"> <div className="charbar" role="list">
<div role="listitem" className={`pcard dmcard${lobby.dm_online ? '' : ' off'}`} title={`Dungeon master${lobby.dm_online ? ' (online)' : ' (offline)'}`}> <div role="listitem" className={`pcard dmcard${lobby.dm_online ? '' : ' off'}`} title={`Dungeon master${lobby.dm_online ? ' (online)' : ' (offline)'}`}>
@@ -448,38 +448,51 @@ function CharBar({ lobby, sel, onSel, settings }) {
) )
})} })}
{lobby.members.length === 0 && <span className="muted small">Nobody has joined yet – share the join code (⚙ Lobby).</span>} {lobby.members.length === 0 && <span className="muted small">Nobody has joined yet – share the join code (⚙ Lobby).</span>}
{settings}
</div> </div>
) )
} }
// ---- initiative tracker: the DM starts a fight, players join automatically, NPCs are added from the token menu // Drag handle that resizes a panel. Reports the new size through onChange, keeps it within min/max. dir = 1: dragging right grows, -1: shrinks.
function InitiativeBar({ lobby, onOp, onStart, onEnd, onPick }) { function Splitter({ value, onChange, min, max, dir = 1, className = '', label }) {
const down = (e) => {
e.preventDefault()
const x0 = e.clientX, v0 = value
document.body.classList.add('resizing')
const mv = (ev) => onChange(Math.round(Math.min(max, Math.max(min, v0 + dir * (ev.clientX - x0)))))
const up = () => { document.body.classList.remove('resizing'); window.removeEventListener('pointermove', mv); window.removeEventListener('pointerup', up) }
window.addEventListener('pointermove', mv); window.addEventListener('pointerup', up)
}
return <div className={`splitter ${className}`} role="separator" aria-orientation="vertical" aria-label={label} title={`${label} (drag, double click to reset)`} onPointerDown={down} />
}
// ---- initiative tracker: compact cards (portrait + number). Players only see the name of whoever's turn it is.
function InitiativeCards({ lobby, tokens, onOp, onPick }) {
const c = lobby.combat, dm = lobby.is_dm const c = lobby.combat, dm = lobby.is_dm
if (!c) return dm ? <div className="fightrow"><button className="ghost" onClick={onStart}>⚔ Start fight</button></div> : null const cardPic = (e) => {
if (e.kind === 'player') { const m = lobby.members.find((x) => x.username === e.user); return m?.character?.portrait ? `/api/lobbies/${lobby.id}/portrait/${encodeURIComponent(e.user)}?v=${m.character.portrait}` : null }
const t = tokens.find((x) => x.id === e.token)
return t?.portrait ? `/api/lobbies/${lobby.id}/token-portrait/${t.id}?v=${t.portrait}` : null
}
const missing = dm ? lobby.members.filter((m) => m.character && !c.entries.some((e) => e.user === m.username)) : [] const missing = dm ? lobby.members.filter((m) => m.character && !c.entries.some((e) => e.user === m.username)) : []
return ( return (
<div className="initbar" role="list" aria-label="Initiative order"> <div className="initcards" role="list" aria-label="Initiative order">
<span className="initround"><b>Round {c.round}</b></span> <span className="initround" title="Round"><b>R{c.round}</b></span>
{c.entries.map((e) => ( {c.entries.map((e) => {
<div key={e.id} role="listitem" className={`initcard ${e.kind}${c.current === e.id ? ' cur' : ''}`} onClick={() => onPick(e)}> const cur = c.current === e.id, pic = cardPic(e), showName = dm || cur
return (
<div key={e.id} role="listitem" className={`initcard ${e.kind}${cur ? ' cur' : ''}`} onClick={() => onPick(e)} title={showName ? `${e.name}: d20 ${e.roll} + ${e.base}` : 'Initiative'}>
<span className="initpic">{pic ? <img src={pic} alt="" /> : (e.name || '?').slice(0, 1).toUpperCase()}</span>
{dm {dm
? <input type="number" className="modin" defaultValue={e.init} key={`${e.id}:${e.init}`} aria-label={`Initiative of ${e.name}`} title={`d20 ${e.roll} + ${e.base}. Click to overwrite.`} ? <input type="number" className="modin" defaultValue={e.init} key={`${e.id}:${e.init}`} aria-label={`Initiative of ${e.name}`}
onClick={(ev) => ev.stopPropagation()} onBlur={(ev) => ev.target.value !== String(e.init) && onOp({ op: 'init', id: e.id, value: ev.target.value })} onKeyDown={(ev) => ev.key === 'Enter' && ev.currentTarget.blur()} /> onClick={(ev) => ev.stopPropagation()} onBlur={(ev) => ev.target.value !== String(e.init) && onOp({ op: 'init', id: e.id, value: ev.target.value })} onKeyDown={(ev) => ev.key === 'Enter' && ev.currentTarget.blur()} />
: <span className="initnum">{e.init}</span>} : <span className="initnum">{e.init}</span>}
<b title={e.name}>{e.name}</b> {showName && <b className="initname">{e.name}</b>}
{dm && <button className="linklike" onClick={(ev) => { ev.stopPropagation(); onOp({ op: 'reroll', id: e.id }) }} title="Roll again" aria-label={`Reroll ${e.name}`}>🎲</button>} {dm && <button className="linklike" onClick={(ev) => { ev.stopPropagation(); onOp({ op: 'reroll', id: e.id }) }} title="Roll again" aria-label={`Reroll ${e.name}`}>🎲</button>}
{dm && <button className="linklike" onClick={(ev) => { ev.stopPropagation(); onOp({ op: 'remove', id: e.id }) }} title="Remove from the fight" aria-label={`Remove ${e.name}`}>×</button>} {dm && <button className="linklike" onClick={(ev) => { ev.stopPropagation(); onOp({ op: 'remove', id: e.id }) }} title="Remove from the fight" aria-label={`Remove ${e.name}`}>×</button>}
</div> </div>
))} )
})}
{missing.map((m) => <button key={m.username} className="ghost small" onClick={() => onOp({ op: 'add', user: m.username })}>+ {m.character.name}</button>)} {missing.map((m) => <button key={m.username} className="ghost small" onClick={() => onOp({ op: 'add', user: m.username })}>+ {m.character.name}</button>)}
{dm && (
<span className="initctl">
<button className="ghost" onClick={() => onOp({ op: 'prev' })} aria-label="Previous turn">◀</button>
<button className="on" onClick={() => onOp({ op: 'next' })}>Next turn ▶</button>
<button className="ghost danger" onClick={onEnd}>End fight</button>
</span>
)}
</div> </div>
) )
} }
@@ -716,7 +729,15 @@ function LobbyView({ id, rules, user }) {
setMapState((m) => ({ ...m, tokens: m.tokens.map((t) => (t.id === tid ? { ...t, ...p } : t)) })) setMapState((m) => ({ ...m, tokens: m.tokens.map((t) => (t.id === tid ? { ...t, ...p } : t)) }))
return send(`/api/lobbies/${id}/tokens/${tid}`, 'PUT', p).catch(() => null) return send(`/api/lobbies/${id}/tokens/${tid}`, 'PUT', p).catch(() => null)
} }
const defSheet = () => Math.round(Math.min(800, Math.max(480, window.innerWidth * 0.42)))
const [sheetW, setSheetW] = useState(() => Number(store('aw_sheetw')) || defSheet())
const [chatW, setChatW] = useState(() => Number(store('aw_chatw')) || 380)
useEffect(() => { document.documentElement.style.setProperty('--chatw', `${chatW}px`); store('aw_chatw', String(chatW)) }, [chatW])
useEffect(() => () => document.documentElement.style.removeProperty('--chatw'), [])
useEffect(() => { store('aw_sheetw', String(sheetW)) }, [sheetW])
const [menu, setMenu] = useState(null) // right click menu on a token const [menu, setMenu] = useState(null) // right click menu on a token
const [topOpen, setTopOpen] = useState(() => store('aw_topopen') === '1') // difficulty and player bar during a fight
const toggleTop = () => { setTopOpen((v) => { store('aw_topopen', v ? '0' : '1'); return !v }) }
const combatOp = (b) => act(`/api/lobbies/${id}/combat`, 'PUT', b) const combatOp = (b) => act(`/api/lobbies/${id}/combat`, 'PUT', b)
const [npcPick, setNpcPick] = useState(false) const [npcPick, setNpcPick] = useState(false)
const addNpcToken = (n) => { const addNpcToken = (n) => {
@@ -768,7 +789,7 @@ function LobbyView({ id, rules, user }) {
{err && <p className="warn">{err}</p>} {err && <p className="warn">{err}</p>}
{info && <p className="small">{info}</p>} {info && <p className="small">{info}</p>}
<div className={`lobbygrid${sheetOpen ? '' : ' nosheet'}`}> <div className={`lobbygrid${sheetOpen ? '' : ' nosheet'}`} style={{ '--sheetw': `${sheetW}px` }}>
{sheetOpen && ( {sheetOpen && (
<div className="sheetcol"> <div className="sheetcol">
<div className="sheetbar"> <div className="sheetbar">
@@ -788,14 +809,33 @@ function LobbyView({ id, rules, user }) {
</div> </div>
)} )}
{sheetOpen && <Splitter value={sheetW} onChange={setSheetW} min={320} max={Math.max(400, window.innerWidth - chatW - 360)} label="Resize the sheet" className="sheetsplit" />}
<div className="playarea"> <div className="playarea">
{!sheetOpen && <button className="ghost showsheet" onClick={() => { setSheetOpen(true); store('aw_sheetopen', '1') }}>▶ Sheet</button>} {!sheetOpen && <button className="ghost showsheet" onClick={() => { setSheetOpen(true); store('aw_sheetopen', '1') }}>▶ Sheet</button>}
<CharBar lobby={lobby} sel={cur?.username} onSel={(u) => { setSel(u); setMapSel(null); setLeftTab('sheet') }} <div className="topbar">
settings={<button className="ghost lobbycfg" title="Lobby settings" onClick={() => (lobby.is_dm ? (setSheetOpen(true), setLeftTab('lobby')) : setShowSettings((v) => !v))} aria-expanded={lobby.is_dm ? sheetOpen && leftTab === 'lobby' : showSettings}>⚙ {lobby.name}</button>} /> <div className="topctl" role="group" aria-label="Fight controls">
<InitiativeBar lobby={lobby} onOp={combatOp} onStart={() => act(`/api/lobbies/${id}/combat`, 'POST')} {lobby.is_dm && !lobby.combat && <button className="ghost mini wide" onClick={() => act(`/api/lobbies/${id}/combat`, 'POST')} title="Start a fight">⚔</button>}
onEnd={() => window.confirm('End the fight?') && act(`/api/lobbies/${id}/combat`, 'DELETE')} {lobby.is_dm && lobby.combat && <>
<button className="ghost mini" onClick={() => combatOp({ op: 'prev' })} title="Previous turn" aria-label="Previous turn">◀</button>
<button className="on mini" onClick={() => combatOp({ op: 'next' })} title="Next turn" aria-label="Next turn">▶</button>
<button className="ghost mini danger" onClick={() => window.confirm('End the fight?') && act(`/api/lobbies/${id}/combat`, 'DELETE')} title="End the fight" aria-label="End the fight">■</button>
</>}
<button className={`mini ${topOpen ? 'on' : 'ghost'}`} onClick={toggleTop} aria-expanded={topOpen} title="Players, difficulty and details">{topOpen ? '▴' : '▾'}</button>
</div>
<div className="topscroll">
{lobby.combat
? <InitiativeCards lobby={lobby} tokens={mapState.tokens} onOp={combatOp}
onPick={(e) => { if (e.kind === 'player') { setSel(e.user); setMapSel(null); setLeftTab('sheet') } else if (lobby.is_dm) { setMapSel(e.token); setLeftTab('sheet') } }} /> onPick={(e) => { if (e.kind === 'player') { setSel(e.user); setMapSel(null); setLeftTab('sheet') } else if (lobby.is_dm) { setMapSel(e.token); setLeftTab('sheet') } }} />
: <CharBar lobby={lobby} sel={cur?.username} onSel={(u) => { setSel(u); setMapSel(null); setLeftTab('sheet') }} />}
</div>
<button className="ghost lobbycfg" title="Lobby settings" onClick={() => (lobby.is_dm ? (setSheetOpen(true), setLeftTab('lobby')) : setShowSettings((v) => !v))} aria-expanded={lobby.is_dm ? sheetOpen && leftTab === 'lobby' : showSettings}>⚙ {lobby.name}</button>
</div>
{topOpen && (
<div className="topmore">
{lobby.combat && <CharBar lobby={lobby} sel={cur?.username} onSel={(u) => { setSel(u); setMapSel(null); setLeftTab('sheet') }} />}
<DifficultyBar lobby={lobby} onSet={(b) => act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} /> <DifficultyBar lobby={lobby} onSet={(b) => act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} />
</div>
)}
<div className="table"> <div className="table">
{showSettings && !lobby.is_dm && <Settings lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} onClose={() => setShowSettings(false)} />} {showSettings && !lobby.is_dm && <Settings lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} onClose={() => setShowSettings(false)} />}
{mapState.map && (() => { {mapState.map && (() => {
@@ -847,6 +887,7 @@ function LobbyView({ id, rules, user }) {
</div> </div>
<aside className="lobbychat card" aria-label="Chat"> <aside className="lobbychat card" aria-label="Chat">
<Splitter value={chatW} onChange={setChatW} min={240} max={Math.max(300, Math.min(900, window.innerWidth - 700))} dir={-1} label="Resize the chat" className="chatsplit" />
<Chat id={id} me={user} people={people} msgs={feed.msgs} poll={feed.poll} to={dest} setTo={setTo} /> <Chat id={id} me={user} people={people} msgs={feed.msgs} poll={feed.poll} to={dest} setTo={setTo} />
</aside> </aside>
</div> </div>
+37
View File
@@ -527,3 +527,40 @@ label.chk input[type=checkbox] { width: auto; margin: 0; }
.radialbtn .ri { font-size: 1.2rem; } .radialbtn .rl { font-size: .55rem; margin-top: 2px; } .radialbtn .ri { font-size: 1.2rem; } .radialbtn .rl { font-size: .55rem; margin-top: 2px; }
.initcard .modin { width: 2.8rem; padding: .1rem .25rem; min-height: 0; } .initcard .modin { width: 2.8rem; padding: .1rem .25rem; min-height: 0; }
.initcard { padding: .1rem .4rem; } .initcard { padding: .1rem .4rem; }
/* compact top area of the lobby */
.topbar { display: flex; align-items: stretch; gap: .4rem; }
.topctl { display: grid; grid-template-columns: 1fr 1fr; gap: 3px; align-content: center; }
.topctl button.mini { padding: 0; min-width: 28px; height: 26px; font-size: .8rem; line-height: 1; }
.topctl button.mini.wide { grid-column: span 1; }
.topscroll { flex: 1; min-width: 0; overflow-x: auto; overflow-y: hidden; padding-bottom: 4px; scrollbar-width: thin; }
.topscroll .charbar, .topscroll .initcards { flex-wrap: nowrap; width: max-content; }
.topbar .lobbycfg { margin-left: 0; align-self: center; flex: none; max-width: 11rem; }
.topmore { display: grid; gap: .5rem; padding: .4rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); }
.topmore .charbar { flex-wrap: wrap; }
.topbar + .topmore, .topbar + .diffbar { margin-top: -.2rem; }
.initcards { display: flex; gap: .35rem; align-items: center; }
.initcard { padding: .15rem .45rem .15rem .15rem; border-radius: 999px; border-left-width: 2px; flex: none; }
.initcard.player { border-left: 2px solid #4f93e6; border-color: #4f93e6; } .initcard.npc { border-left: 2px solid #c0392b; border-color: #c0392b; }
.initcard.cur { border-color: var(--accent, gold); }
.initpic { width: 30px; height: 30px; border-radius: 50%; overflow: hidden; display: grid; place-items: center; background: var(--panel); font-weight: 700; flex: none; }
.initpic img { width: 100%; height: 100%; object-fit: cover; }
.initname { white-space: nowrap; }
.initround { padding: 0 .4rem; flex: none; }
.pcard { flex: none; }
.playarea { gap: .4rem; }
.maptoken em { display: none; }
.maptoken:hover em, .maptoken.sel em, .maptoken.turn em { display: block; }
/* resizing */
.splitter { width: 10px; cursor: col-resize; align-self: stretch; position: relative; touch-action: none; }
.splitter::after { content: ''; position: absolute; left: 4px; top: 0; bottom: 0; width: 2px; border-radius: 2px; background: var(--line); transition: background .15s; }
.splitter:hover::after, body.resizing .splitter::after { background: var(--accent, gold); }
body.resizing { cursor: col-resize; user-select: none; }
.lobbygrid:not(.nosheet) { grid-template-columns: var(--sheetw) 10px minmax(0, 1fr); gap: .3rem; }
.chatsplit { position: absolute; left: -5px; top: 0; bottom: 0; z-index: 12; }
.chatsplit::after { left: 4px; }
@media (max-width: 1100px) {
.lobbygrid, .lobbygrid:not(.nosheet) { grid-template-columns: minmax(0, 1fr); }
.sheetsplit, .chatsplit { display: none; }
}