0930136c46
- 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>
112 lines
4.9 KiB
TypeScript
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>
|
|
);
|
|
}
|