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