Lobby: online presence indicator

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 14:36:25 +00:00
parent 1db8a45cae
commit f6448aff85
4 changed files with 19 additions and 5 deletions
+2
View File
@@ -124,6 +124,8 @@ The **NPCs** tab is a library of rules-based NPCs. It uses the same wizard as th
- In the map editor and at the table, **+ From NPC library** / **+ NPC** places a token that takes the NPC's name, size and portrait. Plain **+ Token** still works. - In the map editor and at the table, **+ From NPC library** / **+ NPC** places a token that takes the NPC's name, size and portrait. Plain **+ Token** still works.
- The DM clicks an NPC token to see its sheet in the left column and can click attributes and skills to roll checks for it (shown in the chat as "NPC: Skill"). Players never see the NPC link or sheet. - The DM clicks an NPC token to see its sheet in the left column and can click attributes and skills to roll checks for it (shown in the chat as "NPC: Skill"). Players never see the NPC link or sheet.
Players and the DM show a green dot in the lobby while they have it open (online presence over the WebSocket; without WebSockets everyone appears offline).
### 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.
+8 -2
View File
@@ -659,19 +659,20 @@ def read_lobby(lid: str, user: dict = Depends(current_user)):
JOIN users u ON u.id = m.user_id LEFT JOIN characters c ON c.id = m.character_id JOIN users u ON u.id = m.user_id LEFT JOIN characters c ON c.id = m.character_id
WHERE m.lobby_id=? ORDER BY m.joined""", (lid,)).fetchall() WHERE m.lobby_id=? ORDER BY m.joined""", (lid,)).fetchall()
members = [] members = []
online = hub.online(lid)
for r in rows: for r in rows:
mine = r["user_id"] == user["id"] mine = r["user_id"] == user["id"]
card = _card(r["data"], rules) if r["data"] else None card = _card(r["data"], rules) if r["data"] else None
# The id is only given out where the viewer may open the full character (the DM sees all, a player only their own) # The id is only given out where the viewer may open the full character (the DM sees all, a player only their own)
members.append({"username": r["username"], "me": mine, "character": card, "color": r["color"], members.append({"username": r["username"], "me": mine, "character": card, "color": r["color"],
"mod": r["char_mod"] if (is_dm or mine) else None, "mod": r["char_mod"] if (is_dm or mine) else None, "online": r["user_id"] in online,
"character_id": r["character_id"] if (is_dm or mine) and card else None}) "character_id": r["character_id"] if (is_dm or mine) and card else None})
hidden = bool(lobby["diff_hidden"]) hidden = bool(lobby["diff_hidden"])
diff = {"label": lobby["diff_label"], "mod": lobby["diff_mod"], "hidden": hidden} diff = {"label": lobby["diff_label"], "mod": lobby["diff_mod"], "hidden": hidden}
if hidden and not is_dm: if hidden and not is_dm:
diff = {"label": "", "mod": None, "hidden": True} diff = {"label": "", "mod": None, "hidden": True}
return {"id": lobby["id"], "name": lobby["name"], "dm": lobby["dm"], "is_dm": is_dm, return {"id": lobby["id"], "name": lobby["name"], "dm": lobby["dm"], "is_dm": is_dm,
"code": lobby["code"] if is_dm else None, "members": members, "difficulty": diff, "dm_online": lobby["owner_id"] in online, "code": lobby["code"] if is_dm else None, "members": members, "difficulty": diff,
"sounds": sounds_mod.settings(lobby["sounds"]), "levels": _levels(rules), "palette": [{"id": i, "hex": h} for i, h in PALETTE], "dm_color": DM_COLOR} "sounds": sounds_mod.settings(lobby["sounds"]), "levels": _levels(rules), "palette": [{"id": i, "hex": h} for i, h in PALETTE], "dm_color": DM_COLOR}
@@ -1153,9 +1154,12 @@ async def lobby_socket(websocket: WebSocket, lid: str):
await websocket.close(code=4401) await websocket.close(code=4401)
return return
await websocket.accept() await websocket.accept()
was_online = user["id"] in hub.online(lid)
if not hub.add(lid, websocket, user["id"]): if not hub.add(lid, websocket, user["id"]):
await websocket.close(code=4429) await websocket.close(code=4429)
return return
if not was_online:
hub.notify(lid, "lobby") # presence changed
try: try:
last_check = time.time() last_check = time.time()
while True: while True:
@@ -1173,6 +1177,8 @@ async def lobby_socket(websocket: WebSocket, lid: str):
pass pass
finally: finally:
hub.remove(lid, websocket) hub.remove(lid, websocket)
if user["id"] not in hub.online(lid):
hub.notify(lid, "lobby")
try: try:
await websocket.close() await websocket.close()
except Exception: except Exception:
+3 -3
View File
@@ -430,9 +430,9 @@ function CharBar({ lobby, sel, onSel }) {
{lobby.members.map((m) => { {lobby.members.map((m) => {
const c = m.character, can = !!m.character_id const c = m.character, can = !!m.character_id
return ( return (
<div key={m.username} role="listitem" className={`pcard${sel === m.username ? ' sel' : ''}${can ? ' can' : ''}`} style={{ '--pc': m.color || 'var(--line)' }} <div key={m.username} role="listitem" className={`pcard${sel === m.username ? ' sel' : ''}${can ? ' can' : ''}${m.online ? '' : ' off'}`} title={m.online ? `${m.username} is online` : `${m.username} is offline`} style={{ '--pc': m.color || 'var(--line)' }}
onClick={() => can && onSel(m.username)} tabIndex={can ? 0 : undefined} onKeyDown={(e) => can && e.key === 'Enter' && onSel(m.username)}> onClick={() => can && onSel(m.username)} tabIndex={can ? 0 : undefined} onKeyDown={(e) => can && e.key === 'Enter' && onSel(m.username)}>
<span className="pavatar">{c?.portrait ? <img src={`/api/lobbies/${lobby.id}/portrait/${encodeURIComponent(m.username)}?v=${c.portrait}`} alt="" /> : (c?.name || m.username).slice(0, 1).toUpperCase()}</span> <span className="pavatar"><i className={`presence ${m.online ? 'on' : ''}`} aria-label={m.online ? 'online' : 'offline'} />{c?.portrait ? <img src={`/api/lobbies/${lobby.id}/portrait/${encodeURIComponent(m.username)}?v=${c.portrait}`} alt="" /> : (c?.name || m.username).slice(0, 1).toUpperCase()}</span>
<span className="pinfo"> <span className="pinfo">
<b title={m.username}>{c ? c.name : 'No character'}</b> <b title={m.username}>{c ? c.name : 'No character'}</b>
</span> </span>
@@ -730,7 +730,7 @@ function LobbyView({ id, rules, user }) {
<div className="lobbyhead"> <div className="lobbyhead">
<a className="small" href="#lobbies">← Lobbies</a> <a className="small" href="#lobbies">← Lobbies</a>
<h1>{lobby.name}</h1> <h1>{lobby.name}</h1>
<span className="muted small">DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''}</span> <span className="muted small"><i className={`presence inline ${lobby.dm_online ? 'on' : ''}`} aria-label={lobby.dm_online ? 'online' : 'offline'} /> DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''}</span>
<span className="tabright"> <span className="tabright">
<button className="ghost" onClick={() => (lobby.is_dm ? (setSheetOpen(true), setLeftTab('lobby')) : setShowSettings((v) => !v))} aria-expanded={lobby.is_dm ? sheetOpen && leftTab === 'lobby' : showSettings}>⚙ Lobby</button> <button className="ghost" onClick={() => (lobby.is_dm ? (setSheetOpen(true), setLeftTab('lobby')) : setShowSettings((v) => !v))} aria-expanded={lobby.is_dm ? sheetOpen && leftTab === 'lobby' : showSettings}>⚙ Lobby</button>
</span> </span>
+6
View File
@@ -495,3 +495,9 @@ label.chk input[type=checkbox] { width: auto; margin: 0; }
.npcpick button { display: flex; align-items: center; gap: .6rem; width: 100%; text-align: left; } .npcpick button { display: flex; align-items: center; gap: .6rem; width: 100%; text-align: left; }
.npcpickwrap { position: absolute; top: 3rem; left: .5rem; z-index: 5; } .npcpickwrap { position: absolute; top: 3rem; left: .5rem; z-index: 5; }
.pnpc .pre { white-space: pre-wrap; margin: .1rem 0 .6rem; font-size: .85rem; } .pnpc .pre { white-space: pre-wrap; margin: .1rem 0 .6rem; font-size: .85rem; }
.pavatar { position: relative; }
.presence { position: absolute; right: -3px; bottom: -3px; width: 11px; height: 11px; border-radius: 50%; background: #777; border: 2px solid var(--panel, #222); }
.presence.on { background: #3fb950; }
.presence.inline { position: static; display: inline-block; width: 9px; height: 9px; border: 0; margin-right: .2rem; }
.pcard.off { opacity: .6; }