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:
2026-06-08 17:01:15 +02:00
parent d94df41a95
commit b2cf62f642
10 changed files with 207 additions and 6 deletions
+85
View File
@@ -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>
</>
);
}