spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1'use client';23import { useCallback, useEffect, useRef, useState } from 'react';4import { useRouter } from 'next/navigation';5import { Search, CornerDownLeft } from 'lucide-react';67export interface SearchHit {8 type: 'cancer' | 'gene' | 'variant' | 'drug' | 'trial' | 'publication' | 'source';9 id: string;10 title: string;11 subtitle?: string | null;12 href: string;13 match: 'exact' | 'alias' | 'prefix' | 'trigram' | 'identifier';14}1516const TYPE_LABEL: Record<SearchHit['type'], string> = { cancer: 'Cancer', gene: 'Gene', variant: 'Variant', drug: 'Drug', trial: 'Trial', publication: 'Publication', source: 'Source' };1718/**19 * ⌘K command palette. Queries /api/search (own route handler, database-backed) with debounce.20 * Keyboard: ↑/↓ to move, Enter to open, Esc to close. Announces result count for screen readers.21 */22export function CommandPalette({ variant = 'button' }: { variant?: 'button' | 'hero' }) {23 const [open, setOpen] = useState(false);24 const [q, setQ] = useState('');25 const [hits, setHits] = useState<SearchHit[]>([]);26 const [active, setActive] = useState(0);27 const [loading, setLoading] = useState(false);28 const inputRef = useRef<HTMLInputElement>(null);29 const router = useRouter();3031 useEffect(() => {32 const onKey = (e: KeyboardEvent) => {33 if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {34 e.preventDefault();35 setOpen((o) => !o);36 } else if (e.key === 'Escape') setOpen(false);37 };38 window.addEventListener('keydown', onKey);39 return () => window.removeEventListener('keydown', onKey);40 }, []);4142 useEffect(() => {43 if (open) setTimeout(() => inputRef.current?.focus(), 10);44 else {45 setQ('');46 setHits([]);47 setActive(0);48 }49 }, [open]);5051 useEffect(() => {52 if (!open) return;53 const term = q.trim();54 if (term.length < 2) {55 setHits([]);56 return;57 }58 const ctrl = new AbortController();59 const t = setTimeout(async () => {60 setLoading(true);61 try {62 const r = await fetch(`/api/search?q=${encodeURIComponent(term)}&limit=12`, { signal: ctrl.signal });63 const j = (await r.json()) as { data: SearchHit[] };64 setHits(j.data ?? []);65 setActive(0);66 } catch {67 /* aborted */68 } finally {69 setLoading(false);70 }71 }, 120);72 return () => {73 clearTimeout(t);74 ctrl.abort();75 };76 }, [q, open]);7778 const go = useCallback(79 (href: string) => {80 setOpen(false);81 router.push(href);82 },83 [router],84 );8586 const onKeyDown = (e: React.KeyboardEvent) => {87 if (e.key === 'ArrowDown') {88 e.preventDefault();89 setActive((a) => Math.min(hits.length - 1, a + 1));90 } else if (e.key === 'ArrowUp') {91 e.preventDefault();92 setActive((a) => Math.max(0, a - 1));93 } else if (e.key === 'Enter') {94 e.preventDefault();95 const h = hits[active];96 if (h) go(h.href);97 else if (q.trim()) go(`/search?q=${encodeURIComponent(q.trim())}`);98 }99 };100101 return (102 <>103 {variant === 'hero' ? (104 <button type="button" onClick={() => setOpen(true)} className="flex w-full items-center gap-3 border border-rule-strong bg-white px-4 py-3 text-left text-[15px] text-ink-3 hover:border-accent focus:border-accent">105 <Search className="h-4 w-4 shrink-0" aria-hidden />106 <span className="flex-1">Search a cancer, gene, variant, drug, trial or PMID…</span>107 <kbd className="ci-mono hidden rounded-sm border border-rule px-1.5 text-[11px] text-ink-3 sm:inline">⌘K</kbd>108 </button>109 ) : (110 <button type="button" onClick={() => setOpen(true)} className="inline-flex items-center gap-2 border border-rule px-2.5 py-1.5 text-[13px] text-ink-2 hover:border-accent hover:text-accent" aria-label="Search (Command K)">111 <Search className="h-3.5 w-3.5" aria-hidden />112 <span className="hidden sm:inline">Search</span>113 <kbd className="ci-mono hidden rounded-sm border border-rule px-1 text-[10.5px] text-ink-3 md:inline">⌘K</kbd>114 </button>115 )}116 {open ? (117 <div className="fixed inset-0 z-[100] bg-ink/30 p-3 sm:p-[10vh]" onMouseDown={(e) => e.target === e.currentTarget && setOpen(false)} role="presentation">118 <div role="dialog" aria-modal="true" aria-label="Search CancerIndex" className="mx-auto w-full max-w-2xl border border-rule-strong bg-paper shadow-2xl">119 <div className="flex items-center gap-2 border-b border-rule px-3">120 <Search className="h-4 w-4 text-ink-3" aria-hidden />121 <input122 ref={inputRef}123 value={q}124 onChange={(e) => setQ(e.target.value)}125 onKeyDown={onKeyDown}126 placeholder="Search cancers, genes, variants, drugs, trials, PMIDs…"127 className="w-full bg-transparent py-3 text-[15px] outline-none placeholder:text-ink-4"128 role="combobox"129 aria-expanded={hits.length > 0}130 aria-controls="ci-search-results"131 aria-activedescendant={hits[active] ? `ci-hit-${active}` : undefined}132 autoComplete="off"133 spellCheck={false}134 />135 <kbd className="ci-mono rounded-sm border border-rule px-1 text-[10.5px] text-ink-3">esc</kbd>136 </div>137 <ul id="ci-search-results" role="listbox" className="max-h-[60vh] overflow-y-auto">138 {hits.map((h, i) => (139 <li140 key={`${h.type}-${h.id}`}141 id={`ci-hit-${i}`}142 role="option"143 aria-selected={i === active}144 onMouseEnter={() => setActive(i)}145 onClick={() => go(h.href)}146 className={`flex cursor-pointer items-center gap-3 border-b border-rule px-3 py-2 text-[14px] ${i === active ? 'bg-accent-soft' : ''}`}147 >148 <span className="ci-kicker w-20 shrink-0">{TYPE_LABEL[h.type]}</span>149 <span className="min-w-0 flex-1">150 <span className="block truncate">{h.title}</span>151 {h.subtitle ? <span className="block truncate text-[12px] text-ink-3">{h.subtitle}</span> : null}152 </span>153 <span className="ci-mono hidden text-[10.5px] text-ink-4 sm:inline">{h.match}</span>154 {i === active ? <CornerDownLeft className="h-3.5 w-3.5 text-ink-3" aria-hidden /> : null}155 </li>156 ))}157 {q.trim().length >= 2 && !loading && hits.length === 0 ? <li className="px-3 py-4 text-[13.5px] text-ink-3">No matching entity. Press Enter for full-text search.</li> : null}158 {q.trim().length < 2 ? <li className="px-3 py-4 text-[13px] text-ink-3">Type at least two characters. Ordering: exact name, alias, prefix, then fuzzy match.</li> : null}159 </ul>160 <p className="sr-only" aria-live="polite">161 {hits.length} results162 </p>163 </div>164 </div>165 ) : null}166 </>167 );168}169