SPB Git
5.7 KB · 147 lines tsx
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
Files · be6a3c4c9e1838cf98ba8aa0b95cc0ee163b31bc
Menu

Theme