feat(frontend): ⌘K search palette modal

This commit is contained in:
2026-05-21 07:13:50 +02:00
parent 5754bec679
commit 4b9ff4b783

View File

@@ -0,0 +1,155 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { searchApi, type SearchResult } from '../api/search.js';
export function SearchPalette({ open, onClose }: { open: boolean; onClose: () => void }) {
const [q, setQ] = useState('');
const [result, setResult] = useState<SearchResult>({ lessons: [], cards: [] });
const [busy, setBusy] = useState(false);
const [activeIdx, setActiveIdx] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
useEffect(() => {
if (open) {
setQ(''); setResult({ lessons: [], cards: [] }); setActiveIdx(0);
setTimeout(() => inputRef.current?.focus(), 10);
}
}, [open]);
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onClose(); }
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
useEffect(() => {
if (q.trim().length < 2) { setResult({ lessons: [], cards: [] }); return; }
setBusy(true);
const t = setTimeout(async () => {
try {
const r = await searchApi.search(q.trim());
setResult(r); setActiveIdx(0);
} finally { setBusy(false); }
}, 200);
return () => clearTimeout(t);
}, [q]);
const flat = [
...result.lessons.map((l) => ({ kind: 'lesson' as const, item: l })),
...result.cards.map((c) => ({ kind: 'card' as const, item: c })),
];
function selectAt(i: number) {
const it = flat[i];
if (!it) return;
if (it.kind === 'lesson') navigate(`/lessons/${it.item.id}`);
else navigate(`/lessons/${it.item.lessonId}#card-${it.item.id}`);
onClose();
}
function onInputKey(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIdx((i) => Math.min(flat.length - 1, i + 1)); }
if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIdx((i) => Math.max(0, i - 1)); }
if (e.key === 'Enter') { e.preventDefault(); selectAt(activeIdx); }
}
if (!open) return null;
const libraryLessons = result.lessons.filter((l) => l.location === 'library');
const marketLessons = result.lessons.filter((l) => l.location === 'marketplace');
const idxOfLesson = (id: number) => flat.findIndex((f) => f.kind === 'lesson' && f.item.id === id);
const idxOfCard = (id: number) => flat.findIndex((f) => f.kind === 'card' && f.item.id === id);
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/40 p-4 pt-24 backdrop-blur-sm"
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: -8 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95 }}
transition={{ type: 'spring', stiffness: 240, damping: 22 }}
className="w-full max-w-2xl overflow-hidden rounded-3xl border border-white/60 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900/95"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-brand-100 px-4 py-3 dark:border-slate-800">
<span className="text-slate-400">🔎</span>
<input
ref={inputRef}
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={onInputKey}
className="flex-1 bg-transparent text-base outline-none"
placeholder="Zoek lessen en kaarten…"
/>
{busy && <span className="text-xs text-slate-400"></span>}
<kbd className="rounded-md border border-slate-300 px-1.5 py-0.5 text-[10px] text-slate-500 dark:border-slate-700">Esc</kbd>
</div>
<div className="max-h-[60vh] overflow-y-auto">
{q.trim().length < 2 && (
<div className="p-8 text-center text-sm text-slate-500">Begin met typen om te zoeken (min. 2 tekens)</div>
)}
{q.trim().length >= 2 && flat.length === 0 && !busy && (
<div className="p-8 text-center text-sm text-slate-500">Geen resultaten</div>
)}
{libraryLessons.length > 0 && (
<Group title="Lessen — Jouw bibliotheek" tone="brand">
{libraryLessons.map((l) => (
<Row key={`l-${l.id}`} active={idxOfLesson(l.id) === activeIdx} onClick={() => selectAt(idxOfLesson(l.id))}>
<div className="font-medium">{l.name}</div>
<div className="text-xs text-slate-500">door {l.ownerDisplayName} · {l.totalCards} kaarten</div>
</Row>
))}
</Group>
)}
{marketLessons.length > 0 && (
<Group title="Lessen — Marketplace" tone="brand">
{marketLessons.map((l) => (
<Row key={`m-${l.id}`} active={idxOfLesson(l.id) === activeIdx} onClick={() => selectAt(idxOfLesson(l.id))}>
<div className="font-medium">{l.name}{l.isCurated ? ' ⭐' : ''}</div>
<div className="text-xs text-slate-500">door {l.ownerDisplayName} · {l.totalCards} kaarten</div>
</Row>
))}
</Group>
)}
{result.cards.length > 0 && (
<Group title="Kaarten" tone="success">
{result.cards.map((c) => (
<Row key={`c-${c.id}`} active={idxOfCard(c.id) === activeIdx} onClick={() => selectAt(idxOfCard(c.id))}>
<div className="font-medium">{c.question}</div>
<div className="text-xs text-slate-500">{c.lessonName} {c.snippet}</div>
</Row>
))}
</Group>
)}
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}
function Group({ title, tone, children }: { title: string; tone: 'brand' | 'success'; children: React.ReactNode }) {
const toneCls = tone === 'brand' ? 'text-brand-700 dark:text-brand-200' : 'text-success-700 dark:text-success-400';
return (
<div>
<div className={`px-4 pt-3 pb-1 text-[10px] font-semibold uppercase tracking-wider ${toneCls}`}>{title}</div>
<ul className="pb-1">{children}</ul>
</div>
);
}
function Row({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<li
className={`cursor-pointer px-4 py-2 ${active ? 'bg-brand-50 dark:bg-brand-900/30' : 'hover:bg-brand-50/60 dark:hover:bg-slate-800/40'}`}
onClick={onClick}
>
{children}
</li>
);
}