TypeScript 55.4%
Python 43.2%
SQL 1.2%
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