'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { Search, CornerDownLeft } from 'lucide-react'; export interface SearchHit { type: 'cancer' | 'gene' | 'variant' | 'drug' | 'trial' | 'publication' | 'source'; id: string; title: string; subtitle?: string | null; href: string; match: 'exact' | 'alias' | 'prefix' | 'trigram' | 'identifier'; } const TYPE_LABEL: Record = { cancer: 'Cancer', gene: 'Gene', variant: 'Variant', drug: 'Drug', trial: 'Trial', publication: 'Publication', source: 'Source' }; /** * ⌘K command palette. Queries /api/search (own route handler, database-backed) with debounce. * Keyboard: ↑/↓ to move, Enter to open, Esc to close. Announces result count for screen readers. */ export function CommandPalette({ variant = 'button' }: { variant?: 'button' | 'hero' }) { const [open, setOpen] = useState(false); const [q, setQ] = useState(''); const [hits, setHits] = useState([]); const [active, setActive] = useState(0); const [loading, setLoading] = useState(false); const inputRef = useRef(null); const router = useRouter(); useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); setOpen((o) => !o); } else if (e.key === 'Escape') setOpen(false); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, []); useEffect(() => { if (open) setTimeout(() => inputRef.current?.focus(), 10); else { setQ(''); setHits([]); setActive(0); } }, [open]); useEffect(() => { if (!open) return; const term = q.trim(); if (term.length < 2) { setHits([]); return; } const ctrl = new AbortController(); const t = setTimeout(async () => { setLoading(true); try { const r = await fetch(`/api/search?q=${encodeURIComponent(term)}&limit=12`, { signal: ctrl.signal }); const j = (await r.json()) as { data: SearchHit[] }; setHits(j.data ?? []); setActive(0); } catch { /* aborted */ } finally { setLoading(false); } }, 120); return () => { clearTimeout(t); ctrl.abort(); }; }, [q, open]); const go = useCallback( (href: string) => { setOpen(false); router.push(href); }, [router], ); const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault(); setActive((a) => Math.min(hits.length - 1, a + 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((a) => Math.max(0, a - 1)); } else if (e.key === 'Enter') { e.preventDefault(); const h = hits[active]; if (h) go(h.href); else if (q.trim()) go(`/search?q=${encodeURIComponent(q.trim())}`); } }; return ( <> {variant === 'hero' ? ( ) : ( )} {open ? (
e.target === e.currentTarget && setOpen(false)} role="presentation">
setQ(e.target.value)} onKeyDown={onKeyDown} placeholder="Search cancers, genes, variants, drugs, trials, PMIDs…" className="w-full bg-transparent py-3 text-[15px] outline-none placeholder:text-ink-4" role="combobox" aria-expanded={hits.length > 0} aria-controls="ci-search-results" aria-activedescendant={hits[active] ? `ci-hit-${active}` : undefined} autoComplete="off" spellCheck={false} /> esc
    {hits.map((h, i) => (
  • setActive(i)} onClick={() => go(h.href)} className={`flex cursor-pointer items-center gap-3 border-b border-rule px-3 py-2 text-[14px] ${i === active ? 'bg-accent-soft' : ''}`} > {TYPE_LABEL[h.type]} {h.title} {h.subtitle ? {h.subtitle} : null} {h.match} {i === active ? : null}
  • ))} {q.trim().length >= 2 && !loading && hits.length === 0 ?
  • No matching entity. Press Enter for full-text search.
  • : null} {q.trim().length < 2 ?
  • Type at least two characters. Ordering: exact name, alias, prefix, then fuzzy match.
  • : null}

{hits.length} results

) : null} ); }