spb/internetpressure
Public
TypeScript 36.3%
Python 31.8%
Go 18%
JavaScript 9.8%
Shell 1.9%
SQL 1.4%
CSS 0.5%
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, setOpenState] = useState(false);14 const [q, setQ] = useState('');15 const [found, setFound] = useState<{ q: string; results: SearchResult[] }>({ q: '', results: [] });16 const [active, setActive] = useState(0);17 const router = useRouter();18 const inputRef = useRef<HTMLInputElement>(null);19 const results = q.trim() && found.q === q.trim() ? found.results : [];20 const loading = Boolean(q.trim()) && found.q !== q.trim();2122 // open/close is an event: reset query + results here, not in an effect23 const setOpen = useCallback((next: boolean | ((o: boolean) => boolean)) => {24 setOpenState((o) => {25 const v = typeof next === 'function' ? next(o) : next;26 if (!v) {27 setQ('');28 setFound({ q: '', results: [] });29 } else setTimeout(() => inputRef.current?.focus(), 10);30 return v;31 });32 }, []);3334 useEffect(() => {35 const onKey = (e: KeyboardEvent) => {36 if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {37 e.preventDefault();38 setOpen((o) => !o);39 } else if (e.key === 'Escape') setOpen(false);40 };41 window.addEventListener('keydown', onKey);42 return () => window.removeEventListener('keydown', onKey);43 }, [setOpen]);4445 useEffect(() => {46 const s = q.trim();47 if (!open || !s) return;48 const ctrl = new AbortController();49 const t = setTimeout(() => {50 fetch(`/api/v1/search?q=${encodeURIComponent(s)}`, { signal: ctrl.signal })51 .then((r) => (r.ok ? r.json() : { results: [] }))52 .then((d: { results?: SearchResult[] }) => {53 setFound({ q: s, results: d.results ?? [] });54 setActive(0);55 })56 .catch(() => {});57 }, 120);58 return () => {59 clearTimeout(t);60 ctrl.abort();61 };62 }, [q, open]);6364 const go = useCallback(65 (r: SearchResult | undefined) => {66 if (!r) return;67 setOpen(false);68 router.push(r.href);69 },70 [router, setOpen],71 );7273 return (74 <>75 <button76 type="button"77 onClick={() => setOpen(true)}78 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"79 aria-label="Search (Command K)"80 >81 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">82 <circle cx="11" cy="11" r="7" />83 <path d="m20 20-3.5-3.5" />84 </svg>85 <span className="hidden sm:inline">Search</span>86 <kbd className="num hidden rounded-[2px] border border-line px-1 text-[9.5px] text-ink-3 sm:inline">⌘K</kbd>87 </button>88 {open && (89 <div className="fixed inset-0 z-[100] flex items-start justify-center bg-bg/80 px-3 pt-[12vh]" onClick={() => setOpen(false)} role="presentation">90 <div role="dialog" aria-modal="true" aria-label="Search" className="panel w-full max-w-[560px] overflow-hidden" onClick={(e) => e.stopPropagation()}>91 <div className="flex items-center gap-2 border-b border-line px-3">92 <span className="text-ink-3" aria-hidden="true">93 /94 </span>95 <input96 ref={inputRef}97 value={q}98 onChange={(e) => setQ(e.target.value)}99 onKeyDown={(e) => {100 if (e.key === 'ArrowDown') {101 e.preventDefault();102 setActive((a) => Math.min(results.length - 1, a + 1));103 } else if (e.key === 'ArrowUp') {104 e.preventDefault();105 setActive((a) => Math.max(0, a - 1));106 } else if (e.key === 'Enter') go(results[active]);107 }}108 placeholder="Country, region, AS number, service, hostname, incident…"109 className="h-11 w-full bg-transparent text-[14px] text-ink placeholder:text-ink-3 focus:outline-none"110 aria-activedescendant={results[active] ? `sr-${active}` : undefined}111 />112 {loading && <span className="text-[10px] text-ink-3">…</span>}113 </div>114 <ul role="listbox" className="max-h-[50vh] overflow-y-auto">115 {results.map((r, i) => (116 <li117 key={`${r.type}-${r.id}`}118 id={`sr-${i}`}119 role="option"120 aria-selected={i === active}121 onMouseEnter={() => setActive(i)}122 onClick={() => go(r)}123 className={`flex cursor-pointer items-center gap-3 px-3 py-2 text-[13px] ${i === active ? 'bg-panel-2' : ''}`}124 >125 <span className="label w-16 shrink-0">{TYPE_LABEL[r.type]}</span>126 <span className="flex-1 truncate text-ink">{r.label}</span>127 {r.pressure != null && (128 <span className="num text-xs" style={{ color: pressureColor(r.pressure) }}>129 {fmt(r.pressure)}130 </span>131 )}132 </li>133 ))}134 {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>}135 {!q.trim() && <li className="px-3 py-4 text-xs text-ink-3">Type to search. ↑↓ to move, ⏎ to open, esc to close.</li>}136 </ul>137 </div>138 </div>139 )}140 </>141 );142}143