Phase 8: session tooling — handouts to players

- GM "📣 Handout" composer in the header (title + body + optional image) sets
  uiStore.activeHandout; a clear button hides it.
- snapshot gains an optional handout {title, body, imageId}; buildSnapshot streams
  the handout image over the existing image channel (id "handout").
- Player view (local + networked) shows the handout as a prominent card above the
  boards. resizeToMax() keeps handout images compact.
- (Live scene/map switching already works via "Show to players".)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 15:48:23 +02:00
parent f923375ebb
commit 7f1ab8f094
10 changed files with 125 additions and 6 deletions
+48
View File
@@ -0,0 +1,48 @@
import { useState } from 'react';
import { useUiStore } from '@/stores/uiStore';
import { fileToDataUrl, resizeToMax } from '@/lib/img/resize';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { Input, Textarea } from '@/components/ui/Input';
/** GM control: compose a handout (text + optional image) shown to players. */
export function HandoutControl() {
const active = useUiStore((s) => s.activeHandout);
const setActive = useUiStore((s) => s.setActiveHandout);
const [open, setOpen] = useState(false);
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [image, setImage] = useState<string | undefined>(undefined);
const openComposer = () => { setTitle(active?.title ?? ''); setBody(active?.body ?? ''); setImage(active?.image); setOpen(true); };
const show = () => { setActive({ title: title.trim() || 'Handout', body: body.trim(), ...(image ? { image } : {}) }); setOpen(false); };
const onImage = async (file: File) => setImage(await resizeToMax(await fileToDataUrl(file), 1280));
return (
<>
<Button size="sm" variant={active ? 'primary' : 'ghost'} onClick={openComposer} title="Show a handout (text / image) to players">
{active ? '📣 Handout ✓' : '📣 Handout'}
</Button>
{active && <Button size="sm" variant="ghost" className="px-1 text-danger" onClick={() => setActive(null)} title="Hide handout"></Button>}
{open && (
<Modal open onClose={() => setOpen(false)} title="Handout for players"
footer={<>
<Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
{active && <Button variant="ghost" className="text-danger" onClick={() => { setActive(null); setOpen(false); }}>Hide current</Button>}
<Button variant="primary" onClick={show}>Show to players</Button>
</>}>
<div className="space-y-3">
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title (e.g. A Mysterious Letter)" aria-label="Handout title" />
<Textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Text shown to players…" rows={5} aria-label="Handout text" />
<div className="flex items-center gap-2">
{image && <img src={image} alt="" className="h-16 w-16 rounded border border-line object-cover" />}
<label className="cursor-pointer rounded-md border border-line px-2 py-1 text-xs text-ink hover:border-accent">Image<input type="file" accept="image/*" className="hidden" aria-label="Handout image" onChange={(e) => { const f = e.target.files?.[0]; if (f) void onImage(f); e.target.value = ''; }} /></label>
{image && <Button size="sm" variant="ghost" onClick={() => setImage(undefined)}>Remove image</Button>}
</div>
<p className="text-xs text-muted">Players see this on the Player View and live, if you're hosting a session.</p>
</div>
</Modal>
)}
</>
);
}
+3 -2
View File
@@ -25,13 +25,14 @@ export function useSessionBroadcaster(campaign: Campaign | null): void {
const calendar = useCalendar(cid);
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
const activeMapId = useUiStore((s) => s.activeMapId);
const activeHandout = useUiStore((s) => s.activeHandout);
const debouncedPush = useDebouncedCallback((s: Snapshot) => pushSnapshot(s), 250);
useEffect(() => {
if (role !== 'gm' || status !== 'connected' || !campaign) return;
const { snapshot, images } = buildSnapshot({ campaign, characters, encounters, maps, calendar: calendar ?? null, activeEncounterId, activeMapId });
const { snapshot, images } = buildSnapshot({ campaign, characters, encounters, maps, calendar: calendar ?? null, activeEncounterId, activeMapId, handout: activeHandout });
debouncedPush(snapshot);
for (const [id, dataUrl] of Object.entries(images)) pushImage(id, dataUrl);
}, [role, status, campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId, debouncedPush]);
}, [role, status, campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId, activeHandout, debouncedPush]);
}
+10 -1
View File
@@ -6,9 +6,18 @@ import { PlayerMapView } from './PlayerMapView';
/** Presentational player view: battle map + party + initiative, from a snapshot. */
export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot; image?: string; images?: Record<string, string> }) {
const { party, encounter, map } = snapshot;
const { party, encounter, map, handout } = snapshot;
const handoutImage = handout?.imageId ? images?.[handout.imageId] : undefined;
return (
<>
{handout && (
<section className="mb-6 rounded-xl border border-accent/50 bg-accent/5 p-4">
<h2 className="font-display text-2xl font-bold text-accent">{handout.title || 'Handout'}</h2>
{handout.body && <p className="mt-1 whitespace-pre-wrap text-ink">{handout.body}</p>}
{handoutImage && <img src={handoutImage} alt="" className="mt-3 max-h-[60vh] w-auto rounded-lg border border-line" />}
</section>
)}
{map && (
<section className="mb-6">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Battle map</h2>
+2 -1
View File
@@ -52,8 +52,9 @@ function LocalPlayerView({ campaign }: { campaign: Campaign }) {
const maps = useMaps(campaign.id);
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
const activeMapId = useUiStore((s) => s.activeMapId);
const activeHandout = useUiStore((s) => s.activeHandout);
const { snapshot, images } = buildSnapshot({ campaign, characters, encounters, maps, calendar: calendar ?? null, activeEncounterId, activeMapId });
const { snapshot, images } = buildSnapshot({ campaign, characters, encounters, maps, calendar: calendar ?? null, activeEncounterId, activeMapId, handout: activeHandout });
const image = snapshot.mapImageId ? images[snapshot.mapImageId] : undefined;
return (