P14a: global session sidebar + roster + private-channel backend
- Session sidebar (header "☰ Session" toggle, mobile drawer) visible to GM AND players: which session you're in, who's here (GM + players), and the live roll feed. The GM's own public rolls now mirror into the local feed too. - Server roster broadcast to everyone (sendRoster on join/seatGrant/disconnect/ host-resume); includes the GM. Players now see the roster, not just the GM. - Targeted private channel (backend, wired to UI next): privateState (GM→server→ one player only) + privateHandout; image sent INLINE so non-recipients never get it. roster/privateHandout schemas + stores + wsSync handlers + senders. - Adversarial-review fixes: GM included in roster; roster refreshes on GM disconnect; privateState returns a forbidden error on auth failure. +3 server tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { useSessionStore } from '@/stores/sessionStore';
|
||||
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
/**
|
||||
* Global session panel (drawer). Everyone in a live session sees the same thing:
|
||||
* which session they're in, who else is here (and their character), and the live
|
||||
* roll feed. The GM gets extra per-player actions (added in later sub-phases:
|
||||
* whisper + targeted share). Opened from the header on any page.
|
||||
*/
|
||||
export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const role = useSessionStore((s) => s.role);
|
||||
const status = useSessionStore((s) => s.status);
|
||||
const joinCode = useSessionStore((s) => s.joinCode);
|
||||
const intent = useSessionStore((s) => s.joinIntent);
|
||||
const roster = useSessionStore((s) => s.roster);
|
||||
const rolls = usePlayerSessionStore((s) => s.rolls);
|
||||
const code = role === 'gm' ? joinCode : intent?.joinCode;
|
||||
const inSession = role !== 'off' || !!intent;
|
||||
|
||||
return (
|
||||
<>
|
||||
{open && <div className="fixed inset-0 z-40 bg-black/40 print:hidden" onClick={onClose} aria-hidden />}
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed right-0 top-0 z-50 flex h-full w-80 max-w-[88vw] transform flex-col border-l border-line bg-panel shadow-2xl transition-transform print:hidden',
|
||||
open ? 'translate-x-0' : 'translate-x-full',
|
||||
)}
|
||||
aria-label="Session panel"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-line p-3">
|
||||
<h2 className="font-display font-semibold text-ink">Session</h2>
|
||||
<button onClick={onClose} aria-label="Close session panel" className="text-muted hover:text-ink">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-3">
|
||||
{inSession && code ? (
|
||||
<div className="rounded-lg border border-line bg-surface p-2">
|
||||
<div className="text-xs uppercase tracking-wide text-muted">{role === 'gm' ? 'Hosting' : 'Joined'}</div>
|
||||
<div className="font-mono text-lg font-semibold text-accent">{code}</div>
|
||||
<div className={cn('text-xs', status === 'connected' ? 'text-success' : 'text-muted')}>● {status === 'connected' ? 'Connected' : status}</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted">You're not in a live session. Host one (GM) or open a join link to play together.</p>
|
||||
)}
|
||||
|
||||
{inSession && (
|
||||
<section>
|
||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Who's here ({roster.length})</h3>
|
||||
{roster.length === 0 ? (
|
||||
<p className="text-xs text-muted">No players have joined yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{roster.map((p) => (
|
||||
<li key={p.playerId} className="flex items-center gap-2 rounded-md border border-line bg-surface px-2 py-1 text-sm text-ink">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-success" aria-hidden />
|
||||
<span className="truncate">{p.name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Rolls</h3>
|
||||
{rolls.length === 0 ? (
|
||||
<p className="text-xs text-muted">No rolls yet this session.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{rolls.map((r) => (
|
||||
<li key={r.id} className="flex items-baseline gap-2 rounded-md border border-line bg-surface px-2 py-1 text-xs">
|
||||
<span className="font-semibold text-accent">{r.playerName}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-ink">{r.label || r.expression}</span>
|
||||
<span className="font-display text-sm font-bold text-ink">{r.total}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user