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:
+220
-93
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import SheetView from './SheetView.jsx'
|
||||
|
||||
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 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)
|
||||
function Chat({ id, me, people, onEvent }) {
|
||||
// Chat, whispers, event log and dice rolls arrive through one feed that is polled while the page is open.
|
||||
function useFeed(id, onEvent, onRoll) {
|
||||
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 box = useRef(null)
|
||||
const stick = useRef(true)
|
||||
|
||||
const poll = async () => {
|
||||
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')) 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(() => {
|
||||
poll()
|
||||
const t = setInterval(poll, 2500)
|
||||
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
|
||||
const el = box.current
|
||||
if (el && stick.current) el.scrollTop = el.scrollHeight
|
||||
@@ -162,43 +178,136 @@ function Chat({ id, me, people, onEvent }) {
|
||||
const submit = async (e) => {
|
||||
e.preventDefault(); setErr('')
|
||||
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.'))
|
||||
setText(''); stick.current = true; poll()
|
||||
}
|
||||
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 (
|
||||
<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) => m.kind === 'event' ? (
|
||||
<div key={m.id} className="msg event"><span className="time">{clock(m.time)}</span> {m.text}</div>
|
||||
) : (
|
||||
<div key={m.id} className={`msg ${m.kind} ${m.mine ? 'mine' : ''}`}>
|
||||
<span className="time">{clock(m.time)}</span>{' '}
|
||||
<strong>{m.mine ? 'You' : m.from || '?'}</strong>
|
||||
{m.kind === 'whisper' && <em className="small"> {m.mine ? `whisper to ${m.to}` : 'whispers to you'}</em>}
|
||||
<span>: {m.text}</span>
|
||||
</div>
|
||||
))}
|
||||
{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 === '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>:{' '}
|
||||
<span className="rollres">{t.n > 1 ? `${t.list} = ` : ''}<b>{t.sum}</b></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}>Whisper to {p}</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 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>
|
||||
)
|
||||
}
|
||||
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
|
||||
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 }) {
|
||||
const mine = useMine()
|
||||
const [lobby, setLobby] = useState(null)
|
||||
@@ -232,7 +341,9 @@ function LobbyView({ id, rules, user, onOpen }) {
|
||||
const [info, setInfo] = useState('')
|
||||
const [pick, setPick] = 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) => {
|
||||
if (!r.ok) throw new Error(r.status)
|
||||
@@ -244,6 +355,13 @@ function LobbyView({ id, rules, user, onOpen }) {
|
||||
return () => clearInterval(t)
|
||||
}, [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) => {
|
||||
setErr(''); setInfo('')
|
||||
const r = await send(url, method, body).catch(() => null)
|
||||
@@ -260,7 +378,8 @@ function LobbyView({ id, rules, user, onOpen }) {
|
||||
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 active = tab.startsWith('sheet:') && !sheets.find((m) => m.username === tab.slice(6)) ? 'party' : tab
|
||||
|
||||
const dest = people.includes(to) ? to : ''
|
||||
|
||||
return (
|
||||
<section className="page lobbypage">
|
||||
<p className="small"><a href="#lobbies">← All lobbies</a></p>
|
||||
@@ -271,70 +390,78 @@ function LobbyView({ id, rules, user, onOpen }) {
|
||||
{err && <p className="warn">{err}</p>}
|
||||
{info && <p className="small">{info}</p>}
|
||||
|
||||
<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>
|
||||
{sheets.map((m) => (
|
||||
<button key={m.username} role="tab" aria-selected={active === `sheet:${m.username}`} className={active === `sheet:${m.username}` ? 'on' : ''}
|
||||
onClick={() => setTab(`sheet:${m.username}`)} title={m.username}>{m.me && !lobby.is_dm ? 'My character' : m.character.name}</button>
|
||||
))}
|
||||
<div className="lobbygrid">
|
||||
<div className="lobbymain">
|
||||
<div className="tabs" role="tablist">
|
||||
<button role="tab" aria-selected={active === 'party'} className={active === 'party' ? 'on' : ''} onClick={() => setTab('party')}>Party ({lobby.members.length})</button>
|
||||
{sheets.map((m) => (
|
||||
<button key={m.username} role="tab" aria-selected={active === `sheet:${m.username}`} className={active === `sheet:${m.username}` ? 'on' : ''}
|
||||
onClick={() => setTab(`sheet:${m.username}`)} title={m.username}>{m.me && !lobby.is_dm ? 'My character' : m.character.name}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{active.startsWith('sheet:') && (() => {
|
||||
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)} />
|
||||
})()}
|
||||
|
||||
{active === 'party' && (
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{lobby.members.length === 0 && <p className="card">Nobody has joined yet. Share the code above.</p>}
|
||||
<ul className="charlist partygrid">
|
||||
{lobby.members.map((m) => (
|
||||
<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')} />
|
||||
))}
|
||||
</ul>
|
||||
{!lobby.is_dm && <p className="small muted">You see your own character in full. Of the others you only see this short card.</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 saved version of your character.</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>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{active === 'chat' && <Chat key={id} id={id} me={user} people={people} onEvent={load} />}
|
||||
|
||||
{active.startsWith('sheet:') && (() => {
|
||||
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)} />
|
||||
})()}
|
||||
|
||||
{active === 'party' && (
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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={(x) => setTab(`sheet:${x.username}`)}
|
||||
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">You see your own character in full. Of the others you only see this short card.</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 saved version of your character.</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>
|
||||
</>
|
||||
)}
|
||||
<DiceTray rolls={tray} me={user} />
|
||||
<DiceBar id={id} to={dest} poll={feed.poll} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -204,3 +204,44 @@ h2 + .charlist, .card + h2 { margin-top: 1.4rem; }
|
||||
.sheetview h4 { margin: 1rem 0 .3rem; }
|
||||
.sheetview table { 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