Files
ttrpg_manager/src/app/RootLayout.tsx
T
NilsBriggen bd6bb240a1 Phase 10: command palette, settings, backup/restore, sample campaign
- Command palette (Ctrl/Cmd+K): fuzzy search over pages + active-campaign
  characters/notes/NPCs/quests + actions; keyboard nav; header ⌘K button
- Settings page: theme, full backup export/import (restore replaces all in one
  transaction), load sample campaign, danger-zone clear-all
- Backup lib (src/lib/io/backup.ts) snapshots every table; sample seeder
  (src/lib/sample.ts) builds a demo campaign
- Header gets ⌘K + settings gear; routes + nav wired

Backup round-trip unit tests + command-palette/settings e2e.
(Deferred from this phase, noted in plan: audio, Discord/Foundry/Roll20, i18n,
push notifications.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:00:09 +02:00

116 lines
4.0 KiB
TypeScript

import { useEffect, useState } from 'react';
import { Link, Outlet, useRouterState } from '@tanstack/react-router';
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';
const NAV = [
{ to: '/', label: 'Campaigns', exact: true },
{ to: '/dashboard', label: 'Dashboard', exact: false },
{ to: '/characters', label: 'Characters', exact: false },
{ to: '/combat', label: 'Combat', exact: false },
{ to: '/dice', label: 'Dice', exact: false },
{ to: '/compendium', label: 'Compendium', exact: false },
] as const;
function CampaignSwitcher() {
const campaigns = useCampaigns();
const active = useActiveCampaign();
const setActive = useUiStore((s) => s.setActiveCampaign);
if (campaigns.length === 0) {
return <span className="text-xs text-muted">No campaigns yet</span>;
}
return (
<Select
aria-label="Active campaign"
className="w-auto min-w-44"
value={active?.id ?? ''}
onChange={(e) => setActive(e.target.value || null)}
>
<option value=""> Select campaign </option>
{campaigns.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.system === '5e' ? 'D&D 5e' : 'PF2e'})
</option>
))}
</Select>
);
}
function ThemeToggle() {
const theme = useUiStore((s) => s.theme);
const toggle = useUiStore((s) => s.toggleTheme);
return (
<Button size="icon" variant="ghost" onClick={toggle} aria-label="Toggle light/dark theme" title="Toggle theme">
{theme === 'dark' ? '☀' : '☾'}
</Button>
);
}
export function RootLayout() {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const [paletteOpen, setPaletteOpen] = useState(false);
// 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);
}, []);
return (
<div className="flex h-full flex-col">
<header className="flex items-center gap-4 border-b border-line bg-panel px-4 py-2">
<Link to="/" className="font-display text-lg font-bold text-accent">
TTRPG Manager
</Link>
<nav className="flex items-center gap-1" aria-label="Primary">
{NAV.map((item) => {
const isActive = item.exact ? pathname === item.to : pathname.startsWith(item.to);
return (
<Link
key={item.to}
to={item.to}
className={cn(
'rounded-md px-3 py-1.5 text-sm transition-colors',
isActive ? 'bg-elevated text-accent' : 'text-muted hover:text-ink hover:bg-elevated',
)}
>
{item.label}
</Link>
);
})}
</nav>
<div className="ml-auto flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={() => setPaletteOpen(true)} title="Command palette (Ctrl/Cmd+K)" aria-label="Open command palette">
K
</Button>
<Link to="/settings" className="rounded-md p-1.5 text-sm text-muted hover:text-ink" title="Settings" aria-label="Settings"></Link>
<CampaignSwitcher />
<ThemeToggle />
</div>
</header>
<main className="flex-1 overflow-auto">
<ErrorBoundary resetKey={pathname}>
<Outlet />
</ErrorBoundary>
</main>
<RollTray />
{paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />}
</div>
);
}