SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
11.4 KB · 207 lines tsx
Raw Blame History
1"use client";23import { useRouter } from "next/navigation";4import { Activity, Building2, Compass, Globe2, Layers, Radar, Search, Siren, Zap } from "lucide-react";5import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";6import type { SearchResult } from "@/lib/api";7import { publicFetch } from "@/lib/owner";8import { useEventDrawer } from "./event-drawer";9import { usePrefs, type Density } from "./prefs";10import { Chip, Kbd, Score } from "./ui";1112/**13 * Global command palette (spec §29): ⌘K / Ctrl+K. Searches entities, sources, events, clusters,14 * URLs and offers navigation + preference commands. Fully keyboard-driven (↑ ↓ ↵ Esc), ARIA listbox.15 */16interface Item {17  id: string;18  group: string;19  label: string;20  hint?: string;21  icon?: ReactNode;22  score?: number | null;23  run: () => void;24}2526const NAV: { label: string; href: string; hint: string; icon: ReactNode }[] = [27  { label: "Live feed", href: "/live", hint: "everything, live", icon: <Activity className="size-3.5" /> },28  { label: "Breaking", href: "/breaking", hint: "breaking · developing · confirmed", icon: <Siren className="size-3.5" /> },29  { label: "Pulse", href: "/pulse", hint: "what is changing on the Internet right now", icon: <Zap className="size-3.5" /> },30  { label: "Radar", href: "/radar", hint: "weak signals, not yet breaking", icon: <Radar className="size-3.5" /> },31  { label: "Silent changes", href: "/silent", hint: "modified without announcement", icon: <Layers className="size-3.5" /> },32  { label: "Explore", href: "/explore", hint: "trending · unusual · entities · sources", icon: <Compass className="size-3.5" /> },33  { label: "Entities", href: "/entities", hint: "organizations, products, models", icon: <Building2 className="size-3.5" /> },34  { label: "Countries", href: "/country", hint: "national desks", icon: <Globe2 className="size-3.5" /> },35  { label: "Sources", href: "/sources", hint: "monitored organizations", icon: <Building2 className="size-3.5" /> },36  { label: "Coverage", href: "/coverage", hint: "global observation coverage score by sector", icon: <Globe2 className="size-3.5" /> },37  { label: "Watchlists", href: "/watchlists", hint: "your entities, sources, keywords", icon: <Layers className="size-3.5" /> },38  { label: "Alerts", href: "/alerts", hint: "rules · webhooks", icon: <Siren className="size-3.5" /> },39  { label: "Bookmarks", href: "/bookmarks", hint: "saved events", icon: <Layers className="size-3.5" /> },40  { label: "Health", href: "/health", hint: "sensors, connectors, throughput", icon: <Activity className="size-3.5" /> },41  { label: "API", href: "/api", hint: "REST · RSS · WebSocket", icon: <Zap className="size-3.5" /> },42];4344export function CommandPalette() {45  const [open, setOpen] = useState(false);46  const [q, setQ] = useState("");47  const [res, setRes] = useState<SearchResult | null>(null);48  const [busy, setBusy] = useState(false);49  const [idx, setIdx] = useState(0);50  const input = useRef<HTMLInputElement>(null);51  const router = useRouter();52  const drawer = useEventDrawer();53  const { setDensity } = usePrefs();5455  const openPalette = (): void => {56    setQ("");57    setRes(null);58    setIdx(0);59    setOpen(true);60    setTimeout(() => input.current?.focus(), 10);61  };62  useEffect(() => {63    const onKey = (e: KeyboardEvent): void => {64      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {65        e.preventDefault();66        if (open) setOpen(false);67        else openPalette();68      } else if (e.key === "/" && !open && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)) {69        e.preventDefault();70        openPalette();71      }72    };73    window.addEventListener("keydown", onKey);74    const onOpen = (): void => openPalette();75    window.addEventListener("ws:palette", onOpen);76    return () => {77      window.removeEventListener("keydown", onKey);78      window.removeEventListener("ws:palette", onOpen);79    };80  }, [open]);81  useEffect(() => {82    if (!open) return;83    const t = q.trim();84    const h = setTimeout(() => {85      if (t.length < 2) {86        setRes(null);87        return;88      }89      setBusy(true);90      publicFetch<SearchResult>(`/api/v1/search?q=${encodeURIComponent(t)}&limit=6`)91        .then((r) => setRes(r))92        .catch(() => setRes(null))93        .finally(() => setBusy(false));94    }, 160);95    return () => clearTimeout(h);96  }, [q, open]);9798  const go = useCallback(99    (href: string) => {100      setOpen(false);101      router.push(href);102    },103    [router],104  );105106  const items = useMemo<Item[]>(() => {107    const t = q.trim().toLowerCase();108    const out: Item[] = [];109    if (res) {110      for (const e of res.entities.slice(0, 5)) out.push({ id: `ent:${e.id}`, group: "Entities", label: e.name, hint: `${e.type}${e.domain ? " · " + e.domain : ""}`, icon: <Building2 className="size-3.5" />, run: () => go(`/entity/${e.id}`) });111      for (const s of res.sources.slice(0, 5)) out.push({ id: `src:${s.id}`, group: "Sources", label: s.name, hint: `${s.domain} · tier ${s.tier}${s.first_party === false ? " · media" : ""}`, icon: <Globe2 className="size-3.5" />, run: () => go(`/source/${s.id}`) });112      for (const c of res.clusters?.slice(0, 3) ?? []) out.push({ id: `clu:${c.id}`, group: "Clusters", label: c.title, hint: `${c.event_count} signals · ${c.source_count} sources · ${c.state}`, icon: <Layers className="size-3.5" />, score: c.max_importance, run: () => go(`/cluster/${c.slug ?? c.id}`) });113      for (const e of res.events.slice(0, 6)) out.push({ id: `evt:${e.id}`, group: "Events", label: e.title, hint: `${e.source?.name} · ${e.event_type.replace(/_/g, " ")}`, icon: <Activity className="size-3.5" />, score: e.signal_score ?? e.importance, run: () => { setOpen(false); drawer.open(e.slug, e); } });114      for (const u of res.urls.slice(0, 3)) out.push({ id: `url:${u.url}`, group: "URLs", label: u.url, hint: `${u.change_count} changes`, icon: <Globe2 className="size-3.5" />, run: () => go(`/url?u=${encodeURIComponent(u.url)}`) });115    }116    if (t.length >= 2) out.push({ id: "search", group: "Search", label: `Search events for “${q.trim()}”`, hint: "supports entity: type: after: silent: importance:>", icon: <Search className="size-3.5" />, run: () => go(`/search?q=${encodeURIComponent(q.trim())}`) });117    const nav = NAV.filter((n) => !t || n.label.toLowerCase().includes(t) || n.hint.includes(t) || n.href.includes(t)).slice(0, t ? 5 : 14);118    for (const n of nav) out.push({ id: `nav:${n.href}`, group: "Go to", label: n.label, hint: n.hint, icon: n.icon, run: () => go(n.href) });119    const cmds: { label: string; hint: string; run: () => void; match: string }[] = [120      { label: "Density: compact", hint: "denser rows", match: "density compact", run: () => setDensity("compact" as Density) },121      { label: "Density: normal", hint: "default rows", match: "density normal", run: () => setDensity("normal" as Density) },122      { label: "Density: comfortable", hint: "roomier rows with summaries", match: "density comfortable", run: () => setDensity("comfortable" as Density) },123      { label: "Toggle theme", hint: "dark / light", match: "theme dark light", run: () => document.documentElement.classList.toggle("dark") },124    ];125    for (const c of cmds.filter((c) => !t || c.match.includes(t) || c.label.toLowerCase().includes(t)).slice(0, t ? 3 : 4)) out.push({ id: `cmd:${c.label}`, group: "Commands", label: c.label, hint: c.hint, icon: <Zap className="size-3.5" />, run: () => { c.run(); setOpen(false); } });126    return out;127  }, [q, res, go, drawer, setDensity]);128129  // keep the highlighted row valid when the result list shrinks130  const safeIdx = Math.min(idx, Math.max(0, items.length - 1));131132  if (!open) return null;133  const groups = [...new Set(items.map((i) => i.group))];134  let flat = -1;135  return (136    <div className="fixed inset-0 z-[60] flex items-start justify-center px-3 pt-[10vh]" role="dialog" aria-modal="true" aria-label="Command palette">137      <button type="button" aria-label="Close" onClick={() => setOpen(false)} className="scrim absolute inset-0 cursor-default" />138      <div className="relative w-full max-w-xl overflow-hidden rounded-lg border border-line bg-panel shadow-2xl animate-fade-in">139        <div className="flex items-center gap-2 border-b border-line px-3">140          <Search className="size-4 text-fg-subtle" />141          <input142            ref={input}143            value={q}144            onChange={(e) => {145              setQ(e.target.value);146              setIdx(0);147            }}148            onKeyDown={(e) => {149              if (e.key === "ArrowDown") {150                e.preventDefault();151                setIdx((i) => Math.min(items.length - 1, i + 1));152              } else if (e.key === "ArrowUp") {153                e.preventDefault();154                setIdx((i) => Math.max(0, i - 1));155              } else if (e.key === "Enter") {156                e.preventDefault();157                items[safeIdx]?.run();158              } else if (e.key === "Escape") setOpen(false);159            }}160            placeholder="Search entities, sources, events, URLs — or jump to a page…"161            className="h-11 w-full bg-transparent text-[14px] outline-none placeholder:text-fg-subtle"162            role="combobox"163            aria-expanded164            aria-controls="palette-list"165            aria-activedescendant={items[safeIdx] ? `pal-${items[safeIdx].id}` : undefined}166            autoComplete="off"167          />168          {busy ? <span className="size-3 animate-pulse rounded-full bg-signal" /> : <Kbd>esc</Kbd>}169        </div>170        <ul id="palette-list" role="listbox" className="max-h-[60vh] overflow-y-auto py-1">171          {groups.map((g) => (172            <li key={g} role="presentation">173              <div className="label px-3 pb-0.5 pt-2">{g}</div>174              <ul role="group">175                {items176                  .filter((i) => i.group === g)177                  .map((it) => {178                    flat++;179                    const active = flat === safeIdx;180                    const my = flat;181                    return (182                      <li key={it.id} id={`pal-${it.id}`} role="option" aria-selected={active} onMouseEnter={() => setIdx(my)} onClick={it.run} className={`flex cursor-pointer items-center gap-2.5 px-3 py-1.5 text-[13px] ${active ? "bg-panel-2" : ""}`}>183                        <span className="text-fg-subtle">{it.icon}</span>184                        <span className="min-w-0 flex-1">185                          <span className="block truncate">{it.label}</span>186                          {it.hint && <span className="block truncate text-[11px] text-fg-subtle">{it.hint}</span>}187                        </span>188                        {it.score !== undefined && it.score !== null && <Score value={it.score} size="sm" kind="signal" />}189                        {active && <Kbd>↵</Kbd>}190                      </li>191                    );192                  })}193              </ul>194            </li>195          ))}196          {!items.length && <li className="px-3 py-6 text-center text-[13px] text-fg-subtle">Nothing matches.</li>}197        </ul>198        <div className="flex items-center gap-3 border-t border-line px-3 py-1.5 text-[11px] text-fg-subtle">199          <span className="inline-flex items-center gap-1"><Kbd>↑</Kbd><Kbd>↓</Kbd> navigate</span>200          <span className="inline-flex items-center gap-1"><Kbd>↵</Kbd> open</span>201          <span className="ml-auto inline-flex items-center gap-1"><Chip>entity:openai</Chip><Chip>type:pricing_change</Chip><Chip>silent:true</Chip></span>202        </div>203      </div>204    </div>205  );206}207