From 7940fcfa3fca929893b10d75a540febbaad6b982 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 17:05:38 +0000 Subject: [PATCH] Compact lobby top bar, scrollable initiative cards, hidden NPC names, resizable panels Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K --- README.md | 2 + backend/app/combat.py | 2 + backend/app/maps.py | 9 ++++ frontend/src/Lobbies.jsx | 105 +++++++++++++++++++++++++++------------ frontend/src/styles.css | 37 ++++++++++++++ 5 files changed, 123 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 887d2e0..457079f 100644 --- a/README.md +++ b/README.md @@ -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 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 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. diff --git a/backend/app/combat.py b/backend/app/combat.py index ff81ed9..93a4f2e 100644 --- a/backend/app/combat.py +++ b/backend/app/combat.py @@ -58,6 +58,8 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, load_rules): continue if toks[e["token"]] and not is_dm: 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}) cur = c.get("current") return {"round": c["round"], "current": cur if any(e["id"] == cur for e in out) else None, "entries": out} diff --git a/backend/app/maps.py b/backend/app/maps.py index 86151c9..8530b3c 100644 --- a/backend/app/maps.py +++ b/backend/app/maps.py @@ -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 WHERE t.lobby_id=? ORDER BY t.rowid""", (lobby["id"],)).fetchall() 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 = [] for r in rows: 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"] except ValueError: name = r["username"] + if not player and not full and r["id"] != turn_tok: + name = "" pt = "" src = r["data"] if player else r["ndata"] if src: diff --git a/frontend/src/Lobbies.jsx b/frontend/src/Lobbies.jsx index 3e3de12..18ef5ba 100644 --- a/frontend/src/Lobbies.jsx +++ b/frontend/src/Lobbies.jsx @@ -424,7 +424,7 @@ function DiceTray({ rolls }) { } // ---- character bar: one card per player, later also for initiative and enemies -function CharBar({ lobby, sel, onSel, settings }) { +function CharBar({ lobby, sel, onSel }) { return (
@@ -448,38 +448,51 @@ function CharBar({ lobby, sel, onSel, settings }) { ) })} {lobby.members.length === 0 && Nobody has joined yet – share the join code (⚙ Lobby).} - {settings}
) } -// ---- initiative tracker: the DM starts a fight, players join automatically, NPCs are added from the token menu -function InitiativeBar({ lobby, onOp, onStart, onEnd, onPick }) { +// 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 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
+} + +// ---- 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 - if (!c) return dm ?
: 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)) : [] return ( -
- Round {c.round} - {c.entries.map((e) => ( -
onPick(e)}> - {dm - ? 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()} /> - : {e.init}} - {e.name} - {dm && } - {dm && } -
- ))} +
+ R{c.round} + {c.entries.map((e) => { + const cur = c.current === e.id, pic = cardPic(e), showName = dm || cur + return ( +
onPick(e)} title={showName ? `${e.name}: d20 ${e.roll} + ${e.base}` : 'Initiative'}> + {pic ? : (e.name || '?').slice(0, 1).toUpperCase()} + {dm + ? 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()} /> + : {e.init}} + {showName && {e.name}} + {dm && } + {dm && } +
+ ) + })} {missing.map((m) => )} - {dm && ( - - - - - - )}
) } @@ -716,7 +729,15 @@ function LobbyView({ id, rules, user }) { 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) } + 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 [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 [npcPick, setNpcPick] = useState(false) const addNpcToken = (n) => { @@ -768,7 +789,7 @@ function LobbyView({ id, rules, user }) { {err &&

{err}

} {info &&

{info}

} -
+
{sheetOpen && (
@@ -788,14 +809,33 @@ function LobbyView({ id, rules, user }) {
)} + {sheetOpen && }
{!sheetOpen && } - { setSel(u); setMapSel(null); setLeftTab('sheet') }} - settings={} /> - act(`/api/lobbies/${id}/combat`, 'POST')} - onEnd={() => window.confirm('End the fight?') && act(`/api/lobbies/${id}/combat`, 'DELETE')} - onPick={(e) => { if (e.kind === 'player') { setSel(e.user); setMapSel(null); setLeftTab('sheet') } else if (lobby.is_dm) { setMapSel(e.token); setLeftTab('sheet') } }} /> - act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} /> +
+
+ {lobby.is_dm && !lobby.combat && } + {lobby.is_dm && lobby.combat && <> + + + + } + +
+
+ {lobby.combat + ? { if (e.kind === 'player') { setSel(e.user); setMapSel(null); setLeftTab('sheet') } else if (lobby.is_dm) { setMapSel(e.token); setLeftTab('sheet') } }} /> + : { setSel(u); setMapSel(null); setLeftTab('sheet') }} />} +
+ +
+ {topOpen && ( +
+ {lobby.combat && { setSel(u); setMapSel(null); setLeftTab('sheet') }} />} + act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} /> +
+ )}
{showSettings && !lobby.is_dm && setShowSettings(false)} />} {mapState.map && (() => { @@ -847,6 +887,7 @@ function LobbyView({ id, rules, user }) {
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6f8fe1c..377c113 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; } .initcard .modin { width: 2.8rem; padding: .1rem .25rem; min-height: 0; } .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; } +}