Lobby: tabs, live chat with whispers, event log, embedded read-only character sheets
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
@@ -79,7 +79,7 @@ export default function App() {
|
||||
onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} />
|
||||
)}
|
||||
{rules && page === 'lobbies' && (
|
||||
<Lobbies section={hash.split('/')[1]} onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} />
|
||||
<Lobbies section={hash.split('/')[1]} rules={rules} user={username} onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} />
|
||||
)}
|
||||
{page === 'account' && <Account user={username} onLogout={logout} />}
|
||||
</main>
|
||||
|
||||
+180
-56
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import SheetView from './SheetView.jsx'
|
||||
|
||||
const J = { 'Content-Type': 'application/json' }
|
||||
const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) })
|
||||
@@ -111,7 +112,7 @@ function Card({ m, isDm, onOpen, onKick }) {
|
||||
<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>}
|
||||
{m.character_id && <button onClick={() => onOpen(m)}>{m.me ? 'Sheet' : 'View sheet'}</button>}
|
||||
{isDm && <button className="ghost danger" onClick={() => onKick(m)}>Remove</button>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,14 +126,113 @@ function Card({ m, isDm, onOpen, onKick }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ---- one lobby (DM view or player view)
|
||||
function LobbyView({ id, onOpen }) {
|
||||
const clock = (t) => new Date(t * 1000).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
// ---- chat, whispers and event log (polls the server every few seconds while the tab is open)
|
||||
function Chat({ id, me, people, onEvent }) {
|
||||
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 () => {
|
||||
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()
|
||||
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()
|
||||
}
|
||||
useEffect(() => {
|
||||
poll()
|
||||
const t = setInterval(poll, 2500)
|
||||
return () => clearInterval(t)
|
||||
}, [id]) // eslint-disable-line
|
||||
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_(id, 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')
|
||||
|
||||
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>
|
||||
))}
|
||||
</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>)}
|
||||
</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>
|
||||
</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 }) {
|
||||
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>
|
||||
<div className="row">
|
||||
<h2>{data?.name || name || 'Nameless'}</h2>
|
||||
<button className="ghost" onClick={onOpen}>{owner ? 'Open in the Character tab' : 'Edit in the Character tab'}</button>
|
||||
</div>
|
||||
<p className="muted small">{owner ? `Played by ${owner}. ` : ''}Read-only, hover or tap a value to see how it is made up.</p>
|
||||
{err && <p className="warn">{err}</p>}
|
||||
{data && <SheetView data={data} baseRules={rules} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- one lobby: Chat, Party (members, settings) and a sheet tab per visible character
|
||||
function LobbyView({ id, rules, user, 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 [tab, setTab] = useState('chat')
|
||||
|
||||
const load = () => fetch(`/api/lobbies/${id}`).then(async (r) => {
|
||||
if (!r.ok) throw new Error(r.status)
|
||||
@@ -140,7 +240,7 @@ function LobbyView({ id, onOpen }) {
|
||||
}).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
|
||||
const t = setInterval(() => !document.hidden && load(), 15000) // picks up joins and character changes of the others
|
||||
return () => clearInterval(t)
|
||||
}, [id]) // eslint-disable-line
|
||||
|
||||
@@ -152,69 +252,93 @@ function LobbyView({ id, onOpen }) {
|
||||
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>
|
||||
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 open = (m) => onOpen({ id: m.character_id, name: m.character?.name, ...(m.me ? {} : { owner: 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 active = tab.startsWith('sheet:') && !sheets.find((m) => m.username === tab.slice(6)) ? 'party' : tab
|
||||
|
||||
return (
|
||||
<section className="page narrow">
|
||||
<section className="page lobbypage">
|
||||
<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>
|
||||
<div className="row">
|
||||
<h1>{lobby.name}</h1>
|
||||
<span className="muted">DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''}</span>
|
||||
</div>
|
||||
{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')} />
|
||||
<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>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Lobbies({ section, onOpen }) {
|
||||
return section ? <LobbyView key={section} id={section} onOpen={onOpen} /> : <LobbyList />
|
||||
export default function Lobbies({ section, rules, user, onOpen }) {
|
||||
return section ? <LobbyView key={section} id={section} rules={rules} user={user} onOpen={onOpen} /> : <LobbyList />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useMemo } from 'react'
|
||||
import Tip from './Tip.jsx'
|
||||
import { normalize } from './calc.js'
|
||||
import { sheetModel } from './sheetModel.js'
|
||||
|
||||
function Rows({ rows, valueLabel }) {
|
||||
if (!rows.length) return <p className="muted small">–</p>
|
||||
return (
|
||||
<table className="difftable"><tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.k} className={r.dim ? 'dim' : ''}>
|
||||
<td><Tip text={r.tip}>{r.label}</Tip>{r.desc && <div className="rowdesc">{r.desc}</div>}</td>
|
||||
<td>{valueLabel ? valueLabel(r) : r.val}</td>
|
||||
{r.extra !== undefined && <td className="muted">{r.extra}</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody></table>
|
||||
)
|
||||
}
|
||||
|
||||
// Read-only character sheet (used in lobbies). Hover or tap a value to see how it is made up.
|
||||
export default function SheetView({ data, baseRules }) {
|
||||
const m = useMemo(() => sheetModel(normalize(data, baseRules), baseRules), [data, baseRules])
|
||||
const c = m.c
|
||||
const trained = m.skills.filter((s) => c.skills.find((x) => x.id === s.k && (x.points > 0 || x.pips > 0 || (x.type === 'advanced' && x.usable))))
|
||||
const rest = m.skills.filter((s) => !trained.includes(s))
|
||||
|
||||
return (
|
||||
<div className="sheetview">
|
||||
<Rows rows={m.head.filter((h) => h.k !== 'name')} />
|
||||
<div className="sheetcols">
|
||||
<div>
|
||||
<h4>Attributes</h4>
|
||||
<Rows rows={m.attrs} />
|
||||
<h4>Derived</h4>
|
||||
<Rows rows={m.derived} />
|
||||
</div>
|
||||
<div>
|
||||
<h4>Skills</h4>
|
||||
<Rows rows={trained} />
|
||||
{rest.length > 0 && (
|
||||
<details>
|
||||
<summary className="small muted">All other skills ({rest.length})</summary>
|
||||
<Rows rows={rest} />
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<h4>Talents</h4>
|
||||
<Rows rows={m.talents} />
|
||||
<h4><Tip text={m.tips.capacity}>Mutations / implants <span className="muted small">{c.capacityUsed}/{c.capacityMax}</span></Tip></h4>
|
||||
<Rows rows={m.augments} />
|
||||
<h4><Tip text={m.tips.carry}>Equipment <span className="muted small">{c.weight}/{c.carryMax} kg</span></Tip></h4>
|
||||
<Rows rows={m.gear} valueLabel={(r) => (r.val > 1 ? `x${r.val}` : '')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -182,3 +182,25 @@ a.button:hover { filter: brightness(1.1); }
|
||||
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; }
|
||||
|
||||
/* lobby page: tabs, chat, sheet */
|
||||
.lobbypage { max-width: 980px; }
|
||||
.tabs { display: flex; flex-wrap: wrap; gap: .3rem; border-bottom: 1px solid var(--line); margin: .8rem 0 1rem; }
|
||||
.tabs button { background: transparent; color: var(--text); border: 1px solid transparent; border-bottom: none; border-radius: var(--radius) var(--radius) 0 0; font-weight: 500; padding: .45rem .9rem; margin-bottom: -1px; }
|
||||
.tabs button.on { background: var(--panel); border-color: var(--line); border-bottom: 1px solid var(--panel); color: var(--text); font-weight: 700; }
|
||||
.chatwrap { display: grid; gap: .6rem; }
|
||||
.chatbox { height: min(55vh, 520px); overflow-y: auto; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: .6rem .8rem; display: flex; flex-direction: column; gap: .25rem; }
|
||||
.msg { overflow-wrap: anywhere; line-height: 1.35; }
|
||||
.msg .time { color: var(--muted); font-size: .75rem; font-variant-numeric: tabular-nums; }
|
||||
.msg.event { color: var(--muted); font-size: .85rem; font-style: italic; }
|
||||
.msg.mine strong { color: var(--noxium); }
|
||||
.msg.whisper { background: var(--panel2); border-left: 3px solid #8a63c7; padding: .15rem .5rem; border-radius: 4px; }
|
||||
.msg.whisper em { color: #8a63c7; }
|
||||
.chatform { flex-wrap: nowrap; gap: .5rem; }
|
||||
.chatform select { width: 12rem; flex: 0 0 auto; }
|
||||
.chatform select.whisper { border-color: #8a63c7; }
|
||||
.chatform input { flex: 1 1 auto; width: auto; min-width: 0; }
|
||||
.sheetcols { display: grid; grid-template-columns: 1fr 1fr; gap: 0 2rem; }
|
||||
.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%; } }
|
||||
|
||||
Reference in New Issue
Block a user