SPB Git forge
3commits 1branches 0releases
417.0 KBsize
maindefault branch
10 days agolast push
TypeScript 66.5% Python 30.9% JavaScript 1.4% CSS 0.7%
7.0 KB · 170 lines tsx
Raw Blame History
1'use client';2import { ArrowRight, Globe2, Rocket, Satellite, Search, X } from 'lucide-react';3import Link from 'next/link';4import { useRouter } from 'next/navigation';5import { useEffect, useRef, useState } from 'react';6import { clientApi } from '@/lib/client-api';7import { cn } from '@/lib/cn';8import { routes } from '@/lib/site';9import type { SearchResult } from '@/lib/types';10import { useSearch } from './search-context';1112const EXAMPLES = ['ISS', 'Starlink', '25544', '1998-067A', 'SpaceX', 'Canada', 'GPS', 'weather satellites', 'GEO'];1314function Icon({ type }: { type: SearchResult['entity_type'] }) {15  const cls = 'size-4 shrink-0 text-ink-3';16  if (type === 'satellite') return <Satellite className={cls} aria-hidden />;17  if (type === 'launch' || type === 'launch_site') return <Rocket className={cls} aria-hidden />;18  return <Globe2 className={cls} aria-hidden />;19}2021/** Command-palette style global search (⌘K or /). Same-origin API; keyboard navigable; mobile full-screen sheet. */22export function SearchDialog() {23  const { open, setOpen } = useSearch();24  const router = useRouter();25  const [q, setQ] = useState('');26  const [results, setResults] = useState<SearchResult[]>([]);27  const [shortcuts, setShortcuts] = useState<{ label: string; href: string }[]>([]);28  const [active, setActive] = useState(0);29  const [loading, setLoading] = useState(false);30  const inputRef = useRef<HTMLInputElement>(null);3132  useEffect(() => {33    if (open) {34      setTimeout(() => inputRef.current?.focus(), 20);35      document.body.style.overflow = 'hidden';36    } else {37      document.body.style.overflow = '';38      setQ('');39      setResults([]);40      setShortcuts([]);41    }42    return () => {43      document.body.style.overflow = '';44    };45  }, [open]);4647  useEffect(() => {48    if (!open) return;49    const term = q.trim();50    if (term.length < 1) {51      setResults([]);52      setShortcuts([]);53      return;54    }55    const ctrl = new AbortController();56    const t = setTimeout(async () => {57      setLoading(true);58      try {59        const res = await clientApi.search(term, 12, ctrl.signal);60        setResults(res.data.results);61        setShortcuts(res.data.shortcuts);62        setActive(0);63      } catch {64        /* aborted or unavailable */65      } finally {66        setLoading(false);67      }68    }, 140);69    return () => {70      clearTimeout(t);71      ctrl.abort();72    };73  }, [q, open]);7475  if (!open) return null;76  const items = [...shortcuts.map((s) => ({ href: s.href, title: s.label, subtitle: 'Filter', entity_type: 'filter' as const })), ...results];7778  const onKey = (e: React.KeyboardEvent) => {79    if (e.key === 'ArrowDown') {80      e.preventDefault();81      setActive((a) => Math.min(a + 1, items.length - 1));82    } else if (e.key === 'ArrowUp') {83      e.preventDefault();84      setActive((a) => Math.max(a - 1, 0));85    } else if (e.key === 'Enter') {86      e.preventDefault();87      const it = items[active];88      if (it) {89        router.push(it.href);90        setOpen(false);91      } else if (q.trim()) {92        router.push(routes.search(q.trim()));93        setOpen(false);94      }95    } else if (e.key === 'Escape') {96      setOpen(false);97    }98  };99100  return (101    <div className="fixed inset-0 z-[100] flex items-start justify-center bg-black/60 backdrop-blur-sm md:pt-[12vh]" role="dialog" aria-modal="true" aria-label="Search SatelliteIndex" onClick={() => setOpen(false)}>102      <div className="panel flex h-[100dvh] w-full flex-col overflow-hidden md:h-auto md:max-h-[70vh] md:w-[640px] md:rounded-xl" onClick={(e) => e.stopPropagation()}>103        <div className="flex items-center gap-3 border-b border-rule px-4 py-3">104          <Search className="size-5 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 satellites, NORAD, COSPAR, operators, countries…"111            className="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          />116          <button type="button" onClick={() => setOpen(false)} className="flex size-9 items-center justify-center rounded-md text-ink-3 hover:bg-plane-2 hover:text-ink" aria-label="Close search">117            <X className="size-5" aria-hidden />118          </button>119        </div>120        <div className="scrollbar-thin flex-1 overflow-y-auto">121          {q.trim() === '' ? (122            <div className="px-4 py-4">123              <p className="eyebrow mb-2">Try</p>124              <div className="flex flex-wrap gap-2">125                {EXAMPLES.map((ex) => (126                  <button key={ex} type="button" onClick={() => setQ(ex)} className="rounded-full border border-rule bg-plane-2 px-3 py-1.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink">127                    {ex}128                  </button>129                ))}130              </div>131              <p className="mt-6 text-xs text-ink-3">132                Press <kbd className="mono rounded border border-rule px-1">↵</kbd> for full results · <kbd className="mono rounded border border-rule px-1">esc</kbd> to close133              </p>134            </div>135          ) : items.length === 0 && !loading ? (136            <div className="px-4 py-8 text-center text-sm text-ink-3">No results for “{q}”. <Link href={routes.search(q)} onClick={() => setOpen(false)} className="link">Open full search</Link></div>137          ) : (138            <ul className="py-2">139              {items.map((it, i) => (140                <li key={`${it.entity_type}-${it.href}`}>141                  <Link142                    href={it.href}143                    onClick={() => setOpen(false)}144                    onMouseEnter={() => setActive(i)}145                    className={cn('flex min-h-[48px] items-center gap-3 px-4 py-2.5', i === active ? 'bg-plane-3' : 'hover:bg-plane-2')}146                  >147                    {it.entity_type === 'filter' ? <ArrowRight className="size-4 shrink-0 text-accent" aria-hidden /> : <Icon type={it.entity_type} />}148                    <span className="min-w-0 flex-1">149                      <span className="block truncate text-[15px] text-ink">{it.title}</span>150                      {it.subtitle && <span className="block truncate text-xs text-ink-3">{it.subtitle}</span>}151                    </span>152                    <span className="eyebrow hidden md:block">{it.entity_type.replace('_', ' ')}</span>153                  </Link>154                </li>155              ))}156              {q.trim() && (157                <li>158                  <Link href={routes.search(q.trim())} onClick={() => setOpen(false)} className="flex min-h-[44px] items-center gap-3 px-4 py-2 text-sm text-accent hover:bg-plane-2">159                    <Search className="size-4" aria-hidden /> All results for “{q.trim()}”160                  </Link>161                </li>162              )}163            </ul>164          )}165        </div>166      </div>167    </div>168  );169}170