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%
5.2 KB · 131 lines tsx
Raw Blame History
1'use client';2/** Search-to-focus: type a name / NORAD / COSPAR, pick a satellite, the globe flies to its live position. */3import { Loader2, Search, X } from 'lucide-react';4import { useEffect, useRef, useState } from 'react';5import { clientApi } from '@/lib/client-api';6import { cn } from '@/lib/cn';7import type { SearchResult } from '@/lib/types';89export interface FocusTarget {10  norad: number;11  name: string;12  slug: string;13}1415function noradFrom(r: SearchResult): number | null {16  const m = /NORAD\s+(\d+)/i.exec(r.subtitle ?? '');17  if (m) return Number(m[1]);18  const tail = /-(\d+)$/.exec(r.slug);19  return tail ? Number(tail[1]) : null;20}2122export function FocusSearch({ onPick, current, onClear, className }: { onPick: (t: FocusTarget) => void; current: FocusTarget | null; onClear: () => void; className?: string }) {23  const [q, setQ] = useState('');24  const [open, setOpen] = useState(false);25  const [loading, setLoading] = useState(false);26  const [items, setItems] = useState<{ r: SearchResult; norad: number }[]>([]);27  const [active, setActive] = useState(0);28  const boxRef = useRef<HTMLDivElement>(null);2930  useEffect(() => {31    const term = q.trim();32    if (term.length < 1) {33      setItems([]);34      return;35    }36    const ctrl = new AbortController();37    const t = setTimeout(async () => {38      setLoading(true);39      try {40        const res = await clientApi.search(term, 12, ctrl.signal);41        const sats = res.data.results42          .filter((r) => r.entity_type === 'satellite')43          .map((r) => ({ r, norad: noradFrom(r) }))44          .filter((x): x is { r: SearchResult; norad: number } => x.norad !== null);45        setItems(sats);46        setActive(0);47        setOpen(true);48      } catch {49        /* aborted */50      } finally {51        setLoading(false);52      }53    }, 160);54    return () => {55      clearTimeout(t);56      ctrl.abort();57    };58  }, [q]);5960  useEffect(() => {61    const onDoc = (e: PointerEvent) => {62      if (!boxRef.current?.contains(e.target as Node)) setOpen(false);63    };64    document.addEventListener('pointerdown', onDoc);65    return () => document.removeEventListener('pointerdown', onDoc);66  }, []);6768  const pick = (it: { r: SearchResult; norad: number }) => {69    onPick({ norad: it.norad, name: it.r.title, slug: it.r.slug });70    setQ('');71    setItems([]);72    setOpen(false);73  };7475  return (76    <div ref={boxRef} className={cn('relative', className)}>77      <div className="flex h-11 items-center gap-2 rounded-md border border-rule bg-plane/85 px-3 backdrop-blur-md focus-within:border-rule-strong">78        {loading ? <Loader2 className="size-4 animate-spin text-ink-3" aria-hidden /> : <Search className="size-4 text-ink-3" aria-hidden />}79        {current && !q ? (80          <div className="flex min-w-0 flex-1 items-center gap-2">81            <span className="truncate text-sm text-ink">{current.name}</span>82            <span className="mono shrink-0 text-2xs text-ink-3">{current.norad}</span>83            <button type="button" onClick={onClear} className="ml-auto flex size-8 shrink-0 items-center justify-center rounded text-ink-3 hover:text-ink" aria-label="Clear focus">84              <X className="size-4" aria-hidden />85            </button>86          </div>87        ) : (88          <input89            value={q}90            onChange={(e) => setQ(e.target.value)}91            onFocus={() => items.length && setOpen(true)}92            onKeyDown={(e) => {93              if (e.key === 'ArrowDown') {94                e.preventDefault();95                setActive((a) => Math.min(a + 1, items.length - 1));96              } else if (e.key === 'ArrowUp') {97                e.preventDefault();98                setActive((a) => Math.max(a - 1, 0));99              } else if (e.key === 'Enter') {100                const it = items[active];101                if (it) pick(it);102              } else if (e.key === 'Escape') setOpen(false);103            }}104            placeholder="Focus a satellite: name, NORAD, COSPAR…"105            className="min-w-0 flex-1 bg-transparent text-[16px] text-ink placeholder:text-ink-3 focus:outline-none md:text-sm"106            aria-label="Search a satellite to focus"107            autoComplete="off"108            spellCheck={false}109          />110        )}111      </div>112      {open && q.trim() && (113        <ul className="panel absolute left-0 right-0 top-full z-20 mt-1 max-h-[46vh] overflow-y-auto py-1" role="listbox">114          {items.length === 0 && !loading && <li className="px-3 py-3 text-sm text-ink-3">No tracked satellite matches “{q.trim()}”.</li>}115          {items.map((it, i) => (116            <li key={it.r.entity_id} role="option" aria-selected={i === active}>117              <button type="button" onMouseEnter={() => setActive(i)} onClick={() => pick(it)} className={cn('flex min-h-[44px] w-full items-center gap-3 px-3 py-2 text-left', i === active ? 'bg-plane-3' : 'hover:bg-plane-2')}>118                <span className="min-w-0 flex-1">119                  <span className="block truncate text-sm text-ink">{it.r.title}</span>120                  <span className="block truncate text-2xs text-ink-3">{it.r.subtitle}</span>121                </span>122                <span className="mono shrink-0 text-2xs text-ink-3">{it.norad}</span>123              </button>124            </li>125          ))}126        </ul>127      )}128    </div>129  );130}131