Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
604 lines
31 KiB
React
604 lines
31 KiB
React
import { useCallback, useEffect, useRef, useState } from 'react'
|
||
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
|
||
|
||
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>
|
||
)
|
||
}
|
||
|
||
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
|
||
const sgn = (n) => (n > 0 ? `+${n}` : n < 0 ? `−${-n}` : '±0')
|
||
const store = (k, v) => { try { if (v === undefined) return localStorage.getItem(k); localStorage.setItem(k, v) } catch { /* storage may be blocked */ } return null }
|
||
const OUTCOME = { crit_success: 'Critical success!', success: 'Success', failure: 'Failure', crit_failure: 'Critical failure!' }
|
||
|
||
// Chat, whispers, event log, dice rolls and checks arrive through one feed that is polled while the page is open.
|
||
function useFeed(id, onEvent, onRoll) {
|
||
const [msgs, setMsgs] = useState([])
|
||
const last = useRef(0)
|
||
const live = useRef(false) // false until the history is loaded: old rolls are not animated again
|
||
const cb = useRef({})
|
||
cb.current = { onEvent, onRoll }
|
||
const poll = useCallback(async () => {
|
||
if (document.hidden) return
|
||
const r = await fetch(`/api/lobbies/${id}/messages?after=${last.current}`).catch(() => null)
|
||
if (!r || !r.ok) return
|
||
const fresh = await r.json()
|
||
const wasLive = live.current
|
||
live.current = true
|
||
if (!fresh.length) return
|
||
last.current = fresh[fresh.length - 1].id
|
||
setMsgs((old) => [...old, ...fresh].slice(-300))
|
||
if (fresh.some((m) => m.kind === 'event')) cb.current.onEvent()
|
||
if (wasLive) fresh.filter((m) => m.kind === 'roll' || m.kind === 'check').forEach((m) => cb.current.onRoll(m))
|
||
}, [id])
|
||
useEffect(() => {
|
||
poll()
|
||
const t = setInterval(poll, 2500)
|
||
return () => clearInterval(t)
|
||
}, [poll])
|
||
return { msgs, poll }
|
||
}
|
||
|
||
const rollText = (m) => {
|
||
const res = m.roll.results, n = m.roll.n
|
||
return { dice: `${n > 1 ? n : ''}d${m.roll.sides}`, list: res && res.join(' + '), sum: res && res.reduce((a, b) => a + b, 0), n }
|
||
}
|
||
|
||
// the text of a skill / attribute check; players do not see the numbers when the DM hides the difficulty
|
||
function checkLine(c) {
|
||
const out = OUTCOME[c.outcome]
|
||
if (c.target === undefined) return <>rolled <strong>{c.roll}</strong> → <span className={`out ${c.outcome}`}>{out}</span></>
|
||
const parts = `${c.base}${c.mod ? ` ${sgn(c.mod)} ${c.diff}` : ''}${c.cmod ? ` ${sgn(c.cmod)} bonus` : ''}`
|
||
return <>rolled <strong>{c.roll}</strong> vs {c.target}{parts !== String(c.target) && <span className="muted small"> ({parts})</span>} →{' '}
|
||
<span className={`out ${c.outcome}`}>{out}</span> <span className="muted small">{c.margin >= 0 ? `by ${c.margin}` : `by ${-c.margin}`}</span></>
|
||
}
|
||
|
||
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
|
||
const el = box.current
|
||
if (el && stick.current) el.scrollTop = el.scrollHeight
|
||
}, [msgs, events])
|
||
|
||
const submit = async (e) => {
|
||
e.preventDefault(); setErr('')
|
||
if (!text.trim()) return
|
||
const r = await send(`/api/lobbies/${id}/messages`, 'POST', { text, to })
|
||
if (!r.ok) return setErr(msg(await r.json().catch(() => ({})), 'Could not send.'))
|
||
setText(''); stick.current = true; poll()
|
||
}
|
||
const shown = msgs.filter((m) => events || m.kind !== 'event')
|
||
const who = (m) => (
|
||
<>
|
||
<strong style={m.color ? { color: m.color } : undefined}>{m.mine ? 'You' : m.from || '?'}</strong>
|
||
{m.to && <em className="small whispertag"> {m.to === m.from ? '(only visible to you, the DM)' : m.mine ? `to ${m.to} (private)` : 'to you (private)'}</em>}
|
||
</>
|
||
)
|
||
|
||
return (
|
||
<div className="chatwrap">
|
||
<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">
|
||
{shown.length === 0 && <p className="muted small">Nothing here yet.</p>}
|
||
{shown.map((m) => {
|
||
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 === 'check') {
|
||
return (
|
||
<div key={m.id} className={`msg rollmsg ${m.mine ? 'mine' : ''}`}>
|
||
<span className="time">{clock(m.time)}</span> {who(m)} – <b>{m.check.what}</b>: {checkLine(m.check)}{m.check.manual && <span className="manualtag small"> own dice</span>}
|
||
</div>
|
||
)
|
||
}
|
||
if (m.kind === 'roll') {
|
||
const t = rollText(m)
|
||
return (
|
||
<div key={m.id} className={`msg rollmsg ${m.to ? 'whisper' : ''} ${m.mine ? 'mine' : ''}`}>
|
||
<span className="time">{clock(m.time)}</span> {who(m)} rolled <strong>{t.dice}</strong>:{' '}
|
||
{t.list === null ? <span className="rollres mystery">{Array(t.n).fill('?').join(' ')}</span>
|
||
: <span className="rollres">{t.n > 1 ? `${t.list} = ` : ''}<b>{t.sum}</b></span>}
|
||
{m.roll.manual && <span className="manualtag small"> own dice</span>}
|
||
</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>
|
||
<form className="row chatform" onSubmit={submit}>
|
||
<select value={to} onChange={(e) => setTo(e.target.value)} aria-label="Send to" className={to ? 'whisper' : ''}>
|
||
<option value="">Everyone</option>
|
||
{people.filter((p) => p !== me).map((p) => <option key={p} value={p}>Only {p} (whisper)</option>)}
|
||
</select>
|
||
<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>
|
||
</form>
|
||
{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>
|
||
<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 below the table, results are thrown into the middle of the table and 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>
|
||
)
|
||
}
|
||
|
||
const MODES = [['mystery', 'Mystery', 'Players see grey dice, but not the result'], ['hidden', 'Hidden', 'Only you see the roll'], ['public', 'Public', 'Everybody sees the roll']]
|
||
|
||
function DiceBar({ to, isDm, count, setCount, mode, setMode, own, setOwn, onRoll, busy }) {
|
||
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={() => onRoll(d)} disabled={busy} title={`Roll ${count > 1 ? count : ''}d${d}`} aria-label={`Roll ${count} d${d}`}>
|
||
<DieIcon sides={d} /><span>d{d}</span>
|
||
</button>
|
||
))}
|
||
<label className="small ownswitch" title="You rolled real dice: type in the result instead of rolling here">
|
||
<input type="checkbox" checked={own} onChange={(e) => setOwn(e.target.checked)} style={{ width: 'auto' }} /> Own dice
|
||
</label>
|
||
{isDm && !to && (
|
||
<span className="seg small" role="group" aria-label="Who sees the DM's rolls">
|
||
{MODES.map(([k, label, tip]) => <button key={k} className={mode === k ? 'on' : 'ghost'} title={tip} onClick={() => setMode(k)}>{label}</button>)}
|
||
</span>
|
||
)}
|
||
{to && <span className="small dicedest priv">Private: {to}</span>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// shown instead of rolling when "Own dice" is on: the player types what the real dice showed
|
||
function ManualEntry({ pending, onSubmit, onCancel, err }) {
|
||
const n = pending.type === 'check' ? 1 : pending.count
|
||
const sides = pending.type === 'check' ? 100 : pending.sides
|
||
const [vals, setVals] = useState(Array(n).fill(''))
|
||
const ok = vals.every((v) => /^\d+$/.test(v) && +v >= 1 && +v <= sides)
|
||
return (
|
||
<form className="manual card" onSubmit={(e) => { e.preventDefault(); if (ok) onSubmit(vals.map(Number)) }}>
|
||
<div className="small">{pending.type === 'check' ? <>Your real d100 for <b>{pending.name}</b></> : <>Your real result{n > 1 ? 's' : ''} for <b>{n > 1 ? n : ''}d{sides}</b></>} (1–{sides})</div>
|
||
<div className="row">
|
||
{vals.map((v, i) => (
|
||
<input key={i} autoFocus={i === 0} inputMode="numeric" value={v} aria-label={`Result ${i + 1}`} style={{ width: '4.2rem' }}
|
||
onChange={(e) => setVals(vals.map((x, j) => (j === i ? e.target.value.replace(/\D/g, '').slice(0, 3) : x)))} />
|
||
))}
|
||
<button type="submit" disabled={!ok}>Send</button>
|
||
<button type="button" className="ghost" onClick={onCancel}>Cancel</button>
|
||
</div>
|
||
{err && <span className="warn small">{err}</span>}
|
||
</form>
|
||
)
|
||
}
|
||
|
||
function Die({ sides, value, delay, mystery }) {
|
||
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'}${mystery ? ' mystery' : ''}`} style={{ animationDelay: `${delay % 200}ms` }}>
|
||
<span>{mystery && !rolling ? '?' : shown ?? '?'}</span>
|
||
</span>
|
||
)
|
||
}
|
||
|
||
// the tray is the middle of the table: dice in the colour of whoever threw them
|
||
function DiceTray({ rolls }) {
|
||
if (!rolls.length) return null
|
||
return (
|
||
<div className="dicetray" aria-hidden="true">
|
||
{rolls.map((m) => {
|
||
const style = { '--dc': m.color || 'var(--noxium)' }
|
||
if (m.kind === 'check') {
|
||
const c = m.check
|
||
return (
|
||
<div key={m.key} className="trayroll" style={style}>
|
||
<div className="small"><b style={{ color: m.color }}>{m.mine ? 'You' : m.from}</b> – {c.what}{c.manual ? ' · own dice' : ''}</div>
|
||
<div className="trayrow"><Die sides={100} value={c.roll} delay={0} />
|
||
<span className="trayout"><span className={`out ${c.outcome}`}>{OUTCOME[c.outcome]}</span>
|
||
{c.target !== undefined && <small>{c.roll} vs {c.target}</small>}</span></div>
|
||
</div>
|
||
)
|
||
}
|
||
const t = rollText(m)
|
||
const sec = m.roll.mystery && !m.mine
|
||
return (
|
||
<div key={m.key} className="trayroll" style={sec ? { '--dc': '#8b8f85' } : style}>
|
||
<div className="small"><b style={{ color: m.color }}>{m.mine ? 'You' : m.from}</b> rolled <strong>{t.dice}</strong>
|
||
{m.to ? (m.to === m.from ? ' · only you' : ` · private${m.mine ? ` to ${m.to}` : ''}`) : ''}{m.roll.manual ? ' · own dice' : ''}</div>
|
||
<div className="trayrow">
|
||
{Array.from({ length: t.n }, (_, i) => <Die key={i} sides={m.roll.sides} value={m.roll.results ? m.roll.results[i] : 0} delay={i * 120} mystery={!m.roll.results} />)}
|
||
{t.n > 1 && t.sum !== null && <span className="traytotal">= {t.sum}</span>}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- character bar: one card per player, later also for initiative and enemies
|
||
function CharBar({ lobby, sel, onSel }) {
|
||
return (
|
||
<div className="charbar" role="list">
|
||
{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)' }}
|
||
onClick={() => can && onSel(m.username)} tabIndex={can ? 0 : undefined} onKeyDown={(e) => can && e.key === 'Enter' && onSel(m.username)}>
|
||
<span className="pavatar">{(c?.name || m.username).slice(0, 1).toUpperCase()}</span>
|
||
<span className="pinfo">
|
||
<b>{c ? c.name : 'No character'}</b>
|
||
<span className="muted small">{m.username}{m.me ? ' (you)' : ''}{c?.class ? ` · ${c.class}` : ''}</span>
|
||
</span>
|
||
{lobby.is_dm
|
||
? <input type="number" className="modin" key={`${m.username}:${m.mod}`} defaultValue={m.mod || 0} min={-100} max={100} title={`Bonus / malus for ${m.username}`} aria-label={`Modifier for ${m.username}`}
|
||
onClick={(e) => e.stopPropagation()} onBlur={(e) => lobby.setMod(m.username, e.target.value)} onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} />
|
||
: m.mod ? <span className={`modchip ${m.mod > 0 ? 'up' : 'down'}`} title="Your personal bonus / malus">{sgn(m.mod)}</span> : null}
|
||
</div>
|
||
)
|
||
})}
|
||
{lobby.members.length === 0 && <span className="muted small">Nobody has joined yet – share the join code (⚙ Lobby).</span>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- the DM sets the difficulty for all checks, from the rules table or a custom number
|
||
function DifficultyBar({ lobby, onSet }) {
|
||
const d = lobby.difficulty
|
||
const idx = lobby.levels.findIndex((l) => l.name === d.label && l.mod === d.mod)
|
||
const [custom, setCustom] = useState('')
|
||
if (!lobby.is_dm) {
|
||
return <div className="diffbar small muted">{d.hidden ? 'The DM sets the difficulty' : <>Difficulty: <b>{d.label}</b> {sgn(d.mod)}</>}</div>
|
||
}
|
||
return (
|
||
<div className="diffbar">
|
||
<label className="small">Difficulty
|
||
<select value={idx >= 0 ? idx : 'custom'} aria-label="Difficulty"
|
||
onChange={(e) => { const l = lobby.levels[e.target.value]; if (l) onSet({ label: l.name, mod: l.mod, hidden: d.hidden }) }}>
|
||
{lobby.levels.map((l, i) => <option key={l.name} value={i}>{l.name} ({sgn(l.mod)})</option>)}
|
||
{idx < 0 && <option value="custom">{d.label} ({sgn(d.mod)})</option>}
|
||
</select>
|
||
</label>
|
||
<form className="row" onSubmit={(e) => { e.preventDefault(); if (/^[-+−]?\d+$/.test(custom.trim())) { onSet({ label: 'Custom', mod: parseInt(custom.replace('−', '-'), 10), hidden: d.hidden }); setCustom('') } }}>
|
||
<input value={custom} onChange={(e) => setCustom(e.target.value)} placeholder="custom ±N" aria-label="Custom modifier" style={{ width: '6rem' }} />
|
||
<button type="submit" className="ghost" disabled={!custom.trim()}>Set</button>
|
||
</form>
|
||
<label className="small"><input type="checkbox" checked={d.hidden} onChange={(e) => onSet({ label: d.label, mod: d.mod, hidden: e.target.checked })} style={{ width: 'auto' }} /> Hide numbers from players</label>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- sheet of one character, refreshed regularly so the DM sees changes the player saves
|
||
function CharTab({ cid, rules, onRoll }) {
|
||
const [data, setData] = useState(null)
|
||
const [err, setErr] = useState('')
|
||
useEffect(() => {
|
||
setData(null); setErr('')
|
||
const load = () => fetch(`/api/characters/${cid}`).then((r) => { if (!r.ok) throw new Error(); return r.json() }).then(setData).catch(() => setErr('Could not load this character.'))
|
||
load()
|
||
const t = setInterval(() => !document.hidden && load(), 15000)
|
||
return () => clearInterval(t)
|
||
}, [cid])
|
||
return (
|
||
<div>
|
||
{err && <p className="warn">{err}</p>}
|
||
{data && <PaperSheet data={data} baseRules={rules} bare onRoll={onRoll} />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// lobby settings: join code, rename, colour, character switch, members, leave / delete
|
||
function Settings({ lobby, id, mine, act, setInfo, onClose }) {
|
||
const [rename, setRename] = useState('')
|
||
const [pick, setPick] = useState('')
|
||
const me = lobby.members.find((m) => m.me)
|
||
const taken = new Set(lobby.members.filter((m) => !m.me).map((m) => m.color))
|
||
return (
|
||
<div className="settings card">
|
||
<div className="row"><h2>Lobby</h2><button className="ghost" onClick={onClose}>Close</button></div>
|
||
{lobby.is_dm && (
|
||
<>
|
||
<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')}>New code</button>
|
||
</div>
|
||
</div>
|
||
<form className="row wrapform" onSubmit={(e) => { e.preventDefault(); if (rename.trim()) act(`/api/lobbies/${id}`, 'PUT', { name: rename }, () => { setRename(''); act() }) }}>
|
||
<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>
|
||
<h3>Players</h3>
|
||
{lobby.members.length === 0 && <p className="muted small">Nobody yet.</p>}
|
||
{lobby.members.map((m) => (
|
||
<div key={m.username} className="row">
|
||
<span><span className="swatch" style={{ background: m.color }} /> {m.username} <span className="muted small">{m.character?.name}</span></span>
|
||
<button className="ghost danger" onClick={() => window.confirm(`Remove ${m.username} from the lobby?`) && act(`/api/lobbies/${id}/members/${encodeURIComponent(m.username)}`, 'DELETE')}>Remove</button>
|
||
</div>
|
||
))}
|
||
</>
|
||
)}
|
||
{!lobby.is_dm && me && (
|
||
<>
|
||
<h3>Your colour</h3>
|
||
<div className="swatches">
|
||
{lobby.palette.map((p) => (
|
||
<button key={p.id} className={`swatchbtn${me.color === p.id ? ' on' : ''}`} style={{ background: p.hex }} disabled={taken.has(p.id)} title={taken.has(p.id) ? `${p.id} (taken)` : p.id}
|
||
aria-label={p.id} onClick={() => act(`/api/lobbies/${id}/me`, 'PUT', { color: p.id })} />
|
||
))}
|
||
</div>
|
||
<h3>Your character</h3>
|
||
<form className="row wrapform" onSubmit={(e) => { e.preventDefault(); if (pick) act(`/api/lobbies/${id}/me`, 'PUT', { character_id: pick }, () => { setPick(''); act() }) }}>
|
||
<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 saved version of your character. Of the others you only see a short card.</p>
|
||
</>
|
||
)}
|
||
<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>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- one lobby: sheet on the left, player area (character bar, difficulty, dice table) in the middle, chat on the right
|
||
function LobbyView({ id, rules, user }) {
|
||
const mine = useMine()
|
||
const [lobby, setLobby] = useState(null)
|
||
const [err, setErr] = useState('')
|
||
const [info, setInfo] = useState('')
|
||
const [sel, setSel] = useState('')
|
||
const [to, setTo] = useState('') // whisper recipient, used for chat messages and dice rolls
|
||
const [tray, setTray] = useState([])
|
||
const [count, setCount] = useState(1)
|
||
const [mode, setMode] = useState('mystery')
|
||
const [own, setOwnState] = useState(() => store('aw_owndice') === '1')
|
||
const [pending, setPending] = useState(null)
|
||
const [busy, setBusy] = useState(false)
|
||
const [mErr, setMErr] = useState('')
|
||
const [showSettings, setShowSettings] = useState(false)
|
||
const [sheetOpen, setSheetOpen] = useState(() => store('aw_sheetopen') !== '0')
|
||
const setOwn = (v) => { setOwnState(v); store('aw_owndice', v ? '1' : '0') }
|
||
|
||
const load = useCallback(() => 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.') }), [id])
|
||
useEffect(() => {
|
||
load()
|
||
const t = setInterval(() => !document.hidden && load(), 5000) // picks up joins, colours, difficulty and character changes
|
||
return () => clearInterval(t)
|
||
}, [load])
|
||
|
||
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) => {
|
||
if (!url) return load()
|
||
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 lobbypage"><p className="warn">{err}</p><a href="#lobbies">Back to all lobbies</a></section>
|
||
if (!lobby) return <section className="page lobbypage"><p className="muted">Loading …</p></section>
|
||
|
||
const me = lobby.members.find((m) => m.me)
|
||
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 cur = sheets.find((m) => m.username === sel) || sheets.find((m) => m.me) || sheets[0]
|
||
const dest = people.includes(to) ? to : ''
|
||
const canCheck = !!(me && me.character_id)
|
||
lobby.setMod = (u, v) => act(`/api/lobbies/${id}/members/${encodeURIComponent(u)}/mod`, 'PUT', { mod: parseInt(v, 10) || 0 })
|
||
|
||
const post = async (url, body, after) => {
|
||
setBusy(true); setMErr('')
|
||
const r = await send(url, 'POST', body).catch(() => null)
|
||
if (!r || !r.ok) setMErr(msg(r ? await r.json().catch(() => ({})) : {}, 'That did not work.'))
|
||
else { after?.(); feed.poll() }
|
||
setTimeout(() => setBusy(false), 350)
|
||
}
|
||
const throwDice = (sides) => {
|
||
if (busy) return
|
||
if (own) return setPending({ type: 'dice', sides, count })
|
||
post(`/api/lobbies/${id}/rolls`, { sides, count, to: dest, ...(lobby.is_dm ? { mode } : {}) })
|
||
}
|
||
const check = (kind, cid, name) => {
|
||
if (busy) return
|
||
if (own) return setPending({ type: 'check', kind, id: cid, name })
|
||
post(`/api/lobbies/${id}/checks`, { kind, id: cid })
|
||
}
|
||
const submitManual = (vals) => {
|
||
if (pending.type === 'check') post(`/api/lobbies/${id}/checks`, { kind: pending.kind, id: pending.id, manual: vals[0] }, () => setPending(null))
|
||
else post(`/api/lobbies/${id}/rolls`, { sides: pending.sides, count: pending.count, to: dest, manual: vals, ...(lobby.is_dm ? { mode } : {}) }, () => setPending(null))
|
||
}
|
||
|
||
return (
|
||
<section className="page lobbypage">
|
||
<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="tabright">
|
||
<button className="ghost" onClick={() => setShowSettings((v) => !v)} aria-expanded={showSettings}>⚙ Lobby</button>
|
||
</span>
|
||
</div>
|
||
{err && <p className="warn">{err}</p>}
|
||
{info && <p className="small">{info}</p>}
|
||
|
||
<div className={`lobbygrid${sheetOpen ? '' : ' nosheet'}`}>
|
||
{sheetOpen && (
|
||
<div className="sheetcol">
|
||
<div className="sheetbar">
|
||
<b>{cur ? cur.character.name : 'Character sheet'}</b>
|
||
<span className="tabright"><ThemeSwitch /> <button className="ghost" onClick={() => { setSheetOpen(false); store('aw_sheetopen', '0') }} title="Hide the sheet">◀</button></span>
|
||
</div>
|
||
{cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} onRoll={cur.me && canCheck ? check : undefined} />
|
||
: <p className="muted small">{lobby.is_dm ? 'Pick a character in the bar to look at its sheet.' : 'You have no character in this lobby.'}</p>}
|
||
</div>
|
||
)}
|
||
|
||
<div className="playarea">
|
||
{!sheetOpen && <button className="ghost showsheet" onClick={() => { setSheetOpen(true); store('aw_sheetopen', '1') }}>▶ Sheet</button>}
|
||
<CharBar lobby={lobby} sel={cur?.username} onSel={setSel} />
|
||
<DifficultyBar lobby={lobby} onSet={(b) => act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} />
|
||
<div className="table">
|
||
{showSettings && <Settings lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} onClose={() => setShowSettings(false)} />}
|
||
<DiceTray rolls={tray} />
|
||
{canCheck && !pending && <p className="tablehint small muted">Click an attribute or skill on your sheet to roll it.</p>}
|
||
{pending && <ManualEntry key={JSON.stringify(pending)} pending={pending} onSubmit={submitManual} onCancel={() => { setPending(null); setMErr('') }} err={mErr} />}
|
||
{!pending && mErr && <p className="warn small tableerr">{mErr}</p>}
|
||
</div>
|
||
<DiceBar to={dest} isDm={lobby.is_dm} count={count} setCount={setCount} mode={mode} setMode={setMode} own={own} setOwn={setOwn} onRoll={throwDice} busy={busy} />
|
||
</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>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
export default function Lobbies({ section, rules, user, onOpen }) {
|
||
return section ? <LobbyView key={section} id={section} rules={rules} user={user} onOpen={onOpen} /> : <LobbyList />
|
||
}
|