SPB Git forge
15commits 1branches 0releases
29.7 MBsize
maindefault branch
10 days agolast push
TypeScript 36.3% Python 31.8% Go 18% JavaScript 9.8% Shell 1.9% SQL 1.4% CSS 0.5%
5.9 KB · 143 lines tsx
Raw Blame History
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