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:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user