spb/internetpressure
Public
1'use client';23import { useRouter } from 'next/navigation';4import { useCallback, useEffect, useRef, useState } from 'react';5import { fmt } from '@/lib/format';6import { pressureColor } from '@/lib/pressure';7import type { SearchResult } from '@/lib/types';89const TYPE_LABEL: Record<SearchResult['type'], string> = { country: 'Country', region: 'Region', asn: 'ASN', service: 'Service', target: 'Target', incident: 'Incident' };1011/** ⌘K search over /api/v1/search — countries, regions, ASNs, services, targets, incidents. */12export function Search() {13 const [open, setOpen] = useState(false);14 const [q, setQ] = useState('');15 const [results, setResults] = useState<SearchResult[]>([]);16 const [active, setActive] = useState(0);17 const [loading, setLoading] = useState(false);18 const router = useRouter();19 const inputRef = useRef<HTMLInputElement>(null);20 const abortRef = useRef<AbortController | null>(null);2122 useEffect(() => {23 const onKey = (e: KeyboardEvent) => {24 if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {25 e.preventDefault();26 setOpen((o) => !o);27 } else if (e.key === 'Escape') setOpen(false);28 };29 window.addEventListener('keydown', onKey);30 return () => window.removeEventListener('keydown', onKey);31 }, []);3233 useEffect(() => {34 if (open) setTimeout(() => inputRef.current?.focus(), 10);35 else {36 setQ('');37 setResults([]);38 }39 }, [open]);4041 useEffect(() => {42 if (!open) return;43 const s = q.trim();44 if (!s) {45 setResults([]);46 return;47 }48 abortRef.current?.abort();49 const ctrl = new AbortController();50 abortRef.current = ctrl;51 setLoading(true);52 const t = setTimeout(() => {53 fetch(`/api/v1/search?q=${encodeURIComponent(s)}`, { signal: ctrl.signal })54 .then((r) => (r.ok ? r.json() : { results: [] }))55 .then((d: { results?: SearchResult[] }) => {56 setResults(d.results ?? []);57 setActive(0);58 })59 .catch(() => {})60 .finally(() => setLoading(false));61 }, 120);62 return () => {63 clearTimeout(t);64 ctrl.abort();65 };66 }, [q, open]);6768 const go = useCallback(69 (r: SearchResult | undefined) => {70 if (!r) return;71 setOpen(false);72 router.push(r.href);73 },74 [router],75 );7677 return (78 <>79 <button80 type="button"81 onClick={() => setOpen(true)}82 className="inline-flex h-7 items-center gap-2 rounded-[4px] border border-line px-2 text-[11px] text-ink-2 hover:border-line-2 hover:text-ink"83 aria-label="Search (Command K)"84 >85 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">86 <circle cx="11" cy="11" r="7" />87 <path d="m20 20-3.5-3.5" />88 </svg>89 <span className="hidden sm:inline">Search</span>90 <kbd className="num hidden rounded-[2px] border border-line px-1 text-[9.5px] text-ink-3 sm:inline">⌘K</kbd>91 </button>92 {open && (93 <div className="fixed inset-0 z-[100] flex items-start justify-center bg-bg/80 px-3 pt-[12vh]" onClick={() => setOpen(false)} role="presentation">94 <div role="dialog" aria-modal="true" aria-label="Search" className="panel w-full max-w-[560px] overflow-hidden" onClick={(e) => e.stopPropagation()}>95 <div className="flex items-center gap-2 border-b border-line px-3">96 <span className="text-ink-3" aria-hidden="true">97 /98 </span>99 <input100 ref={inputRef}101 value={q}102 onChange={(e) => setQ(e.target.value)}103 onKeyDown={(e) => {104 if (e.key === 'ArrowDown') {105 e.preventDefault();106 setActive((a) => Math.min(results.length - 1, a + 1));107 } else if (e.key === 'ArrowUp') {108 e.preventDefault();109 setActive((a) => Math.max(0, a - 1));110 } else if (e.key === 'Enter') go(results[active]);111 }}112 placeholder="Country, region, AS number, service, hostname, incident…"113 className="h-11 w-full bg-transparent text-[14px] text-ink placeholder:text-ink-3 focus:outline-none"114 aria-activedescendant={results[active] ? `sr-${active}` : undefined}115 />116 {loading && <span className="text-[10px] text-ink-3">…</span>}117 </div>118 <ul role="listbox" className="max-h-[50vh] overflow-y-auto">119 {results.map((r, i) => (120 <li121 key={`${r.type}-${r.id}`}122 id={`sr-${i}`}123 role="option"124 aria-selected={i === active}125 onMouseEnter={() => setActive(i)}126 onClick={() => go(r)}127 className={`flex cursor-pointer items-center gap-3 px-3 py-2 text-[13px] ${i === active ? 'bg-panel-2' : ''}`}128 >129 <span className="label w-16 shrink-0">{TYPE_LABEL[r.type]}</span>130 <span className="flex-1 truncate text-ink">{r.label}</span>131 {r.pressure != null && (132 <span className="num text-xs" style={{ color: pressureColor(r.pressure) }}>133 {fmt(r.pressure)}134 </span>135 )}136 </li>137 ))}138 {q.trim() && !loading && results.length === 0 && <li className="px-3 py-6 text-center text-xs text-ink-3">No match in the observatory.</li>}139 {!q.trim() && <li className="px-3 py-4 text-xs text-ink-3">Type to search. ↑↓ to move, ⏎ to open, esc to close.</li>}140 </ul>141 </div>142 </div>143 )}144 </>145 );146}147