Lobby: desktop layout with chat column, dice hotbar and shared dice tray, server-side rolls
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
@@ -85,11 +85,12 @@ Each wizard page has a **Reset this page** button that clears only that page's d
|
|||||||
|
|
||||||
The **Lobbies** tab groups a campaign. Whoever creates a lobby is its DM and gets an 8-character join code. Players join with the code and pick one of their saved characters (they can switch it later or leave).
|
The **Lobbies** tab groups a campaign. Whoever creates a lobby is its DM and gets an 8-character join code. Players join with the code and pick one of their saved characters (they can switch it later or leave).
|
||||||
|
|
||||||
A lobby has tabs:
|
The lobby page is built for desktop: the main area on the left, the chat as a fixed column on the right and a dice hotbar at the bottom centre.
|
||||||
|
|
||||||
- **Chat**: live chat for everyone, plus **whispers** to a single person (pick them in the dropdown next to the message field). Whispers are only visible to sender and recipient, the DM does not see other people's whispers. The same feed shows the **event log** (joins, leaves, character switches, renames, new codes); it can be hidden with a checkbox. The chat polls the server every few seconds, keeps the last 1000 entries per lobby and allows 500 characters per message and 30 messages per minute.
|
- **Chat** (right column): live chat for everyone, plus **whispers** to a single person (pick them in the dropdown next to the message field). Whispers are only visible to sender and recipient, the DM does not see other people's whispers. The same feed shows the **event log** (joins, leaves, character switches, renames, new codes); it can be hidden with a checkbox. The chat polls the server every few seconds, keeps the last 1000 entries per lobby and allows 500 characters per message and 30 messages per minute.
|
||||||
- **Party**: member cards (name, concept, class, profession). The DM also finds the join code, rename, remove player and delete lobby here.
|
- **Dice** (bottom centre): d4, d6, d8, d10, d12, d20 and d100, with a count (1 to 10 dice). A click rolls on the server (so nobody can cheat), the dice tumble into a tray above the hotbar for everybody in the lobby and the result is posted in the chat. Rolls follow the chat's recipient dropdown: with a person selected it is a private roll only that person (and you) can see.
|
||||||
- **One sheet tab per character**: the DM gets a tab for every character in the lobby (read-only, always the current saved version, refreshed every 15 s); a player gets "My character". Hover or tap a value to see how it is made up. "Open in the Character tab" jumps to the full editor (for the DM it is a read-only look, "Save" would create the DM's own copy).
|
- **Party** (tab): member cards (name, concept, class, profession). The DM also finds the join code, rename, remove player and delete lobby here.
|
||||||
|
- **One sheet tab per character** (next to Party): the DM gets a tab for every character in the lobby (read-only, always the current saved version, refreshed every 15 s); a player gets "My character". Hover or tap a value to see how it is made up. "Open in the Character tab" jumps to the full editor (for the DM it is a read-only look, "Save" would create the DM's own copy).
|
||||||
|
|
||||||
Players only see a short card of the other players, never their sheets. Guessing join codes is rate limited. Deleting a character takes it out of the lobbies (the player can pick another); deleting a lobby does not delete characters.
|
Players only see a short card of the other players, never their sheets. Guessing join codes is rate limited. Deleting a character takes it out of the lobbies (the player can pick another); deleting a lobby does not delete characters.
|
||||||
|
|
||||||
@@ -111,6 +112,7 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l
|
|||||||
| GET / PUT / DELETE | `/api/lobbies/{id}` | Members and cards (DM sees all ids) / rename / delete (DM only) |
|
| GET / PUT / DELETE | `/api/lobbies/{id}` | Members and cards (DM sees all ids) / rename / delete (DM only) |
|
||||||
| POST | `/api/lobbies/{id}/code` | New join code (DM only) |
|
| POST | `/api/lobbies/{id}/code` | New join code (DM only) |
|
||||||
| GET / POST | `/api/lobbies/{id}/messages` | Chat and event log (`?after=<id>` to poll) / send, optional `to` = whisper |
|
| GET / POST | `/api/lobbies/{id}/messages` | Chat and event log (`?after=<id>` to poll) / send, optional `to` = whisper |
|
||||||
|
| POST | `/api/lobbies/{id}/rolls` | Roll `{sides, count, to?}` (d4–d20, d100; up to 10 dice), result goes into the chat |
|
||||||
| PUT / DELETE | `/api/lobbies/{id}/me` | Switch your character / leave |
|
| PUT / DELETE | `/api/lobbies/{id}/me` | Switch your character / leave |
|
||||||
| DELETE | `/api/lobbies/{id}/members/{username}` | Remove a player (DM only) |
|
| DELETE | `/api/lobbies/{id}/members/{username}` | Remove a player (DM only) |
|
||||||
| POST | `/api/presets` | Create a preset |
|
| POST | `/api/presets` | Create a preset |
|
||||||
|
|||||||
+48
-11
@@ -521,6 +521,19 @@ def read_lobby(lid: str, user: dict = Depends(current_user)):
|
|||||||
"code": lobby["code"] if is_dm else None, "members": members}
|
"code": lobby["code"] if is_dm else None, "members": members}
|
||||||
|
|
||||||
|
|
||||||
|
def _recipient(con, lobby, user: dict, to):
|
||||||
|
"""User id to whisper to (the DM or a member, not yourself), None for everybody."""
|
||||||
|
to = str(to or "").strip()
|
||||||
|
if not to:
|
||||||
|
return None
|
||||||
|
target = con.execute("""SELECT u.id FROM users u WHERE u.username=? AND (u.id=? OR EXISTS
|
||||||
|
(SELECT 1 FROM lobby_members m WHERE m.lobby_id=? AND m.user_id=u.id))""",
|
||||||
|
(to, lobby["owner_id"], lobby["id"])).fetchone()
|
||||||
|
if not target or target["id"] == user["id"]:
|
||||||
|
raise HTTPException(422, "You can only whisper to other people in this lobby")
|
||||||
|
return target["id"]
|
||||||
|
|
||||||
|
|
||||||
@api.get("/lobbies/{lid}/messages")
|
@api.get("/lobbies/{lid}/messages")
|
||||||
def read_messages(lid: str, after: int = 0, user: dict = Depends(current_user)):
|
def read_messages(lid: str, after: int = 0, user: dict = Depends(current_user)):
|
||||||
"""Chat and event log. Whispers are only returned to the sender and the recipient (not even the DM sees them).
|
"""Chat and event log. Whispers are only returned to the sender and the recipient (not even the DM sees them).
|
||||||
@@ -534,8 +547,14 @@ def read_messages(lid: str, after: int = 0, user: dict = Depends(current_user)):
|
|||||||
rows = con.execute(q + " AND m.id>? ORDER BY m.id LIMIT 200", (lid, user["id"], user["id"], after)).fetchall()
|
rows = con.execute(q + " AND m.id>? ORDER BY m.id LIMIT 200", (lid, user["id"], user["id"], after)).fetchall()
|
||||||
else:
|
else:
|
||||||
rows = list(reversed(con.execute(q + " ORDER BY m.id DESC LIMIT 100", (lid, user["id"], user["id"])).fetchall()))
|
rows = list(reversed(con.execute(q + " ORDER BY m.id DESC LIMIT 100", (lid, user["id"], user["id"])).fetchall()))
|
||||||
return [{"id": r["id"], "kind": r["kind"], "text": r["text"], "time": r["created"], "from": r["sender"] or "",
|
out = []
|
||||||
"to": r["target"] or "", "mine": r["user_id"] == user["id"] and r["kind"] != "event"} for r in rows]
|
for r in rows:
|
||||||
|
item = {"id": r["id"], "kind": r["kind"], "text": r["text"], "time": r["created"], "from": r["sender"] or "",
|
||||||
|
"to": r["target"] or "", "mine": r["user_id"] == user["id"] and r["kind"] != "event"}
|
||||||
|
if r["kind"] == "roll":
|
||||||
|
item["roll"], item["text"] = json.loads(r["text"]), ""
|
||||||
|
out.append(item)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@api.post("/lobbies/{lid}/messages")
|
@api.post("/lobbies/{lid}/messages")
|
||||||
@@ -549,15 +568,7 @@ def send_message(lid: str, body: dict = Body(...), user: dict = Depends(current_
|
|||||||
limiter.fail(f"chat:{user['id']}")
|
limiter.fail(f"chat:{user['id']}")
|
||||||
with db() as con:
|
with db() as con:
|
||||||
lobby = _lobby_for(con, lid, user)
|
lobby = _lobby_for(con, lid, user)
|
||||||
to = str(body.get("to") or "").strip()
|
to_id = _recipient(con, lobby, user, body.get("to"))
|
||||||
to_id = None
|
|
||||||
if to:
|
|
||||||
target = con.execute("""SELECT u.id FROM users u WHERE u.username=? AND (u.id=? OR EXISTS
|
|
||||||
(SELECT 1 FROM lobby_members m WHERE m.lobby_id=? AND m.user_id=u.id))""",
|
|
||||||
(to, lobby["owner_id"], lid)).fetchone()
|
|
||||||
if not target or target["id"] == user["id"]:
|
|
||||||
raise HTTPException(422, "You can only whisper to other people in this lobby")
|
|
||||||
to_id = target["id"]
|
|
||||||
cur = con.execute("INSERT INTO lobby_messages (lobby_id, user_id, to_user_id, kind, text, created) VALUES (?,?,?,?,?,?)",
|
cur = con.execute("INSERT INTO lobby_messages (lobby_id, user_id, to_user_id, kind, text, created) VALUES (?,?,?,?,?,?)",
|
||||||
(lid, user["id"], to_id, "whisper" if to_id else "chat", text, time.time()))
|
(lid, user["id"], to_id, "whisper" if to_id else "chat", text, time.time()))
|
||||||
con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE(
|
con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE(
|
||||||
@@ -565,6 +576,32 @@ def send_message(lid: str, body: dict = Body(...), user: dict = Depends(current_
|
|||||||
return {"id": cur.lastrowid}
|
return {"id": cur.lastrowid}
|
||||||
|
|
||||||
|
|
||||||
|
DICE = (4, 6, 8, 10, 12, 20, 100)
|
||||||
|
MAX_DICE = 10
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/lobbies/{lid}/rolls")
|
||||||
|
def roll_dice(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||||
|
"""Rolls on the server (so nobody can cheat) and posts the result in the chat; with `to` it is a private roll."""
|
||||||
|
try:
|
||||||
|
sides, count = int(body.get("sides")), int(body.get("count", 1))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(422, "Invalid dice")
|
||||||
|
if sides not in DICE or not 1 <= count <= MAX_DICE:
|
||||||
|
raise HTTPException(422, f"Roll 1 to {MAX_DICE} dice of: " + ", ".join(f"d{d}" for d in DICE))
|
||||||
|
throttle(f"chat:{user['id']}", 30, 60)
|
||||||
|
limiter.fail(f"chat:{user['id']}")
|
||||||
|
results = [secrets.randbelow(sides) + 1 for _ in range(count)]
|
||||||
|
with db() as con:
|
||||||
|
lobby = _lobby_for(con, lid, user)
|
||||||
|
to_id = _recipient(con, lobby, user, body.get("to"))
|
||||||
|
cur = con.execute("INSERT INTO lobby_messages (lobby_id, user_id, to_user_id, kind, text, created) VALUES (?,?,?,?,?,?)",
|
||||||
|
(lid, user["id"], to_id, "roll", json.dumps({"sides": sides, "results": results}), time.time()))
|
||||||
|
con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE(
|
||||||
|
(SELECT id FROM lobby_messages WHERE lobby_id=? ORDER BY id DESC LIMIT 1 OFFSET ?), 0)""", (lid, lid, KEEP_MESSAGES))
|
||||||
|
return {"id": cur.lastrowid, "sides": sides, "results": results}
|
||||||
|
|
||||||
|
|
||||||
@api.put("/lobbies/{lid}")
|
@api.put("/lobbies/{lid}")
|
||||||
def rename_lobby(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
def rename_lobby(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||||
name = str(body.get("name", "")).strip()[:60]
|
name = str(body.get("name", "")).strip()[:60]
|
||||||
|
|||||||
+160
-33
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import SheetView from './SheetView.jsx'
|
import SheetView from './SheetView.jsx'
|
||||||
|
|
||||||
const J = { 'Content-Type': 'application/json' }
|
const J = { 'Content-Type': 'application/json' }
|
||||||
@@ -127,33 +127,49 @@ function Card({ m, isDm, onOpen, onKick }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const clock = (t) => new Date(t * 1000).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
const clock = (t) => new Date(t * 1000).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
const DICE = [4, 6, 8, 10, 12, 20, 100]
|
||||||
|
const reduced = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||||
|
|
||||||
// ---- chat, whispers and event log (polls the server every few seconds while the tab is open)
|
// Chat, whispers, event log and dice rolls arrive through one feed that is polled while the page is open.
|
||||||
function Chat({ id, me, people, onEvent }) {
|
function useFeed(id, onEvent, onRoll) {
|
||||||
const [msgs, setMsgs] = useState([])
|
const [msgs, setMsgs] = useState([])
|
||||||
const [text, setText] = useState('')
|
|
||||||
const [to, setTo] = useState('')
|
|
||||||
const [events, setEvents] = useState(true)
|
|
||||||
const [err, setErr] = useState('')
|
|
||||||
const last = useRef(0)
|
const last = useRef(0)
|
||||||
const box = useRef(null)
|
const live = useRef(false) // false until the history is loaded: old rolls are not animated again
|
||||||
const stick = useRef(true)
|
const cb = useRef({})
|
||||||
|
cb.current = { onEvent, onRoll }
|
||||||
const poll = async () => {
|
const poll = useCallback(async () => {
|
||||||
if (document.hidden) return
|
if (document.hidden) return
|
||||||
const r = await fetch(`/api/lobbies/${id}/messages?after=${last.current}`).catch(() => null)
|
const r = await fetch(`/api/lobbies/${id}/messages?after=${last.current}`).catch(() => null)
|
||||||
if (!r || !r.ok) return
|
if (!r || !r.ok) return
|
||||||
const fresh = await r.json()
|
const fresh = await r.json()
|
||||||
|
const wasLive = live.current
|
||||||
|
live.current = true
|
||||||
if (!fresh.length) return
|
if (!fresh.length) return
|
||||||
last.current = fresh[fresh.length - 1].id
|
last.current = fresh[fresh.length - 1].id
|
||||||
setMsgs((old) => [...old, ...fresh].slice(-300))
|
setMsgs((old) => [...old, ...fresh].slice(-300))
|
||||||
if (fresh.some((m) => m.kind === 'event')) onEvent()
|
if (fresh.some((m) => m.kind === 'event')) cb.current.onEvent()
|
||||||
}
|
if (wasLive) fresh.filter((m) => m.kind === 'roll').forEach((m) => cb.current.onRoll(m))
|
||||||
|
}, [id])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
poll()
|
poll()
|
||||||
const t = setInterval(poll, 2500)
|
const t = setInterval(poll, 2500)
|
||||||
return () => clearInterval(t)
|
return () => clearInterval(t)
|
||||||
}, [id]) // eslint-disable-line
|
}, [poll])
|
||||||
|
return { msgs, poll }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rollText = (m) => {
|
||||||
|
const n = m.roll.results.length, sum = m.roll.results.reduce((a, b) => a + b, 0)
|
||||||
|
return { dice: `${n > 1 ? n : ''}d${m.roll.sides}`, list: m.roll.results.join(' + '), sum, n }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chat({ me, people, msgs, poll, id, to, setTo }) {
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const [events, setEvents] = useState(true)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const box = useRef(null)
|
||||||
|
const stick = useRef(true)
|
||||||
|
|
||||||
useEffect(() => { // keep the newest line in view unless the user scrolled up
|
useEffect(() => { // keep the newest line in view unless the user scrolled up
|
||||||
const el = box.current
|
const el = box.current
|
||||||
if (el && stick.current) el.scrollTop = el.scrollHeight
|
if (el && stick.current) el.scrollTop = el.scrollHeight
|
||||||
@@ -162,43 +178,136 @@ function Chat({ id, me, people, onEvent }) {
|
|||||||
const submit = async (e) => {
|
const submit = async (e) => {
|
||||||
e.preventDefault(); setErr('')
|
e.preventDefault(); setErr('')
|
||||||
if (!text.trim()) return
|
if (!text.trim()) return
|
||||||
const r = await send_(id, text, to)
|
const r = await send(`/api/lobbies/${id}/messages`, 'POST', { text, to })
|
||||||
if (!r.ok) return setErr(msg(await r.json().catch(() => ({})), 'Could not send.'))
|
if (!r.ok) return setErr(msg(await r.json().catch(() => ({})), 'Could not send.'))
|
||||||
setText(''); stick.current = true; poll()
|
setText(''); stick.current = true; poll()
|
||||||
}
|
}
|
||||||
const shown = msgs.filter((m) => events || m.kind !== 'event')
|
const shown = msgs.filter((m) => events || m.kind !== 'event')
|
||||||
|
const who = (m) => (
|
||||||
|
<>
|
||||||
|
<strong>{m.mine ? 'You' : m.from || '?'}</strong>
|
||||||
|
{m.to && <em className="small whispertag"> {m.mine ? `to ${m.to}` : 'to you'} (private)</em>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chatwrap">
|
<div className="chatwrap">
|
||||||
<div className="chatbox" ref={box} onScroll={(e) => { const el = e.currentTarget; stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40 }}
|
<div className="chatbox" ref={box} onScroll={(e) => { const el = e.currentTarget; stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40 }}
|
||||||
role="log" aria-live="polite">
|
role="log" aria-live="polite">
|
||||||
{shown.length === 0 && <p className="muted small">Nothing here yet.</p>}
|
{shown.length === 0 && <p className="muted small">Nothing here yet.</p>}
|
||||||
{shown.map((m) => m.kind === 'event' ? (
|
{shown.map((m) => {
|
||||||
<div key={m.id} className="msg event"><span className="time">{clock(m.time)}</span> {m.text}</div>
|
if (m.kind === 'event') return <div key={m.id} className="msg event"><span className="time">{clock(m.time)}</span> {m.text}</div>
|
||||||
) : (
|
if (m.kind === 'roll') {
|
||||||
<div key={m.id} className={`msg ${m.kind} ${m.mine ? 'mine' : ''}`}>
|
const t = rollText(m)
|
||||||
<span className="time">{clock(m.time)}</span>{' '}
|
return (
|
||||||
<strong>{m.mine ? 'You' : m.from || '?'}</strong>
|
<div key={m.id} className={`msg rollmsg ${m.to ? 'whisper' : ''} ${m.mine ? 'mine' : ''}`}>
|
||||||
{m.kind === 'whisper' && <em className="small"> {m.mine ? `whisper to ${m.to}` : 'whispers to you'}</em>}
|
<span className="time">{clock(m.time)}</span> {who(m)} rolled <strong>{t.dice}</strong>:{' '}
|
||||||
<span>: {m.text}</span>
|
<span className="rollres">{t.n > 1 ? `${t.list} = ` : ''}<b>{t.sum}</b></span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={m.id} className={`msg ${m.kind} ${m.mine ? 'mine' : ''}`}>
|
||||||
|
<span className="time">{clock(m.time)}</span> {who(m)}<span>: {m.text}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<form className="row chatform" onSubmit={submit}>
|
<form className="row chatform" onSubmit={submit}>
|
||||||
<select value={to} onChange={(e) => setTo(e.target.value)} aria-label="Send to" className={to ? 'whisper' : ''}>
|
<select value={to} onChange={(e) => setTo(e.target.value)} aria-label="Send to" className={to ? 'whisper' : ''}>
|
||||||
<option value="">Everyone</option>
|
<option value="">Everyone</option>
|
||||||
{people.filter((p) => p !== me).map((p) => <option key={p} value={p}>Whisper to {p}</option>)}
|
{people.filter((p) => p !== me).map((p) => <option key={p} value={p}>Only {p} (whisper)</option>)}
|
||||||
</select>
|
</select>
|
||||||
<input value={text} onChange={(e) => setText(e.target.value)} placeholder={to ? `Whisper to ${to} …` : 'Message …'} maxLength={500} aria-label="Message" autoComplete="off" />
|
<input value={text} onChange={(e) => setText(e.target.value)} placeholder={to ? `Whisper to ${to} …` : 'Message …'} maxLength={500} aria-label="Message" autoComplete="off" />
|
||||||
<button type="submit" disabled={!text.trim()}>Send</button>
|
<button type="submit" disabled={!text.trim()}>Send</button>
|
||||||
</form>
|
</form>
|
||||||
{err && <p className="warn small">{err}</p>}
|
{err && <p className="warn small">{err}</p>}
|
||||||
<label className="small muted"><input type="checkbox" checked={events} onChange={(e) => setEvents(e.target.checked)} style={{ width: 'auto' }} /> Show events (joins, character switches …)</label>
|
<label className="small muted"><input type="checkbox" checked={events} onChange={(e) => setEvents(e.target.checked)} style={{ width: 'auto' }} /> Show events (joins, character switches …)</label>
|
||||||
<p className="small muted">Whispers are only seen by you and the person you send them to, the DM does not see other people's whispers.</p>
|
<p className="small muted">Whispers and private rolls are only seen by you and the person you pick, not even the DM sees other people's.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- dice: hotbar at the bottom, results fly into a tray above it and are posted in the chat
|
||||||
|
const SHAPE = { 4: '50% 4%, 96% 92%, 4% 92%', 6: '6% 6%, 94% 6%, 94% 94%, 6% 94%', 8: '50% 0, 100% 50%, 50% 100%, 0 50%', 10: '50% 0, 98% 40%, 50% 100%, 2% 40%',
|
||||||
|
12: '50% 0, 98% 38%, 80% 96%, 20% 96%, 2% 38%', 20: '25% 4%, 75% 4%, 100% 50%, 75% 96%, 25% 96%, 0 50%', 100: '' }
|
||||||
|
|
||||||
|
function DieIcon({ sides }) {
|
||||||
|
const pts = SHAPE[sides] && SHAPE[sides].split(', ').map((p) => p.split(' ').map((v) => parseFloat(v) * .24).join(',')).join(' ')
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
|
||||||
|
{pts ? <polygon points={pts} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" /> : <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="1.6" />}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiceBar({ id, to, poll }) {
|
||||||
|
const [count, setCount] = useState(1)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const roll = async (sides) => {
|
||||||
|
if (busy) return
|
||||||
|
setBusy(true); setErr('')
|
||||||
|
const r = await send(`/api/lobbies/${id}/rolls`, 'POST', { sides, count, to }).catch(() => null)
|
||||||
|
if (!r || !r.ok) setErr(msg(r ? await r.json().catch(() => ({})) : {}, 'Roll failed.'))
|
||||||
|
else poll()
|
||||||
|
setTimeout(() => setBusy(false), 350)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="dicebar" role="toolbar" aria-label="Dice">
|
||||||
|
<label className="small muted dicecount">Dice
|
||||||
|
<select value={count} onChange={(e) => setCount(+e.target.value)} aria-label="Number of dice">
|
||||||
|
{[1, 2, 3, 4, 5, 6, 8, 10].map((n) => <option key={n} value={n}>{n}×</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{DICE.map((d) => (
|
||||||
|
<button key={d} className="ghost diebtn" onClick={() => roll(d)} disabled={busy} title={`Roll ${count > 1 ? count : ''}d${d}`} aria-label={`Roll ${count} d${d}`}>
|
||||||
|
<DieIcon sides={d} /><span>d{d}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span className={`small dicedest ${to ? 'priv' : 'muted'}`}>{to ? `Private: ${to}` : 'Public'}</span>
|
||||||
|
{err && <span className="warn small">{err}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Die({ sides, value, delay }) {
|
||||||
|
const [shown, setShown] = useState(reduced() ? value : null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (reduced()) return
|
||||||
|
const t0 = Date.now(), run = 650 + delay
|
||||||
|
const iv = setInterval(() => {
|
||||||
|
if (Date.now() - t0 >= run) { setShown(value); clearInterval(iv) } else setShown(1 + Math.floor(Math.random() * sides))
|
||||||
|
}, 70)
|
||||||
|
return () => clearInterval(iv)
|
||||||
|
}, [sides, value, delay])
|
||||||
|
const rolling = shown !== value
|
||||||
|
return (
|
||||||
|
<span className={`die d${sides} ${rolling ? 'rolling' : 'landed'}`} style={{ animationDelay: `${delay % 200}ms` }}>
|
||||||
|
<span>{shown ?? '?'}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiceTray({ rolls, me }) {
|
||||||
|
if (!rolls.length) return null
|
||||||
|
return (
|
||||||
|
<div className="dicetray" aria-hidden="true">
|
||||||
|
{rolls.map((m) => {
|
||||||
|
const t = rollText(m)
|
||||||
|
return (
|
||||||
|
<div key={m.key} className="trayroll">
|
||||||
|
<div className="small">{m.mine ? 'You' : m.from} rolled <strong>{t.dice}</strong>{m.to ? ` · private${m.mine ? ` to ${m.to}` : ''}` : ''}</div>
|
||||||
|
<div className="trayrow">
|
||||||
|
{m.roll.results.map((v, i) => <Die key={i} sides={m.roll.sides} value={v} delay={i * 120} />)}
|
||||||
|
{t.n > 1 && <span className="traytotal">= {t.sum}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const send_ = (id, text, to) => send(`/api/lobbies/${id}/messages`, 'POST', { text, to })
|
|
||||||
|
|
||||||
// ---- sheet of one character, refreshed regularly so the DM sees changes the player saves
|
// ---- sheet of one character, refreshed regularly so the DM sees changes the player saves
|
||||||
function CharTab({ cid, owner, rules, onOpen, name }) {
|
function CharTab({ cid, owner, rules, onOpen, name }) {
|
||||||
@@ -224,7 +333,7 @@ function CharTab({ cid, owner, rules, onOpen, name }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- one lobby: Chat, Party (members, settings) and a sheet tab per visible character
|
// ---- one lobby: member cards and one sheet tab per visible character on the left, chat on the right, dice at the bottom
|
||||||
function LobbyView({ id, rules, user, onOpen }) {
|
function LobbyView({ id, rules, user, onOpen }) {
|
||||||
const mine = useMine()
|
const mine = useMine()
|
||||||
const [lobby, setLobby] = useState(null)
|
const [lobby, setLobby] = useState(null)
|
||||||
@@ -232,7 +341,9 @@ function LobbyView({ id, rules, user, onOpen }) {
|
|||||||
const [info, setInfo] = useState('')
|
const [info, setInfo] = useState('')
|
||||||
const [pick, setPick] = useState('')
|
const [pick, setPick] = useState('')
|
||||||
const [rename, setRename] = useState('')
|
const [rename, setRename] = useState('')
|
||||||
const [tab, setTab] = useState('chat')
|
const [tab, setTab] = useState('party')
|
||||||
|
const [to, setTo] = useState('') // whisper recipient, used for chat messages and dice rolls
|
||||||
|
const [tray, setTray] = useState([])
|
||||||
|
|
||||||
const load = () => fetch(`/api/lobbies/${id}`).then(async (r) => {
|
const load = () => fetch(`/api/lobbies/${id}`).then(async (r) => {
|
||||||
if (!r.ok) throw new Error(r.status)
|
if (!r.ok) throw new Error(r.status)
|
||||||
@@ -244,6 +355,13 @@ function LobbyView({ id, rules, user, onOpen }) {
|
|||||||
return () => clearInterval(t)
|
return () => clearInterval(t)
|
||||||
}, [id]) // eslint-disable-line
|
}, [id]) // eslint-disable-line
|
||||||
|
|
||||||
|
const onRoll = useCallback((m) => {
|
||||||
|
const key = `${m.id}`
|
||||||
|
setTray((t) => [...t.slice(-2), { ...m, key }])
|
||||||
|
setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 9000)
|
||||||
|
}, [])
|
||||||
|
const feed = useFeed(id, load, onRoll)
|
||||||
|
|
||||||
const act = async (url, method, body, done) => {
|
const act = async (url, method, body, done) => {
|
||||||
setErr(''); setInfo('')
|
setErr(''); setInfo('')
|
||||||
const r = await send(url, method, body).catch(() => null)
|
const r = await send(url, method, body).catch(() => null)
|
||||||
@@ -260,6 +378,7 @@ function LobbyView({ id, rules, user, onOpen }) {
|
|||||||
const people = [lobby.dm, ...lobby.members.map((m) => m.username)]
|
const people = [lobby.dm, ...lobby.members.map((m) => m.username)]
|
||||||
const sheets = lobby.members.filter((m) => m.character_id) // the server only gives out ids the viewer may open
|
const sheets = lobby.members.filter((m) => m.character_id) // the server only gives out ids the viewer may open
|
||||||
const active = tab.startsWith('sheet:') && !sheets.find((m) => m.username === tab.slice(6)) ? 'party' : tab
|
const active = tab.startsWith('sheet:') && !sheets.find((m) => m.username === tab.slice(6)) ? 'party' : tab
|
||||||
|
const dest = people.includes(to) ? to : ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page lobbypage">
|
<section className="page lobbypage">
|
||||||
@@ -271,8 +390,9 @@ function LobbyView({ id, rules, user, onOpen }) {
|
|||||||
{err && <p className="warn">{err}</p>}
|
{err && <p className="warn">{err}</p>}
|
||||||
{info && <p className="small">{info}</p>}
|
{info && <p className="small">{info}</p>}
|
||||||
|
|
||||||
|
<div className="lobbygrid">
|
||||||
|
<div className="lobbymain">
|
||||||
<div className="tabs" role="tablist">
|
<div className="tabs" role="tablist">
|
||||||
<button role="tab" aria-selected={active === 'chat'} className={active === 'chat' ? 'on' : ''} onClick={() => setTab('chat')}>Chat</button>
|
|
||||||
<button role="tab" aria-selected={active === 'party'} className={active === 'party' ? 'on' : ''} onClick={() => setTab('party')}>Party ({lobby.members.length})</button>
|
<button role="tab" aria-selected={active === 'party'} className={active === 'party' ? 'on' : ''} onClick={() => setTab('party')}>Party ({lobby.members.length})</button>
|
||||||
{sheets.map((m) => (
|
{sheets.map((m) => (
|
||||||
<button key={m.username} role="tab" aria-selected={active === `sheet:${m.username}`} className={active === `sheet:${m.username}` ? 'on' : ''}
|
<button key={m.username} role="tab" aria-selected={active === `sheet:${m.username}`} className={active === `sheet:${m.username}` ? 'on' : ''}
|
||||||
@@ -280,8 +400,6 @@ function LobbyView({ id, rules, user, onOpen }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{active === 'chat' && <Chat key={id} id={id} me={user} people={people} onEvent={load} />}
|
|
||||||
|
|
||||||
{active.startsWith('sheet:') && (() => {
|
{active.startsWith('sheet:') && (() => {
|
||||||
const m = sheets.find((x) => x.username === active.slice(6))
|
const m = sheets.find((x) => x.username === active.slice(6))
|
||||||
return <CharTab key={m.character_id} cid={m.character_id} owner={m.me ? '' : m.username} name={m.character.name} rules={rules} onOpen={() => open(m)} />
|
return <CharTab key={m.character_id} cid={m.character_id} owner={m.me ? '' : m.username} name={m.character.name} rules={rules} onOpen={() => open(m)} />
|
||||||
@@ -309,7 +427,7 @@ function LobbyView({ id, rules, user, onOpen }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{lobby.members.length === 0 && <p className="card">Nobody has joined yet. Share the code above.</p>}
|
{lobby.members.length === 0 && <p className="card">Nobody has joined yet. Share the code above.</p>}
|
||||||
<ul className="charlist">
|
<ul className="charlist partygrid">
|
||||||
{lobby.members.map((m) => (
|
{lobby.members.map((m) => (
|
||||||
<Card key={m.username} m={m} isDm={lobby.is_dm} onOpen={(x) => setTab(`sheet:${x.username}`)}
|
<Card key={m.username} m={m} isDm={lobby.is_dm} onOpen={(x) => setTab(`sheet:${x.username}`)}
|
||||||
onKick={(x) => window.confirm(`Remove ${x.username} from the lobby?`) && act(`/api/lobbies/${id}/members/${encodeURIComponent(x.username)}`, 'DELETE')} />
|
onKick={(x) => window.confirm(`Remove ${x.username} from the lobby?`) && act(`/api/lobbies/${id}/members/${encodeURIComponent(x.username)}`, 'DELETE')} />
|
||||||
@@ -335,6 +453,15 @@ function LobbyView({ id, rules, user, onOpen }) {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="lobbychat card" aria-label="Chat">
|
||||||
|
<Chat id={id} me={user} people={people} msgs={feed.msgs} poll={feed.poll} to={dest} setTo={setTo} />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DiceTray rolls={tray} me={user} />
|
||||||
|
<DiceBar id={id} to={dest} poll={feed.poll} />
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,3 +204,44 @@ h2 + .charlist, .card + h2 { margin-top: 1.4rem; }
|
|||||||
.sheetview h4 { margin: 1rem 0 .3rem; }
|
.sheetview h4 { margin: 1rem 0 .3rem; }
|
||||||
.sheetview table { width: 100%; }
|
.sheetview table { width: 100%; }
|
||||||
@media (max-width: 760px) { .sheetcols { grid-template-columns: 1fr; } .chatform { flex-wrap: wrap; } .chatform select { width: 100%; } }
|
@media (max-width: 760px) { .sheetcols { grid-template-columns: 1fr; } .chatform { flex-wrap: wrap; } .chatform select { width: 100%; } }
|
||||||
|
|
||||||
|
/* lobby desktop layout: main area left, chat column right, dice bar bottom centre */
|
||||||
|
.lobbypage { max-width: 1500px; padding-bottom: 8rem; }
|
||||||
|
.lobbygrid { display: grid; grid-template-columns: minmax(0, 1fr) 400px; gap: 1.4rem; align-items: start; }
|
||||||
|
.lobbychat { position: sticky; top: 4.6rem; height: calc(100vh - 11rem); min-height: 22rem; padding: .7rem; display: flex; flex-direction: column; }
|
||||||
|
.lobbychat .chatwrap { flex: 1; min-height: 0; grid-template-rows: minmax(0, 1fr) auto auto auto auto; }
|
||||||
|
.lobbychat .chatbox { height: auto; min-height: 0; }
|
||||||
|
.lobbychat .chatform { flex-wrap: wrap; }
|
||||||
|
.lobbychat .chatform select { width: 100%; flex-basis: 100%; }
|
||||||
|
.lobbychat .chatform input { flex: 1 1 8rem; }
|
||||||
|
.partygrid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
|
||||||
|
.msg.rollmsg .rollres b { font-size: 1.05rem; color: var(--noxium); }
|
||||||
|
.whispertag { color: #8a63c7; }
|
||||||
|
@media (max-width: 1050px) { .lobbygrid { grid-template-columns: 1fr; } .lobbychat { position: static; height: 28rem; } }
|
||||||
|
|
||||||
|
.dicebar { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%); z-index: 30; display: flex; align-items: center; gap: .35rem; flex-wrap: wrap; justify-content: center;
|
||||||
|
max-width: calc(100vw - 1.5rem); background: var(--panel); border: 1px solid var(--line); border-radius: 999px; padding: .4rem .8rem; box-shadow: 0 6px 24px rgba(0, 0, 0, .22); }
|
||||||
|
.dicecount { display: flex; align-items: center; gap: .3rem; margin-right: .3rem; }
|
||||||
|
.dicecount select { width: auto; padding: .25rem .4rem; }
|
||||||
|
.diebtn { display: flex; flex-direction: column; align-items: center; gap: 0; padding: .2rem .5rem; border-radius: 12px; min-width: 3.1rem; font-size: .75rem; line-height: 1.1; }
|
||||||
|
.diebtn:hover:not(:disabled) { border-color: var(--noxium); color: var(--noxium); }
|
||||||
|
.dicedest { margin-left: .4rem; padding-left: .6rem; border-left: 1px solid var(--line); }
|
||||||
|
.dicedest.priv { color: #8a63c7; font-weight: 600; }
|
||||||
|
.dicetray { position: fixed; bottom: 5.4rem; left: 50%; transform: translateX(-50%); z-index: 29; display: grid; gap: .5rem; justify-items: center; pointer-events: none; }
|
||||||
|
.trayroll { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: .5rem .9rem; box-shadow: 0 6px 24px rgba(0, 0, 0, .25); animation: trayin .25s ease-out; }
|
||||||
|
.trayrow { display: flex; align-items: center; gap: .5rem; margin-top: .3rem; flex-wrap: wrap; justify-content: center; max-width: 30rem; }
|
||||||
|
.traytotal { font-weight: 700; font-size: 1.2rem; margin-left: .3rem; }
|
||||||
|
.die { display: inline-flex; align-items: center; justify-content: center; width: 2.9rem; height: 2.9rem; background: var(--noxium); color: #12150f; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||||
|
.die.d4 { clip-path: polygon(50% 4%, 96% 92%, 4% 92%); padding-top: .55rem; }
|
||||||
|
.die.d6 { border-radius: 12px; }
|
||||||
|
.die.d8 { clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); }
|
||||||
|
.die.d10 { clip-path: polygon(50% 0, 98% 40%, 50% 100%, 2% 40%); }
|
||||||
|
.die.d12 { clip-path: polygon(50% 0, 98% 38%, 80% 96%, 20% 96%, 2% 38%); }
|
||||||
|
.die.d20 { clip-path: polygon(25% 4%, 75% 4%, 100% 50%, 75% 96%, 25% 96%, 0 50%); }
|
||||||
|
.die.d100 { border-radius: 50%; }
|
||||||
|
.die.rolling { animation: tumble .32s linear infinite; filter: brightness(.9); }
|
||||||
|
.die.landed { animation: land .25s ease-out; }
|
||||||
|
@keyframes tumble { 0% { transform: rotate(0) translateY(0); } 25% { transform: rotate(90deg) translateY(-8px); } 50% { transform: rotate(180deg) translateY(0); } 75% { transform: rotate(270deg) translateY(-5px); } 100% { transform: rotate(360deg) translateY(0); } }
|
||||||
|
@keyframes land { 0% { transform: scale(1.35); } 100% { transform: scale(1); } }
|
||||||
|
@keyframes trayin { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .die.rolling, .die.landed, .trayroll { animation: none; } }
|
||||||
|
|||||||
Reference in New Issue
Block a user