import { useEffect, useRef, useState } from 'react'; import { Link, Outlet, useRouterState } from '@tanstack/react-router'; import { Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower, LibraryBig, Settings as SettingsIcon, Search, PanelLeftClose, PanelLeftOpen, Sun, Moon, PanelRight, ScrollText, Drama, Target, CalendarDays, FlaskConical, Sparkles, Wand2, ShieldCheck, type LucideIcon, } from 'lucide-react'; import { useUiStore } from '@/stores/uiStore'; import { CommandPalette } from '@/components/CommandPalette'; import { useActiveCampaign, useCampaigns } from '@/features/campaigns/hooks'; import { cn } from '@/lib/cn'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; 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'; import { usePlayerConnection } from '@/features/play/usePlayerConnection'; import { useCloudAutosave } from '@/features/cloud/useCloudAutosave'; import { PlayerSessionBadge } from '@/features/play/PlayerConnection'; import { SignalsBell } from '@/features/assistant/SignalsBell'; import { SyncStatusIndicator } from '@/features/cloud/SyncStatusIndicator'; import { SessionSidebar } from '@/features/play/SessionSidebar'; import { useSessionStore } from '@/stores/sessionStore'; import { useIsAdmin } from '@/features/admin/useIsAdmin'; type NavItem = { to: string; label: string; icon: LucideIcon; exact: boolean; group: 'top' | 'play' | 'world' | 'reference' }; const NAV: NavItem[] = [ { to: '/', label: 'Campaigns', icon: LibraryBig, exact: true, group: 'top' }, { to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, exact: false, group: 'play' }, { to: '/characters', label: 'Characters', icon: UserRound, exact: false, group: 'play' }, { to: '/combat', label: 'Combat', icon: Swords, exact: false, group: 'play' }, { to: '/director', label: 'AI Director', icon: Wand2, exact: false, group: 'play' }, { to: '/maps', label: 'Battle Map', icon: MapIcon, exact: false, group: 'play' }, { to: '/dice', label: 'Dice', icon: Dices, exact: false, group: 'play' }, { to: '/notes', label: 'Notes', icon: ScrollText, exact: false, group: 'world' }, { to: '/npcs', label: 'NPCs', icon: Drama, exact: false, group: 'world' }, { to: '/quests', label: 'Quests', icon: Target, exact: false, group: 'world' }, { to: '/calendar', label: 'Calendar', icon: CalendarDays, exact: false, group: 'world' }, { to: '/compendium', label: 'Compendium', icon: BookOpenText, exact: false, group: 'reference' }, { to: '/homebrew', label: 'Homebrew', icon: FlaskConical, exact: false, group: 'reference' }, { to: '/assistant', label: 'Assistant', icon: Sparkles, exact: false, group: 'reference' }, { to: '/play', label: 'Live Session', icon: RadioTower, exact: false, group: 'reference' }, ]; const GROUPS: { id: NavItem['group']; label: string | null }[] = [ { id: 'top', label: null }, { id: 'play', label: 'At the Table' }, { id: 'world', label: 'World' }, { id: 'reference', label: 'Reference' }, ]; function CampaignSwitcher() { const campaigns = useCampaigns(); const active = useActiveCampaign(); const setActive = useUiStore((s) => s.setActiveCampaign); if (campaigns.length === 0) { return No campaigns yet; } return ( ); } function ThemeToggle() { const theme = useUiStore((s) => s.theme); const toggle = useUiStore((s) => s.toggleTheme); return ( ); } export function RootLayout() { const pathname = useRouterState({ select: (s) => s.location.pathname }); const [paletteOpen, setPaletteOpen] = useState(false); const sessionDockOpen = useUiStore((s) => s.sessionDockOpen); const setSessionDock = useUiStore((s) => s.setSessionDock); const railCollapsed = useUiStore((s) => s.railCollapsed); const toggleRail = useUiStore((s) => s.toggleRail); const sessionConnected = useSessionStore((s) => s.status === 'connected'); const sessionRole = useSessionStore((s) => s.role); const joinIntent = useSessionStore((s) => s.joinIntent); const activeCampaign = useActiveCampaign(); // While the GM is hosting, mirror state to players (inert unless hosting). useSessionBroadcaster(activeCampaign ?? null); usePlayerConnection(); useCloudAutosave(); // Instance admins get an extra nav entry (server re-checks every /api/admin call). const isAdmin = useIsAdmin(); const nav: NavItem[] = isAdmin ? [...NAV, { to: '/admin', label: 'Admin', icon: ShieldCheck, exact: false, group: 'reference' }] : NAV; // Global Ctrl/Cmd+K opens the command palette. useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); setPaletteOpen((v) => !v); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, []); // Players get the session panel docked open by default (once) — they live in it. const autoOpenedRef = useRef(false); useEffect(() => { if (!autoOpenedRef.current && joinIntent && sessionRole === 'player') { autoOpenedRef.current = true; setSessionDock(true); } }, [joinIntent, sessionRole, setSessionDock]); // Labels show only when expanded AND on >= sm; the rail is an icon strip (68px) // on phones and when collapsed, so a single nav serves every size. const showLabel = !railCollapsed; return ( <>