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:
@@ -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.
|
||||
- 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 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
@@ -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
|
||||
WHERE m.lobby_id=? ORDER BY m.joined""", (lid,)).fetchall()
|
||||
members = []
|
||||
online = hub.online(lid)
|
||||
for r in rows:
|
||||
mine = r["user_id"] == user["id"]
|
||||
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)
|
||||
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})
|
||||
hidden = bool(lobby["diff_hidden"])
|
||||
diff = {"label": lobby["diff_label"], "mod": lobby["diff_mod"], "hidden": hidden}
|
||||
if hidden and not is_dm:
|
||||
diff = {"label": "", "mod": None, "hidden": True}
|
||||
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}
|
||||
|
||||
|
||||
@@ -1153,9 +1154,12 @@ async def lobby_socket(websocket: WebSocket, lid: str):
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
await websocket.accept()
|
||||
was_online = user["id"] in hub.online(lid)
|
||||
if not hub.add(lid, websocket, user["id"]):
|
||||
await websocket.close(code=4429)
|
||||
return
|
||||
if not was_online:
|
||||
hub.notify(lid, "lobby") # presence changed
|
||||
try:
|
||||
last_check = time.time()
|
||||
while True:
|
||||
@@ -1173,6 +1177,8 @@ async def lobby_socket(websocket: WebSocket, lid: str):
|
||||
pass
|
||||
finally:
|
||||
hub.remove(lid, websocket)
|
||||
if user["id"] not in hub.online(lid):
|
||||
hub.notify(lid, "lobby")
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
|
||||
@@ -430,9 +430,9 @@ function CharBar({ lobby, sel, onSel }) {
|
||||
{lobby.members.map((m) => {
|
||||
const c = m.character, can = !!m.character_id
|
||||
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)}>
|
||||
<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">
|
||||
<b title={m.username}>{c ? c.name : 'No character'}</b>
|
||||
</span>
|
||||
@@ -730,7 +730,7 @@ function LobbyView({ id, rules, user }) {
|
||||
<div className="lobbyhead">
|
||||
<a className="small" href="#lobbies">← Lobbies</a>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -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; }
|
||||
.npcpickwrap { position: absolute; top: 3rem; left: .5rem; z-index: 5; }
|
||||
.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; }
|
||||
|
||||
Reference in New Issue
Block a user