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>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import Fuse from 'fuse.js';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { useActiveCampaign } from '@/features/campaigns/hooks';
|
||||
import { useCharacters } from '@/features/characters/hooks';
|
||||
import { useNotes, useNpcs, useQuests } from '@/features/world/hooks';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface Command {
|
||||
id: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
const NAV: { label: string; to: string }[] = [
|
||||
{ label: 'Campaigns', to: '/' },
|
||||
{ label: 'Dashboard', to: '/dashboard' },
|
||||
{ label: 'Characters', to: '/characters' },
|
||||
{ label: 'Combat', to: '/combat' },
|
||||
{ label: 'Dice', to: '/dice' },
|
||||
{ label: 'Compendium', to: '/compendium' },
|
||||
{ label: 'Notes', to: '/notes' },
|
||||
{ label: 'NPCs', to: '/npcs' },
|
||||
{ label: 'Quests', to: '/quests' },
|
||||
{ label: 'Maps', to: '/maps' },
|
||||
{ label: 'Homebrew', to: '/homebrew' },
|
||||
{ label: 'Player View', to: '/play' },
|
||||
{ label: 'Settings', to: '/settings' },
|
||||
];
|
||||
|
||||
export function CommandPalette({ onClose }: { onClose: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
const toggleTheme = useUiStore((s) => s.toggleTheme);
|
||||
const campaign = useActiveCampaign();
|
||||
const cid = campaign?.id ?? '';
|
||||
const characters = useCharacters(cid);
|
||||
const notes = useNotes(cid);
|
||||
const npcs = useNpcs(cid);
|
||||
const quests = useQuests(cid);
|
||||
|
||||
const [q, setQ] = useState('');
|
||||
const [idx, setIdx] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => { inputRef.current?.focus(); }, []);
|
||||
|
||||
const go = (to: string, params?: Record<string, string>) => () => {
|
||||
void navigate(params ? { to, params } : { to });
|
||||
onClose();
|
||||
};
|
||||
|
||||
const commands = useMemo<Command[]>(() => {
|
||||
const list: Command[] = NAV.map((n) => ({ id: `nav:${n.to}`, label: n.label, hint: 'Go', run: go(n.to) }));
|
||||
list.push({ id: 'act:theme', label: 'Toggle theme', hint: 'Action', run: () => { toggleTheme(); onClose(); } });
|
||||
for (const c of characters) list.push({ id: `char:${c.id}`, label: c.name, hint: 'Character', run: go('/characters/$characterId', { characterId: c.id }) });
|
||||
for (const n of notes) list.push({ id: `note:${n.id}`, label: n.title, hint: 'Note', run: go('/notes') });
|
||||
for (const n of npcs) list.push({ id: `npc:${n.id}`, label: n.name, hint: 'NPC', run: go('/npcs') });
|
||||
for (const qu of quests) list.push({ id: `quest:${qu.id}`, label: qu.title, hint: 'Quest', run: go('/quests') });
|
||||
return list;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [characters, notes, npcs, quests, toggleTheme]);
|
||||
|
||||
const fuse = useMemo(() => new Fuse(commands, { keys: ['label'], threshold: 0.4 }), [commands]);
|
||||
const results = useMemo(() => (q.trim() ? fuse.search(q.trim()).map((r) => r.item) : commands).slice(0, 50), [q, fuse, commands]);
|
||||
const clampedIdx = Math.min(idx, Math.max(0, results.length - 1));
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setIdx((i) => Math.min(results.length - 1, i + 1)); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setIdx((i) => Math.max(0, i - 1)); }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); results[clampedIdx]?.run(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); onClose(); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center p-4 pt-24" role="dialog" aria-modal aria-label="Command palette">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} aria-hidden />
|
||||
<div className="relative w-full max-w-lg overflow-hidden rounded-lg border border-line bg-panel shadow-2xl">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={q}
|
||||
onChange={(e) => { setQ(e.target.value); setIdx(0); }}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Search pages, characters, notes…"
|
||||
aria-label="Command search"
|
||||
className="w-full border-b border-line bg-transparent px-4 py-3 text-sm text-ink placeholder:text-muted focus:outline-none"
|
||||
/>
|
||||
<ul className="max-h-80 overflow-auto py-1">
|
||||
{results.length === 0 ? (
|
||||
<li className="px-4 py-3 text-sm text-muted">No matches.</li>
|
||||
) : (
|
||||
results.map((c, i) => (
|
||||
<li key={c.id}>
|
||||
<button
|
||||
onMouseEnter={() => setIdx(i)}
|
||||
onClick={() => c.run()}
|
||||
className={cn('flex w-full items-center justify-between px-4 py-2 text-left text-sm', i === clampedIdx ? 'bg-elevated text-ink' : 'text-muted')}
|
||||
>
|
||||
<span className="truncate">{c.label}</span>
|
||||
{c.hint && <span className="shrink-0 text-xs text-muted">{c.hint}</span>}
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user