spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
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