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