SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
5.8 KB · 138 lines tsx
Raw Blame History
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