Files
ttrpg_manager/src/components/CommandPalette.tsx
T
NilsBriggen 0930136c46 Phase 11: data-driven Assistant
- Deterministic advisors (src/lib/assistant): party resources (downed/bloodied/
  spent slots/rest nudges), session prep (dangling [[wiki links]], active quests,
  empty journal), live combat hints (turn, downed combatants) — pure + tested
- Data-driven encounter builder: greedily assembles level/CR-appropriate monsters
  to a target difficulty via the Phase-3 budget; one click builds + opens combat
- Assistant page: suggestion cards with one-click actions (goto, long rest,
  create note); wired into routes, dashboard, command palette
- 8 advisor/builder unit tests + assistant e2e

Conversational LLM layer intentionally deferred (no provider/key in this env);
the deterministic advisor is the offline default per the plan.

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

112 lines
4.9 KiB
TypeScript

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: 'Assistant', to: '/assistant' },
{ 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>
);
}