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:
@@ -39,3 +39,19 @@ test('player view shows the party and hides enemy HP numbers', async ({ page })
|
||||
await expect(page.getByText('Bandit')).toBeVisible();
|
||||
await expect(page.getByText(/Healthy|Bloodied|Down/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('handout: GM pushes a handout to the player view', async ({ page }) => {
|
||||
await page.getByRole('button', { name: '+ New campaign' }).first().click();
|
||||
await page.locator('input[data-autofocus]').fill('Handout Camp');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
await page.getByRole('button', { name: /Handout/ }).click();
|
||||
await page.getByLabel('Handout title').fill('A Mysterious Letter');
|
||||
await page.getByLabel('Handout text').fill('Meet me at midnight.');
|
||||
await page.getByRole('button', { name: 'Show to players' }).click();
|
||||
|
||||
await page.getByRole('link', { name: 'Dashboard' }).click();
|
||||
await page.getByRole('link', { name: 'Player View' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'A Mysterious Letter' })).toBeVisible();
|
||||
await expect(page.getByText('Meet me at midnight.')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { RollTray } from '@/components/ui/RollTray';
|
||||
import { Select } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { SessionControl } from '@/features/play/SessionControl';
|
||||
import { HandoutControl } from '@/features/play/HandoutControl';
|
||||
import { useSessionBroadcaster } from '@/features/play/useSessionBroadcaster';
|
||||
|
||||
const NAV = [
|
||||
@@ -98,6 +99,7 @@ export function RootLayout() {
|
||||
})}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{activeCampaign && <HandoutControl />}
|
||||
<SessionControl />
|
||||
<Button size="sm" variant="ghost" onClick={() => setPaletteOpen(true)} title="Command palette (Ctrl/Cmd+K)" aria-label="Open command palette">
|
||||
⌘K
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -16,6 +16,26 @@ function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Downscale an image to fit within maxDim (preserving aspect), as a JPEG data URL. */
|
||||
export async function resizeToMax(src: string, maxDim = 1024, quality = 0.85): Promise<string> {
|
||||
try {
|
||||
const img = await loadImage(src);
|
||||
const scale = Math.min(1, maxDim / Math.max(img.naturalWidth, img.naturalHeight));
|
||||
const w = Math.max(1, Math.round(img.naturalWidth * scale));
|
||||
const h = Math.max(1, Math.round(img.naturalHeight * scale));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w; canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return src;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
return canvas.toDataURL('image/jpeg', quality);
|
||||
} catch {
|
||||
return src;
|
||||
}
|
||||
}
|
||||
|
||||
/** Natural pixel size of an image data URL. */
|
||||
export function imageSize(src: string): Promise<{ w: number; h: number }> {
|
||||
return loadImage(src).then((img) => ({ w: img.naturalWidth, h: img.naturalHeight }));
|
||||
|
||||
@@ -56,6 +56,12 @@ export const playerMapSchema = z.object({
|
||||
tokens: z.array(playerTokenSchema), drawings: z.array(playerDrawingSchema), hasImage: z.boolean(),
|
||||
});
|
||||
|
||||
export const handoutSchema = z.object({
|
||||
title: z.string(),
|
||||
body: z.string(),
|
||||
imageId: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const snapshotSchema = z.object({
|
||||
campaignName: z.string(),
|
||||
calendarDay: int.nullable(),
|
||||
@@ -63,6 +69,7 @@ export const snapshotSchema = z.object({
|
||||
encounter: playerEncounterSchema.nullable(),
|
||||
map: playerMapSchema.nullable(),
|
||||
mapImageId: z.string().nullable(),
|
||||
handout: handoutSchema.nullable().optional(),
|
||||
});
|
||||
export type Snapshot = z.infer<typeof snapshotSchema>;
|
||||
|
||||
|
||||
@@ -24,8 +24,9 @@ export function buildSnapshot(input: {
|
||||
calendar: Calendar | null;
|
||||
activeEncounterId: string | null;
|
||||
activeMapId: string | null;
|
||||
handout?: { title: string; body: string; image?: string } | null;
|
||||
}): SnapshotResult {
|
||||
const { campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId } = input;
|
||||
const { campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId, handout } = input;
|
||||
const sys = getSystem(campaign.system);
|
||||
const images: Record<string, string> = {};
|
||||
const byId = new Map(characters.map((c) => [c.id, c]));
|
||||
@@ -70,6 +71,12 @@ export function buildSnapshot(input: {
|
||||
|
||||
if (activeMap?.image) images[activeMap.id] = activeMap.image;
|
||||
|
||||
let handoutOut: { title: string; body: string; imageId: string | null } | null = null;
|
||||
if (handout) {
|
||||
if (handout.image) images['handout'] = handout.image;
|
||||
handoutOut = { title: handout.title, body: handout.body, imageId: handout.image ? 'handout' : null };
|
||||
}
|
||||
|
||||
return {
|
||||
snapshot: {
|
||||
campaignName: campaign.name,
|
||||
@@ -78,6 +85,7 @@ export function buildSnapshot(input: {
|
||||
encounter: activeEnc ? toPlayerEncounter(activeEnc) : null,
|
||||
map: projection,
|
||||
mapImageId: activeMap && activeMap.image ? activeMap.id : null,
|
||||
handout: handoutOut,
|
||||
},
|
||||
images,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,8 @@ import { persist } from 'zustand/middleware';
|
||||
|
||||
export type Theme = 'dark' | 'light';
|
||||
|
||||
export interface Handout { title: string; body: string; image?: string }
|
||||
|
||||
interface UiState {
|
||||
theme: Theme;
|
||||
/** id of the campaign currently in focus across feature screens */
|
||||
@@ -11,11 +13,14 @@ interface UiState {
|
||||
activeEncounterId: string | null;
|
||||
/** map currently projected to the player view */
|
||||
activeMapId: string | null;
|
||||
/** a handout currently shown to players (title/body/optional image) */
|
||||
activeHandout: Handout | null;
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
setActiveCampaign: (id: string | null) => void;
|
||||
setActiveEncounter: (id: string | null) => void;
|
||||
setActiveMap: (id: string | null) => void;
|
||||
setActiveHandout: (h: Handout | null) => void;
|
||||
}
|
||||
|
||||
export const useUiStore = create<UiState>()(
|
||||
@@ -25,14 +30,16 @@ export const useUiStore = create<UiState>()(
|
||||
activeCampaignId: null,
|
||||
activeEncounterId: null,
|
||||
activeMapId: null,
|
||||
activeHandout: null,
|
||||
setTheme: (theme) => {
|
||||
applyTheme(theme);
|
||||
set({ theme });
|
||||
},
|
||||
toggleTheme: () => get().setTheme(get().theme === 'dark' ? 'light' : 'dark'),
|
||||
setActiveCampaign: (id) => set({ activeCampaignId: id, activeEncounterId: null, activeMapId: null }),
|
||||
setActiveCampaign: (id) => set({ activeCampaignId: id, activeEncounterId: null, activeMapId: null, activeHandout: null }),
|
||||
setActiveEncounter: (id) => set({ activeEncounterId: id }),
|
||||
setActiveMap: (id) => set({ activeMapId: id }),
|
||||
setActiveHandout: (activeHandout) => set({ activeHandout }),
|
||||
}),
|
||||
{
|
||||
name: 'ttrpg-ui',
|
||||
|
||||
Reference in New Issue
Block a user