diff --git a/README.md b/README.md index 0450c3b..871f6bf 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/app/main.py b/backend/app/main.py index f73783a..58cef4c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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: diff --git a/frontend/src/Lobbies.jsx b/frontend/src/Lobbies.jsx index 37d43ed..abe0f07 100644 --- a/frontend/src/Lobbies.jsx +++ b/frontend/src/Lobbies.jsx @@ -430,9 +430,9 @@ function CharBar({ lobby, sel, onSel }) { {lobby.members.map((m) => { const c = m.character, can = !!m.character_id return ( -
can && onSel(m.username)} tabIndex={can ? 0 : undefined} onKeyDown={(e) => can && e.key === 'Enter' && onSel(m.username)}> - {c?.portrait ? : (c?.name || m.username).slice(0, 1).toUpperCase()} + {c?.portrait ? : (c?.name || m.username).slice(0, 1).toUpperCase()} {c ? c.name : 'No character'} @@ -730,7 +730,7 @@ function LobbyView({ id, rules, user }) {
← Lobbies

{lobby.name}

- DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''} + DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6ceb13b..3eed402 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; }