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 ; return {(c) => }; } function Shell({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) { const enterFullscreen = () => void document.documentElement.requestFullscreen?.().catch(() => {}); return (
The table is live

{title}

{subtitle}


{children}
); } /** 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 ( ); } /** 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) => { const cur = usePlayerSessionStore.getState().myCharacter; if (!cur) return; usePlayerSessionStore.getState().patchMyCharacter(diff); sendPlayerPatch(cur.id, diff); }; if (!snapshot) { return ( {error ? :

Connecting…

}
); } const image = snapshot.mapImageId ? images[snapshot.mapImageId] : undefined; return ( {myCharacter && seatStatus === 'granted' ? ( ) : ( )} ); }