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
+73 -32
View File
@@ -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 (
<div className="charbar" role="list">
<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>}
{settings}
</div>
)
}
// ---- 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 <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
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)) : []
return (
<div className="initbar" role="list" aria-label="Initiative order">
<span className="initround"><b>Round {c.round}</b></span>
{c.entries.map((e) => (
<div key={e.id} role="listitem" className={`initcard ${e.kind}${c.current === e.id ? ' cur' : ''}`} onClick={() => onPick(e)}>
{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.`}
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>}
<b title={e.name}>{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: 'remove', id: e.id }) }} title="Remove from the fight" aria-label={`Remove ${e.name}`}>×</button>}
</div>
))}
<div className="initcards" role="list" aria-label="Initiative order">
<span className="initround" title="Round"><b>R{c.round}</b></span>
{c.entries.map((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
? <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()} />
: <span className="initnum">{e.init}</span>}
{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: 'remove', id: e.id }) }} title="Remove from the fight" aria-label={`Remove ${e.name}`}>×</button>}
</div>
)
})}
{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>
)
}
@@ -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 && <p className="warn">{err}</p>}
{info && <p className="small">{info}</p>}
<div className={`lobbygrid${sheetOpen ? '' : ' nosheet'}`}>
<div className={`lobbygrid${sheetOpen ? '' : ' nosheet'}`} style={{ '--sheetw': `${sheetW}px` }}>
{sheetOpen && (
<div className="sheetcol">
<div className="sheetbar">
@@ -788,14 +809,33 @@ function LobbyView({ id, rules, user }) {
</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">
{!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') }}
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>} />
<InitiativeBar lobby={lobby} onOp={combatOp} onStart={() => 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') } }} />
<DifficultyBar lobby={lobby} onSet={(b) => act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} />
<div className="topbar">
<div className="topctl" role="group" aria-label="Fight controls">
{lobby.is_dm && !lobby.combat && <button className="ghost mini wide" onClick={() => act(`/api/lobbies/${id}/combat`, 'POST')} title="Start a fight">⚔</button>}
{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') } }} />
: <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)} />
</div>
)}
<div className="table">
{showSettings && !lobby.is_dm && <Settings lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} onClose={() => setShowSettings(false)} />}
{mapState.map && (() => {
@@ -847,6 +887,7 @@ function LobbyView({ id, rules, user }) {
</div>
<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} />
</aside>
</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; }
.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; }
}