SPB Git

spb/ai-atlas

Public
7.0 KB · 166 lines tsx
1'use client';2import { ArrowRight, CornerDownLeft, Search, X } from 'lucide-react';3import Link from 'next/link';4import { useRouter } from 'next/navigation';5import { useEffect, useRef, useState } from 'react';6import { EntityBadge } from '@/components/ui/badges';7import { clientApi } from '@/lib/client-api';8import { cn } from '@/lib/cn';9import { EXAMPLE_QUERIES, exploreNav, routes } from '@/lib/site';10import type { Suggestion } from '@/lib/types';11import { useSearch } from './search-context';1213/** Command-palette global search (⌘K or /): prefix suggestions from /search/suggest; Enter → /search?q=. Mobile: full-screen sheet. */14export function SearchDialog() {15  const { open, setOpen } = useSearch();16  const router = useRouter();17  const [q, setQ] = useState('');18  const [items, setItems] = useState<Suggestion[]>([]);19  const [active, setActive] = useState(0);20  const [loading, setLoading] = useState(false);21  const [failed, setFailed] = useState(false);22  const inputRef = useRef<HTMLInputElement>(null);2324  useEffect(() => {25    if (open) {26      setTimeout(() => inputRef.current?.focus(), 20);27      document.body.style.overflow = 'hidden';28    } else {29      document.body.style.overflow = '';30      setQ('');31      setItems([]);32      setFailed(false);33    }34    return () => {35      document.body.style.overflow = '';36    };37  }, [open]);3839  useEffect(() => {40    if (!open) return;41    const term = q.trim();42    if (term.length < 1) {43      setItems([]);44      return;45    }46    const ctrl = new AbortController();47    const t = setTimeout(async () => {48      setLoading(true);49      try {50        const res = await clientApi.suggest(term, ctrl.signal);51        setItems(res.items ?? []);52        setFailed(false);53        setActive(0);54      } catch (e) {55        if ((e as Error).name !== 'AbortError') setFailed(true);56      } finally {57        setLoading(false);58      }59    }, 120);60    return () => {61      clearTimeout(t);62      ctrl.abort();63    };64  }, [q, open]);6566  if (!open) return null;67  const rows: { key: string; href: string; node: React.ReactNode }[] = items.map((s) => ({68    key: s.id,69    href: routes.entity(s),70    node: (71      <>72        <EntityBadge type={s.entity_type} small />73        <span className="min-w-0 flex-1 truncate text-[15px] text-ink">{s.name}</span>74        {s.organization_name && <span className="hidden truncate text-xs text-ink-3 sm:block">{s.organization_name}</span>}75      </>76    ),77  }));78  const term = q.trim();79  const allRows = term ? [...rows, { key: '__all', href: routes.search(term), node: (<><Search className="size-4 text-accent" aria-hidden /><span className="flex-1 text-sm text-accent">Search everything for “{term}”</span><CornerDownLeft className="size-3.5 text-ink-3" aria-hidden /></>) }] : rows;8081  const go = (href: string) => {82    router.push(href);83    setOpen(false);84  };85  const onKey = (e: React.KeyboardEvent) => {86    if (e.key === 'ArrowDown') {87      e.preventDefault();88      setActive((a) => Math.min(a + 1, allRows.length - 1));89    } else if (e.key === 'ArrowUp') {90      e.preventDefault();91      setActive((a) => Math.max(a - 1, 0));92    } else if (e.key === 'Enter') {93      e.preventDefault();94      const it = allRows[active];95      if (it) go(it.href);96      else if (term) go(routes.search(term));97    } else if (e.key === 'Escape') setOpen(false);98  };99100  return (101    <div className="fixed inset-0 z-[100] flex items-start justify-center bg-black/50 backdrop-blur-[2px] md:pt-[12vh]" role="dialog" aria-modal="true" aria-label="Search AI Atlas" onClick={() => setOpen(false)}>102      <div className="panel flex h-[100dvh] w-full flex-col overflow-hidden rounded-none md:h-auto md:max-h-[70vh] md:w-[680px] md:rounded-lg" onClick={(e) => e.stopPropagation()}>103        <div className="flex items-center gap-3 border-b border-rule px-4 py-2.5">104          <Search className="size-5 shrink-0 text-ink-3" aria-hidden />105          <input106            ref={inputRef}107            value={q}108            onChange={(e) => setQ(e.target.value)}109            onKeyDown={onKey}110            placeholder="Search models, companies, papers, benchmarks… or ask in plain English"111            className="h-11 min-w-0 flex-1 bg-transparent text-[16px] text-ink placeholder:text-ink-3 focus:outline-none"112            autoComplete="off"113            spellCheck={false}114            aria-label="Search"115            aria-activedescendant={allRows[active] ? `sugg-${allRows[active].key}` : undefined}116          />117          <button type="button" onClick={() => setOpen(false)} className="flex size-10 items-center justify-center rounded-sm text-ink-3 hover:bg-surface-2 hover:text-ink" aria-label="Close search">118            <X className="size-5" aria-hidden />119          </button>120        </div>121        <div className="scrollbar-thin flex-1 overflow-y-auto">122          {term === '' ? (123            <div className="px-4 py-4">124              <p className="eyebrow mb-2">Try asking</p>125              <ul className="flex flex-wrap gap-2">126                {EXAMPLE_QUERIES.map((ex) => (127                  <li key={ex}>128                    <button type="button" onClick={() => setQ(ex)} className="border border-rule bg-surface px-2.5 py-1.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink">129                      {ex}130                    </button>131                  </li>132                ))}133              </ul>134              <p className="eyebrow mt-6 mb-2">Browse</p>135              <ul className="grid grid-cols-2 gap-x-4 sm:grid-cols-3">136                {exploreNav.map((n) => (137                  <li key={n.href}>138                    <Link href={n.href} onClick={() => setOpen(false)} className="flex h-10 items-center gap-2 text-sm text-ink-2 hover:text-accent">139                      <ArrowRight className="size-3.5" aria-hidden /> {n.label}140                    </Link>141                  </li>142                ))}143              </ul>144              <p className="mt-6 text-xs text-ink-3">145                <kbd className="mono border border-rule px-1">↵</kbd> full results · <kbd className="mono border border-rule px-1">esc</kbd> close · <kbd className="mono border border-rule px-1">⌘K</kbd> anywhere146              </p>147            </div>148          ) : (149            <ul className="py-1" role="listbox">150              {failed && <li className="px-4 py-2 text-xs text-warning">Suggestions unavailable — press Enter to search.</li>}151              {!loading && !failed && rows.length === 0 && <li className="px-4 py-3 text-sm text-ink-3">No direct match — search everything below.</li>}152              {allRows.map((r, i) => (153                <li key={r.key} id={`sugg-${r.key}`} role="option" aria-selected={i === active}>154                  <Link href={r.href} onClick={() => setOpen(false)} onMouseEnter={() => setActive(i)} className={cn('flex min-h-[46px] items-center gap-3 px-4 py-2', i === active ? 'bg-surface-3' : 'hover:bg-surface-2')}>155                    {r.node}156                  </Link>157                </li>158              ))}159            </ul>160          )}161        </div>162      </div>163    </div>164  );165}166
Files · 9f1db171c786cf4a3b99436329f29b145c02aeec
Menu

Theme