"use client"; import { useRouter } from "next/navigation"; import { Activity, Building2, Compass, Globe2, Layers, Radar, Search, Siren, Zap } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { SearchResult } from "@/lib/api"; import { publicFetch } from "@/lib/owner"; import { useEventDrawer } from "./event-drawer"; import { usePrefs, type Density } from "./prefs"; import { Chip, Kbd, Score } from "./ui"; /** * Global command palette (spec §29): ⌘K / Ctrl+K. Searches entities, sources, events, clusters, * URLs and offers navigation + preference commands. Fully keyboard-driven (↑ ↓ ↵ Esc), ARIA listbox. */ interface Item { id: string; group: string; label: string; hint?: string; icon?: ReactNode; score?: number | null; run: () => void; } const NAV: { label: string; href: string; hint: string; icon: ReactNode }[] = [ { label: "Live feed", href: "/live", hint: "everything, live", icon: }, { label: "Breaking", href: "/breaking", hint: "breaking · developing · confirmed", icon: }, { label: "Pulse", href: "/pulse", hint: "what is changing on the Internet right now", icon: }, { label: "Radar", href: "/radar", hint: "weak signals, not yet breaking", icon: }, { label: "Silent changes", href: "/silent", hint: "modified without announcement", icon: }, { label: "Explore", href: "/explore", hint: "trending · unusual · entities · sources", icon: }, { label: "Entities", href: "/entities", hint: "organizations, products, models", icon: }, { label: "Countries", href: "/country", hint: "national desks", icon: }, { label: "Sources", href: "/sources", hint: "monitored organizations", icon: }, { label: "Coverage", href: "/coverage", hint: "global observation coverage score by sector", icon: }, { label: "Watchlists", href: "/watchlists", hint: "your entities, sources, keywords", icon: }, { label: "Alerts", href: "/alerts", hint: "rules · webhooks", icon: }, { label: "Bookmarks", href: "/bookmarks", hint: "saved events", icon: }, { label: "Health", href: "/health", hint: "sensors, connectors, throughput", icon: }, { label: "API", href: "/api", hint: "REST · RSS · WebSocket", icon: }, ]; export function CommandPalette() { const [open, setOpen] = useState(false); const [q, setQ] = useState(""); const [res, setRes] = useState(null); const [busy, setBusy] = useState(false); const [idx, setIdx] = useState(0); const input = useRef(null); const router = useRouter(); const drawer = useEventDrawer(); const { setDensity } = usePrefs(); const openPalette = (): void => { setQ(""); setRes(null); setIdx(0); setOpen(true); setTimeout(() => input.current?.focus(), 10); }; useEffect(() => { const onKey = (e: KeyboardEvent): void => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); if (open) setOpen(false); else openPalette(); } else if (e.key === "/" && !open && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)) { e.preventDefault(); openPalette(); } }; window.addEventListener("keydown", onKey); const onOpen = (): void => openPalette(); window.addEventListener("ws:palette", onOpen); return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("ws:palette", onOpen); }; }, [open]); useEffect(() => { if (!open) return; const t = q.trim(); const h = setTimeout(() => { if (t.length < 2) { setRes(null); return; } setBusy(true); publicFetch(`/api/v1/search?q=${encodeURIComponent(t)}&limit=6`) .then((r) => setRes(r)) .catch(() => setRes(null)) .finally(() => setBusy(false)); }, 160); return () => clearTimeout(h); }, [q, open]); const go = useCallback( (href: string) => { setOpen(false); router.push(href); }, [router], ); const items = useMemo(() => { const t = q.trim().toLowerCase(); const out: Item[] = []; if (res) { 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: , run: () => go(`/entity/${e.id}`) }); 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: , run: () => go(`/source/${s.id}`) }); 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: , score: c.max_importance, run: () => go(`/cluster/${c.slug ?? c.id}`) }); 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: , score: e.signal_score ?? e.importance, run: () => { setOpen(false); drawer.open(e.slug, e); } }); 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: , run: () => go(`/url?u=${encodeURIComponent(u.url)}`) }); } if (t.length >= 2) out.push({ id: "search", group: "Search", label: `Search events for “${q.trim()}”`, hint: "supports entity: type: after: silent: importance:>", icon: , run: () => go(`/search?q=${encodeURIComponent(q.trim())}`) }); const nav = NAV.filter((n) => !t || n.label.toLowerCase().includes(t) || n.hint.includes(t) || n.href.includes(t)).slice(0, t ? 5 : 14); 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) }); const cmds: { label: string; hint: string; run: () => void; match: string }[] = [ { label: "Density: compact", hint: "denser rows", match: "density compact", run: () => setDensity("compact" as Density) }, { label: "Density: normal", hint: "default rows", match: "density normal", run: () => setDensity("normal" as Density) }, { label: "Density: comfortable", hint: "roomier rows with summaries", match: "density comfortable", run: () => setDensity("comfortable" as Density) }, { label: "Toggle theme", hint: "dark / light", match: "theme dark light", run: () => document.documentElement.classList.toggle("dark") }, ]; 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: , run: () => { c.run(); setOpen(false); } }); return out; }, [q, res, go, drawer, setDensity]); // keep the highlighted row valid when the result list shrinks const safeIdx = Math.min(idx, Math.max(0, items.length - 1)); if (!open) return null; const groups = [...new Set(items.map((i) => i.group))]; let flat = -1; return (
); }