740cf20b93
- Replace all emoji icons (~26 instances) with Lucide icons across the app (DashboardPage, SessionSidebar, PlayerBoards, MyCharacterPanel, PlayerViewPage, EncounterTracker, CampaignsPage, HandoutControl, SessionControl, CharacterSheet, EncounterTipCard, ActionGuide) - Expose real 5e race data in wizard (ASI/vision/traits instead of stub desc); add vision field to loadRaces5e return type - Surface subclass descriptions after picking a subclass in the wizard - Add per-class tips, race synergy notes, and ability/skill guidance in wizard - New ActionGuide component: collapsible "What can I do on my turn?" in player view - New SessionPrepCard: AI + fallback session hook generator on assistant page - New NpcGenCard: AI + fallback quick NPC generator with "Add to campaign" action - Inline NPC detail generator (wand button) on each NPC card in NpcsPage - Quest hook generator button in QuestsPage header - Condition tooltips in player party view (useConditionGlossary) - Pass campaign.system through session snapshot so player view is system-aware Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
135 lines
6.4 KiB
TypeScript
135 lines
6.4 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useLiveQuery } from 'dexie-react-hooks';
|
|
import type { Campaign, Character } from '@/lib/schemas';
|
|
import { localSync } from '@/lib/sync';
|
|
import { buildSnapshot } from '@/lib/sync/snapshot';
|
|
import { claimSeat, sendPlayerPatch } from '@/lib/sync/wsSync';
|
|
import { charactersRepo } from '@/lib/db/repositories';
|
|
import { useUiStore } from '@/stores/uiStore';
|
|
import { useSessionStore } from '@/stores/sessionStore';
|
|
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
|
import { useCharacters, useAllPcs } from '@/features/characters/hooks';
|
|
import { useEncounters } from '@/features/combat/hooks';
|
|
import { useCalendar, useMaps } from '@/features/world/hooks';
|
|
import { Maximize2 } from 'lucide-react';
|
|
import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { PlayerBoards } from './PlayerBoards';
|
|
import { SeatClaimScreen } from './SeatClaimScreen';
|
|
import { MyCharacterPanel } from './MyCharacterPanel';
|
|
import { RollFeed } from './RollFeed';
|
|
|
|
function roomParam(): string | null {
|
|
return new URLSearchParams(window.location.search).get('room');
|
|
}
|
|
|
|
export function PlayerViewPage() {
|
|
// Joining via link (?room=CODE) records a persistent join intent; the app-level
|
|
// connection (RootLayout) keeps it alive across navigation. We render the
|
|
// networked table whenever an intent exists, even without ?room in the URL.
|
|
const joinIntent = useSessionStore((s) => s.joinIntent);
|
|
const room = roomParam();
|
|
useEffect(() => {
|
|
if (room && room !== joinIntent?.joinCode) useSessionStore.getState().setJoinIntent({ joinCode: room });
|
|
}, [room, joinIntent?.joinCode]);
|
|
|
|
if (joinIntent || room) return <NetworkedPlayerView />;
|
|
return <RequireCampaign>{(c) => <LocalPlayerView campaign={c} />}</RequireCampaign>;
|
|
}
|
|
|
|
function Shell({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) {
|
|
const enterFullscreen = () => void document.documentElement.requestFullscreen?.().catch(() => {});
|
|
return (
|
|
<Page>
|
|
<div className="mb-6">
|
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
|
<div>
|
|
<div className="font-display text-sm italic text-accent">The table is live</div>
|
|
<h1 className="font-display text-3xl font-semibold tracking-tight text-ink">{title}</h1>
|
|
<p className="mt-1 text-sm text-muted">{subtitle}</p>
|
|
</div>
|
|
<Button variant="secondary" onClick={enterFullscreen} className="print:hidden"><Maximize2 size={14} aria-hidden /> Fullscreen</Button>
|
|
</div>
|
|
<hr className="gilt-rule mt-3" />
|
|
</div>
|
|
{children}
|
|
</Page>
|
|
);
|
|
}
|
|
|
|
/** Local single-device player view (GM's own screen). */
|
|
function LocalPlayerView({ campaign }: { campaign: Campaign }) {
|
|
const characters = useCharacters(campaign.id);
|
|
const encounters = useEncounters(campaign.id);
|
|
const calendar = useCalendar(campaign.id);
|
|
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, handout: activeHandout });
|
|
const image = snapshot.mapImageId ? images[snapshot.mapImageId] : undefined;
|
|
|
|
return (
|
|
<Shell title={campaign.name} subtitle={`Player View${snapshot.calendarDay !== null ? ` · in-world day ${snapshot.calendarDay}` : ''} · ${localSync.status.toUpperCase()}`}>
|
|
<PlayerBoards snapshot={snapshot} images={images} {...(image ? { image } : {})} />
|
|
</Shell>
|
|
);
|
|
}
|
|
|
|
/** Networked player view — renders the live table. The connection itself is kept
|
|
* alive app-wide by usePlayerConnection (RootLayout), so this view is just UI. */
|
|
function NetworkedPlayerView() {
|
|
const room = useSessionStore((s) => s.joinIntent?.joinCode) ?? '';
|
|
const status = useSessionStore((s) => s.status);
|
|
const error = useSessionStore((s) => s.error);
|
|
const snapshot = usePlayerSessionStore((s) => s.snapshot);
|
|
const images = usePlayerSessionStore((s) => s.images);
|
|
const myCharacter = usePlayerSessionStore((s) => s.myCharacter);
|
|
const seatStatus = usePlayerSessionStore((s) => s.seatStatus);
|
|
const [needPw, setNeedPw] = useState(false);
|
|
|
|
// Locally-developed copies of party characters (for offline-edit handoff) PLUS
|
|
// the player's own PCs, so they can push one the GM hasn't added yet.
|
|
const partyIdsKey = snapshot ? snapshot.party.map((p) => p.id).join(',') : '';
|
|
const localList = useLiveQuery(() => charactersRepo.getMany(partyIdsKey ? partyIdsKey.split(',') : []), [partyIdsKey], [] as Character[]);
|
|
const myPcs = useAllPcs();
|
|
const localChars = new Map([...(localList ?? []), ...myPcs].map((c) => [c.id, c]));
|
|
|
|
useEffect(() => {
|
|
if (error?.toLowerCase().includes('password') && !needPw) {
|
|
setNeedPw(true);
|
|
const pw = window.prompt('This session needs a password:')?.trim();
|
|
if (pw) { useSessionStore.getState().setJoinIntent({ joinCode: room, password: pw }); setNeedPw(false); }
|
|
}
|
|
}, [error, needPw, room]);
|
|
|
|
const handleClaim = (characterId: string) => claimSeat(characterId, localChars.get(characterId));
|
|
const handlePatch = (diff: Partial<Character>) => {
|
|
const cur = usePlayerSessionStore.getState().myCharacter;
|
|
if (!cur) return;
|
|
usePlayerSessionStore.getState().patchMyCharacter(diff);
|
|
sendPlayerPatch(cur.id, diff);
|
|
};
|
|
|
|
if (!snapshot) {
|
|
return (
|
|
<Shell title="Joining session…" subtitle={`Room ${room} · ${status}`}>
|
|
{error ? <EmptyState title="Couldn't join" hint={error} /> : <p className="text-sm text-muted">Connecting…</p>}
|
|
</Shell>
|
|
);
|
|
}
|
|
const image = snapshot.mapImageId ? images[snapshot.mapImageId] : undefined;
|
|
return (
|
|
<Shell title={snapshot.campaignName} subtitle={`Player View · live${snapshot.calendarDay !== null ? ` · in-world day ${snapshot.calendarDay}` : ''}`}>
|
|
{myCharacter && seatStatus === 'granted' ? (
|
|
<MyCharacterPanel character={myCharacter} onPatch={handlePatch} />
|
|
) : (
|
|
<SeatClaimScreen snapshot={snapshot} localChars={localChars} ownCharacters={myPcs} pending={seatStatus === 'pending'} onClaim={handleClaim} />
|
|
)}
|
|
<PlayerBoards snapshot={snapshot} images={images} {...(image ? { image } : {})} />
|
|
<RollFeed />
|
|
</Shell>
|
|
);
|
|
}
|