bd6bb240a1
- 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>
116 lines
4.0 KiB
TypeScript
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>
|
|
);
|
|
}
|