feat(frontend): ⌘K search palette modal
This commit is contained in:
155
packages/frontend/src/components/SearchPalette.tsx
Normal file
155
packages/frontend/src/components/SearchPalette.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user