spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1'use client';23import { useEffect, useRef, useState } from 'react';4import { useRouter } from 'next/navigation';5import { Search, X, Plus } from 'lucide-react';67interface Hit {8 type: string;9 id: string;10 title: string;11 subtitle?: string | null;12 href: string;13 match: string;14}1516/**17 * Compare picker: adds/removes cancers by slug; the selection lives in the URL (?ids=a,b,c) so a comparison18 * is shareable. Uses the existing /api/search route (database-backed) and keeps only cancer hits.19 */20export function ComparePicker({ selected, max, min }: { selected: Array<{ slug: string; name: string }>; max: number; min: number }) {21 const router = useRouter();22 const [q, setQ] = useState('');23 const [hits, setHits] = useState<Hit[]>([]);24 const [loading, setLoading] = useState(false);25 const [open, setOpen] = useState(false);26 const box = useRef<HTMLDivElement>(null);2728 useEffect(() => {29 const term = q.trim();30 if (term.length < 2) {31 setHits([]);32 return;33 }34 const ctrl = new AbortController();35 const t = setTimeout(async () => {36 setLoading(true);37 try {38 const r = await fetch(`/api/search?q=${encodeURIComponent(term)}&limit=20`, { signal: ctrl.signal });39 const j = (await r.json()) as { data: Hit[] };40 const chosen = new Set(selected.map((s) => s.slug));41 setHits((j.data ?? []).filter((h) => h.type === 'cancer' && !chosen.has(h.href.replace(/^\/cancer\//, ''))).slice(0, 8));42 setOpen(true);43 } catch {44 /* aborted */45 } finally {46 setLoading(false);47 }48 }, 150);49 return () => {50 clearTimeout(t);51 ctrl.abort();52 };53 }, [q, selected]);5455 useEffect(() => {56 const onDoc = (e: MouseEvent) => {57 if (box.current && !box.current.contains(e.target as Node)) setOpen(false);58 };59 document.addEventListener('mousedown', onDoc);60 return () => document.removeEventListener('mousedown', onDoc);61 }, []);6263 const navigate = (slugs: string[]) => {64 router.push(slugs.length ? `/compare?ids=${slugs.map(encodeURIComponent).join(',')}` : '/compare');65 };66 const add = (href: string) => {67 const slug = href.replace(/^\/cancer\//, '');68 if (!slug || selected.some((s) => s.slug === slug) || selected.length >= max) return;69 setQ('');70 setHits([]);71 setOpen(false);72 navigate([...selected.map((s) => s.slug), slug]);73 };74 const remove = (slug: string) => navigate(selected.filter((s) => s.slug !== slug).map((s) => s.slug));75 const full = selected.length >= max;7677 return (78 <div className="border border-rule bg-paper-2 p-3">79 <div className="flex flex-wrap items-center gap-2">80 {selected.map((s) => (81 <span key={s.slug} className="inline-flex items-center gap-1 border border-rule-strong bg-paper px-2 py-1 text-[13px]">82 {s.name}83 <button type="button" onClick={() => remove(s.slug)} aria-label={`Remove ${s.name} from the comparison`} className="ml-0.5 text-ink-3 hover:text-danger">84 <X className="h-3.5 w-3.5" aria-hidden />85 </button>86 </span>87 ))}88 {selected.length === 0 ? <span className="text-[13px] text-ink-3">No cancer selected yet.</span> : null}89 </div>90 <div ref={box} className="relative mt-2">91 <label className="sr-only" htmlFor="compare-picker-input">92 Add a cancer to the comparison93 </label>94 <div className={`flex items-center gap-2 border bg-paper px-2.5 ${full ? 'border-rule text-ink-4' : 'border-rule-strong focus-within:border-accent'}`}>95 {full ? <Plus className="h-4 w-4 shrink-0" aria-hidden /> : <Search className="h-4 w-4 shrink-0 text-ink-3" aria-hidden />}96 <input97 id="compare-picker-input"98 value={q}99 onChange={(e) => setQ(e.target.value)}100 onFocus={() => hits.length && setOpen(true)}101 onKeyDown={(e) => {102 if (e.key === 'Enter' && hits[0]) {103 e.preventDefault();104 add(hits[0].href);105 } else if (e.key === 'Escape') setOpen(false);106 }}107 disabled={full}108 placeholder={full ? `Maximum ${max} cancers — remove one to add another` : `Add a cancer (${selected.length}/${max}) — e.g. glioblastoma, pancreatic, KRAS…`}109 className="w-full bg-transparent py-2 text-[14px] outline-none placeholder:text-ink-4 disabled:cursor-not-allowed"110 autoComplete="off"111 spellCheck={false}112 role="combobox"113 aria-expanded={open && hits.length > 0}114 aria-controls="compare-picker-results"115 />116 </div>117 {open && q.trim().length >= 2 ? (118 <ul id="compare-picker-results" role="listbox" className="absolute left-0 right-0 z-20 mt-1 max-h-72 overflow-y-auto border border-rule-strong bg-paper shadow-lg">119 {hits.map((h) => (120 <li key={h.id} role="option" aria-selected={false}>121 <button type="button" onClick={() => add(h.href)} className="flex w-full items-baseline justify-between gap-2 px-3 py-2 text-left text-[13.5px] hover:bg-accent-soft">122 <span className="min-w-0 truncate">{h.title}</span>123 {h.subtitle ? <span className="shrink-0 text-[11.5px] text-ink-3">{h.subtitle}</span> : null}124 </button>125 </li>126 ))}127 {!loading && hits.length === 0 ? <li className="px-3 py-2 text-[13px] text-ink-3">No matching cancer entity.</li> : null}128 {loading ? <li className="px-3 py-2 text-[13px] text-ink-3">Searching…</li> : null}129 </ul>130 ) : null}131 </div>132 <p className="mt-1.5 text-[11.5px] text-ink-3">133 Compare {min} to {max} cancers. The selection is in the URL — copy it to share this comparison.134 </p>135 </div>136 );137}138