Add lobbies: DM sees all characters, players only a short card of others

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-21 16:23:36 +00:00
parent e8584e043d
commit aad8e0515b
6 changed files with 440 additions and 5 deletions
+5 -1
View File
@@ -3,6 +3,7 @@ import Creator from './Creator.jsx'
import Rules from './Rules.jsx'
import Characters from './Characters.jsx'
import Presets from './Presets.jsx'
import Lobbies from './Lobbies.jsx'
import Login from './Login.jsx'
import Account from './Account.jsx'
import { claimLegacy } from './legacy.js'
@@ -52,7 +53,7 @@ export default function App() {
if (!auth.user) return <main><Login registrationOpen={auth.registration_open} onDone={(u) => setAuth({ ...auth, user: u })} /></main>
const page = hash.split('/')[0]
const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters']]
const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters'], ['lobbies', 'Lobbies']]
return (
<>
@@ -77,6 +78,9 @@ export default function App() {
<Characters onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }}
onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} />
)}
{rules && page === 'lobbies' && (
<Lobbies section={hash.split('/')[1]} onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} />
)}
{page === 'account' && <Account user={username} onLogout={logout} />}
</main>
</>
+220
View File
@@ -0,0 +1,220 @@
import { useEffect, useState } from 'react'
const J = { 'Content-Type': 'application/json' }
const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) })
const msg = (d, fallback) => (typeof d?.detail === 'string' ? d.detail : fallback)
function useMine() {
const [mine, setMine] = useState([])
useEffect(() => { fetch('/api/characters').then((r) => r.json()).then((d) => setMine(d.mine || [])).catch(() => {}) }, [])
return mine
}
function CharSelect({ mine, value, onChange, label }) {
return (
<select value={value} onChange={(e) => onChange(e.target.value)} aria-label={label}>
<option value="">Choose a character …</option>
{mine.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
)
}
// ---- list of lobbies: create one (you become the DM) or join one with a code
function LobbyList() {
const mine = useMine()
const [data, setData] = useState(null)
const [name, setName] = useState('')
const [code, setCode] = useState('')
const [cid, setCid] = useState('')
const [err, setErr] = useState('')
const load = () => fetch('/api/lobbies').then((r) => r.json()).then(setData).catch(() => setErr('Could not load your lobbies.'))
useEffect(() => { load() }, [])
const create = async (e) => {
e.preventDefault(); setErr('')
const r = await send('/api/lobbies', 'POST', { name })
const d = await r.json().catch(() => ({}))
if (!r.ok) return setErr(msg(d, 'Could not create the lobby.'))
window.location.hash = `lobbies/${d.id}`
}
const join = async (e) => {
e.preventDefault(); setErr('')
if (!cid) return setErr('Choose which of your characters joins.')
const r = await send('/api/lobbies/join', 'POST', { code, character_id: cid })
const d = await r.json().catch(() => ({}))
if (!r.ok) return setErr(msg(d, 'Could not join.'))
window.location.hash = `lobbies/${d.id}`
}
return (
<section className="page narrow">
<h1>Lobbies</h1>
<p className="muted">
A lobby is a group for one campaign. Whoever creates it is the DM and can look at every character in it.
Players only see a short card of each other (name, concept, class and profession).
</p>
{err && <p className="warn">{err}</p>}
<div className="card">
<h2>Join a lobby</h2>
<form className="row wrapform" onSubmit={join}>
<input value={code} onChange={(e) => setCode(e.target.value)} placeholder="Join code from your DM" aria-label="Join code" autoComplete="off" />
<CharSelect mine={mine} value={cid} onChange={setCid} label="Character that joins" />
<button type="submit" disabled={!code.trim()}>Join</button>
</form>
{mine.length === 0 && <p className="small muted">You need a saved character first (Character → Summary → Save).</p>}
</div>
<h2>Lobbies you joined</h2>
{data && data.joined.length === 0 && <p className="muted small">None yet.</p>}
<ul className="charlist">
{(data?.joined || []).map((l) => (
<li key={l.id} className="card row">
<div>
<strong>{l.name}</strong>
<div className="muted small">DM {l.dm}{l.character ? ` · playing ${l.character}` : ' · no character selected'}</div>
</div>
<div className="actions"><a className="button" href={`#lobbies/${l.id}`}>Open</a></div>
</li>
))}
</ul>
<h2>Lobbies you run (as DM)</h2>
{data && data.mine.length === 0 && <p className="muted small">None yet.</p>}
<ul className="charlist">
{(data?.mine || []).map((l) => (
<li key={l.id} className="card row">
<div>
<strong>{l.name}</strong>
<div className="muted small">{l.members} player{l.members === 1 ? '' : 's'}</div>
</div>
<div className="actions"><a className="button" href={`#lobbies/${l.id}`}>Open</a></div>
</li>
))}
</ul>
<form className="row wrapform" onSubmit={create}>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name of a new lobby" aria-label="Lobby name" maxLength={60} />
<button type="submit" disabled={!name.trim()}>+ Create lobby</button>
</form>
</section>
)
}
function Card({ m, isDm, onOpen, onKick }) {
const c = m.character
return (
<li className={`card lobbycard ${m.me ? 'me' : ''}`}>
<div className="row">
<div>
<strong>{c ? c.name : 'No character'}</strong>
<div className="muted small">{m.username}{m.me ? ' (you)' : ''}</div>
</div>
<div className="actions">
{m.character_id && <button onClick={() => onOpen(m)}>{m.me ? 'Open' : 'View'}</button>}
{isDm && <button className="ghost danger" onClick={() => onKick(m)}>Remove</button>}
</div>
</div>
{c && (
<div className="chips">
{[c.class && (c.subclass ? `${c.class} · ${c.subclass}` : c.class), c.profession].filter(Boolean).map((t) => <span key={t} className="chip">{t}</span>)}
</div>
)}
{c?.concept && <p className="small muted">{c.concept}</p>}
</li>
)
}
// ---- one lobby (DM view or player view)
function LobbyView({ id, onOpen }) {
const mine = useMine()
const [lobby, setLobby] = useState(null)
const [err, setErr] = useState('')
const [info, setInfo] = useState('')
const [pick, setPick] = useState('')
const [rename, setRename] = useState('')
const load = () => fetch(`/api/lobbies/${id}`).then(async (r) => {
if (!r.ok) throw new Error(r.status)
setLobby(await r.json())
}).catch((e) => { if (String(e.message) === '404') setErr('This lobby does not exist or you are not part of it.') })
useEffect(() => {
load()
const t = setInterval(load, 15000) // pick up joins and character changes of the others
return () => clearInterval(t)
}, [id]) // eslint-disable-line
const act = async (url, method, body, done) => {
setErr(''); setInfo('')
const r = await send(url, method, body).catch(() => null)
const d = r ? await r.json().catch(() => ({})) : {}
if (!r || !r.ok) return setErr(msg(d, 'That did not work.'))
done ? done(d) : load()
}
if (err && !lobby) return <section className="page narrow"><p className="warn">{err}</p><a href="#lobbies">Back to all lobbies</a></section>
if (!lobby) return <section className="page narrow"><p className="muted">Loading …</p></section>
const me = lobby.members.find((m) => m.me)
const open = (m) => onOpen({ id: m.character_id, name: m.character?.name, ...(m.me ? {} : { owner: m.username }) })
return (
<section className="page narrow">
<p className="small"><a href="#lobbies">← All lobbies</a></p>
<h1>{lobby.name}</h1>
<p className="muted">DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''}</p>
{err && <p className="warn">{err}</p>}
{info && <p className="small">{info}</p>}
{lobby.is_dm && (
<div className="card">
<div className="row">
<div>
<div className="muted small">Join code – give it to your players</div>
<strong className="joincode">{lobby.code}</strong>
</div>
<div className="actions">
<button className="ghost" onClick={() => navigator.clipboard?.writeText(lobby.code).then(() => setInfo('Code copied.')).catch(() => {})}>Copy</button>
<button className="ghost" onClick={() => window.confirm('The old code stops working. Current players stay in the lobby.') && act(`/api/lobbies/${id}/code`, 'POST', undefined, (d) => setLobby({ ...lobby, code: d.code }))}>New code</button>
</div>
</div>
<form className="row wrapform" onSubmit={(e) => { e.preventDefault(); if (rename.trim()) act(`/api/lobbies/${id}`, 'PUT', { name: rename }, () => { setRename(''); load() }) }}>
<input value={rename} onChange={(e) => setRename(e.target.value)} placeholder="Rename lobby" aria-label="New lobby name" maxLength={60} />
<button type="submit" className="ghost" disabled={!rename.trim()}>Rename</button>
</form>
</div>
)}
<h2>Characters ({lobby.members.length})</h2>
{lobby.members.length === 0 && <p className="card">Nobody has joined yet. Share the code above.</p>}
<ul className="charlist">
{lobby.members.map((m) => (
<Card key={m.username} m={m} isDm={lobby.is_dm} onOpen={open}
onKick={(x) => window.confirm(`Remove ${x.username} from the lobby?`) && act(`/api/lobbies/${id}/members/${encodeURIComponent(x.username)}`, 'DELETE')} />
))}
</ul>
{lobby.is_dm && <p className="small muted">As DM you can open every character here, but only read it. "Save" in the Character tab would create your own copy.</p>}
{!lobby.is_dm && me && (
<div className="card">
<h2>Your character</h2>
<form className="row wrapform" onSubmit={(e) => { e.preventDefault(); if (pick) act(`/api/lobbies/${id}/me`, 'PUT', { character_id: pick }, () => { setPick(''); load() }) }}>
<CharSelect mine={mine} value={pick} onChange={setPick} label="Switch character" />
<button type="submit" className="ghost" disabled={!pick}>Switch</button>
</form>
<p className="small muted">The DM always sees the current version of your character, so changes you save show up for them.</p>
</div>
)}
<div className="actions wrap">
{lobby.is_dm
? <button className="ghost danger" onClick={() => window.confirm(`Delete the lobby "${lobby.name}" for everyone? Characters are not deleted.`) && act(`/api/lobbies/${id}`, 'DELETE', undefined, () => { window.location.hash = 'lobbies' })}>Delete lobby</button>
: <button className="ghost danger" onClick={() => window.confirm('Leave this lobby?') && act(`/api/lobbies/${id}/me`, 'DELETE', undefined, () => { window.location.hash = 'lobbies' })}>Leave lobby</button>}
</div>
</section>
)
}
export default function Lobbies({ section, onOpen }) {
return section ? <LobbyView key={section} id={section} onOpen={onOpen} /> : <LobbyList />
}
+13
View File
@@ -169,3 +169,16 @@ tr.dim td { opacity: .55; }
.tipwrap:hover .tipicon, .tipwrap:focus .tipicon { color: var(--noxium); border-color: var(--noxium); }
.tippop { position: fixed; z-index: 1000; max-width: 340px; padding: .55rem .75rem; background: var(--panel2); color: var(--text); border: 1px solid var(--diesium); border-radius: var(--radius); box-shadow: 0 6px 20px rgba(0,0,0,.35); font-size: .85rem; line-height: 1.4; pointer-events: none; }
.tippop div + div { margin-top: .35rem; }
/* lobbies */
a.button { display: inline-block; font-weight: 600; text-decoration: none; background: var(--noxium); color: #12150f; border: 1px solid var(--noxium); padding: .5rem 1rem; border-radius: var(--radius); }
a.button:hover { filter: brightness(1.1); }
.wrapform { flex-wrap: wrap; gap: .5rem; margin-top: .6rem; }
.wrapform > input, .wrapform > select { flex: 1 1 12rem; width: auto; }
.lobbycard { display: grid; gap: .45rem; }
.lobbycard.me { border-color: var(--noxium); }
.lobbycard p { margin: 0; }
.joincode { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 1.5rem; letter-spacing: .15em; }
span.chip { border: 1px solid var(--line); font-size: .85rem; padding: .1rem .6rem; }
.lobbycard .chips { gap: .4rem; }
h2 + .charlist, .card + h2 { margin-top: 1.4rem; }