@@ -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 typ e=" number " className=" modin " defaultValue={e.init} key={` ${e.i d}: ${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 && <butto n className=" linklike " onClick={(ev) => { ev.stopPropagation(); onOp({ op: 'remove', id: e.id }) }} title=" Remove from the fight " aria-label={`Remove ${e.name}`}>× </butto n>}
</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} rol e=" listitem " className={`initcard ${e.kin d}${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()} />
: <spa n className=" initnum ">{e.init}</spa n>}
{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')}
onP ick={(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 " onCl ick={() => 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 >