From 4b9ff4b783c786744650d094d75a07cec18c6251 Mon Sep 17 00:00:00 2001 From: Bert Hausmans Date: Thu, 21 May 2026 07:13:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E2=8C=98K=20search=20palette?= =?UTF-8?q?=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../frontend/src/components/SearchPalette.tsx | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 packages/frontend/src/components/SearchPalette.tsx diff --git a/packages/frontend/src/components/SearchPalette.tsx b/packages/frontend/src/components/SearchPalette.tsx new file mode 100644 index 0000000..4447693 --- /dev/null +++ b/packages/frontend/src/components/SearchPalette.tsx @@ -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({ lessons: [], cards: [] }); + const [busy, setBusy] = useState(false); + const [activeIdx, setActiveIdx] = useState(0); + const inputRef = useRef(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) { + 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 ( + + + e.stopPropagation()} + > +
+ 🔎 + setQ(e.target.value)} + onKeyDown={onInputKey} + className="flex-1 bg-transparent text-base outline-none" + placeholder="Zoek lessen en kaarten…" + /> + {busy && } + Esc +
+
+ {q.trim().length < 2 && ( +
Begin met typen om te zoeken (min. 2 tekens)
+ )} + {q.trim().length >= 2 && flat.length === 0 && !busy && ( +
Geen resultaten
+ )} + {libraryLessons.length > 0 && ( + + {libraryLessons.map((l) => ( + selectAt(idxOfLesson(l.id))}> +
{l.name}
+
door {l.ownerDisplayName} · {l.totalCards} kaarten
+
+ ))} +
+ )} + {marketLessons.length > 0 && ( + + {marketLessons.map((l) => ( + selectAt(idxOfLesson(l.id))}> +
{l.name}{l.isCurated ? ' ⭐' : ''}
+
door {l.ownerDisplayName} · {l.totalCards} kaarten
+
+ ))} +
+ )} + {result.cards.length > 0 && ( + + {result.cards.map((c) => ( + selectAt(idxOfCard(c.id))}> +
{c.question}
+
{c.lessonName} — {c.snippet}
+
+ ))} +
+ )} +
+
+
+
+ ); +} + +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 ( +
+
{title}
+
    {children}
+
+ ); +} + +function Row({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) { + return ( +
  • + {children} +
  • + ); +}