"use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { Bookmark, BookmarkCheck, ExternalLink, Maximize2, X } from "lucide-react"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { EventDetail, EventItem } from "@/lib/api"; import { fmtMs, fmtOffset, fmtScore, relTime, typeLabel, utcDateTime, CLASS_LABELS } from "@/lib/format"; import { ownerFetch, publicFetch } from "@/lib/owner"; import { FieldChanges } from "./field-changes"; import { Badge, Bar, Chip, EvidenceTag, Flag, Score, Skeleton, StateBadge, TypeChip } from "./ui"; /** * Intelligence panel (spec §33, §78): a right drawer on desktop, a full-screen sheet on mobile. * Opens from any feed row without navigation; the permanent page stays one click away. */ interface DrawerCtx { open: (slug: string, seed?: EventItem) => void; close: () => void; } const Ctx = createContext({ open: () => {}, close: () => {} }); export const useEventDrawer = (): DrawerCtx => useContext(Ctx); export function EventDrawerProvider({ children }: { children: ReactNode }) { // The drawer state is keyed by pathname: a route change opens a fresh (closed) drawer. const pathname = usePathname(); const [state, setState] = useState<{ path: string; slug: string | null; seed: EventItem | null; detail: EventDetail | null; loading: boolean }>({ path: pathname, slug: null, seed: null, detail: null, loading: false }); const current = state.path === pathname ? state : { path: pathname, slug: null, seed: null, detail: null, loading: false }; const slug = current.slug; const seed = current.seed; const detail = current.detail; const loading = current.loading; const open = useCallback((s: string, e?: EventItem) => setState({ path: pathname, slug: s, seed: e ?? null, detail: null, loading: true }), [pathname]); const close = useCallback(() => setState({ path: pathname, slug: null, seed: null, detail: null, loading: false }), [pathname]); useEffect(() => { if (!slug) return; let cancelled = false; publicFetch(`/api/v1/events/${encodeURIComponent(slug)}`) .then((d) => { if (!cancelled) setState((st) => (st.slug === slug ? { ...st, detail: d, loading: false } : st)); }) .catch(() => { if (!cancelled) setState((st) => (st.slug === slug ? { ...st, loading: false } : st)); }); return () => { cancelled = true; }; }, [slug]); useEffect(() => { if (!slug) return; const onKey = (e: KeyboardEvent): void => { if (e.key === "Escape") close(); }; document.addEventListener("keydown", onKey); const prev = document.body.style.overflow; if (window.innerWidth < 1024) document.body.style.overflow = "hidden"; return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = prev; }; }, [slug, close]); const value = useMemo(() => ({ open, close }), [open, close]); return ( {children} {slug && } ); } function Drawer({ slug, seed, detail, loading, onClose }: { slug: string; seed: EventItem | null; detail: EventDetail | null; loading: boolean; onClose: () => void }) { const ev = detail?.event ?? seed; const ref = useRef(null); useEffect(() => { ref.current?.focus(); }, [slug]); return (
{!ev ? ( ) : ( <>
{ev.source?.name} {ev.country && } {ev.silent_change && } {ev.first_party === false ? : }

{ev.title}

detected {relTime(ev.detected_at)} · {utcDateTime(ev.detected_at)}

{ev.summary}

{/* WHAT CHANGED */} {(ev.field_changes?.length ?? 0) > 0 && (
What changed
)} {ev.why_it_matters && (
Why it matters · analysis

{ev.why_it_matters}

)} {/* SCORES */}
WebSensor signal score
{(ev.signal_score ?? ev.importance) >= 80 ? "attention now" : (ev.signal_score ?? ev.importance) >= 60 ? "worth a look" : "informational"}
{(ev.score_reasons?.length ?? 0) > 0 && (
    {ev.score_reasons!.slice(0, 7).map((r, i) => (
  • {r.sign} {r.text} {r.points !== undefined && {r.points > 0 ? "+" : ""}{r.points}}
  • ))}
)} {ev.change_class &&
Semantic class: {CLASS_LABELS[ev.change_class] ?? ev.change_class}
}
{/* CLUSTER / PROPAGATION */} {ev.cluster && ev.cluster.event_count > 1 && (
Event cluster
propagation timeline →
{ev.cluster.lead_time_ms !== null && ev.cluster.lead_time_ms !== undefined && ev.cluster.lead_time_ms > 0 && (

WebSensor lead time {fmtOffset(ev.cluster.lead_time_ms).replace("+", "")} before the first external report.

)}
)} {/* EVIDENCE */}
Evidence
{ev.url} {detail ? (
{detail.snapshots.map((s) => ( ))}
) : ( )}
Open diff {ev.old_snapshot_id && ev.new_snapshot_id && Compare snapshots} Sensor URL history
{/* HISTORICAL CONTEXT */} {detail?.history && detail.history.length > 0 && (
Previous changes on this page
    {detail.history.map((h) => (
  • {relTime(h.detected_at)} {h.title}
  • ))}
)} {/* RELATED */} {detail?.related && detail.related.length > 0 && (
Related signals
    {detail.related.slice(0, 6).map((r) => (
  • {r.source?.name} {r.title}
  • ))}
)} {ev.entities?.length > 0 && (
{ev.entities.map((x) => ( {x.name} ))}
)}

{typeLabel(ev.event_type)} · {loading ? "loading details…" : "raw evidence is immutable; analysis is labelled and versioned."}

)}
); } function Mini({ label, v, tone }: { label: string; v: number; tone?: "signal" | "hot" | "high" | "mid" | "silent" | "info" }) { return (
{label} {fmtScore(v)}
= 80 ? "hot" : v >= 60 ? "high" : "signal")} />
); } function Cell({ v, l }: { v: number; l: string }) { return (
{v}
{l}
); } function FragmentRow({ label, value }: { label: string; value: string }) { return ( <>
{label}
{value}
); } function DrawerSkeleton() { return (
); } export function BookmarkButton({ eventId, label = false }: { eventId: string; label?: boolean }) { const [state, setState] = useState<"idle" | "on" | "busy">("idle"); useEffect(() => { let c = false; ownerFetch<{ ids: string[] }>("/api/v1/bookmarks/ids") .then((r) => { if (!c && r.ids.includes(eventId)) setState("on"); }) .catch(() => undefined); return () => { c = true; }; }, [eventId]); const toggle = async (): Promise => { const was = state; setState("busy"); try { if (was === "on") { await ownerFetch(`/api/v1/bookmarks/${encodeURIComponent(eventId)}`, { method: "DELETE" }); setState("idle"); } else { await ownerFetch("/api/v1/bookmarks", { method: "POST", body: JSON.stringify({ event_id: eventId }) }); setState("on"); } } catch { setState(was); } }; return ( ); }