TypeScript 55.4%
Python 43.2%
SQL 1.2%
1"use client";23import Link from "next/link";4import { usePathname } from "next/navigation";5import { Bookmark, BookmarkCheck, ExternalLink, Maximize2, X } from "lucide-react";6import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";7import type { EventDetail, EventItem } from "@/lib/api";8import { fmtMs, fmtOffset, fmtScore, relTime, typeLabel, utcDateTime, CLASS_LABELS } from "@/lib/format";9import { ownerFetch, publicFetch } from "@/lib/owner";10import { FieldChanges } from "./field-changes";11import { Badge, Bar, Chip, EvidenceTag, Flag, Score, Skeleton, StateBadge, TypeChip } from "./ui";1213/**14 * Intelligence panel (spec §33, §78): a right drawer on desktop, a full-screen sheet on mobile.15 * Opens from any feed row without navigation; the permanent page stays one click away.16 */17interface DrawerCtx {18 open: (slug: string, seed?: EventItem) => void;19 close: () => void;20}21const Ctx = createContext<DrawerCtx>({ open: () => {}, close: () => {} });22export const useEventDrawer = (): DrawerCtx => useContext(Ctx);2324export function EventDrawerProvider({ children }: { children: ReactNode }) {25 // The drawer state is keyed by pathname: a route change opens a fresh (closed) drawer.26 const pathname = usePathname();27 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 });28 const current = state.path === pathname ? state : { path: pathname, slug: null, seed: null, detail: null, loading: false };29 const slug = current.slug;30 const seed = current.seed;31 const detail = current.detail;32 const loading = current.loading;33 const open = useCallback((s: string, e?: EventItem) => setState({ path: pathname, slug: s, seed: e ?? null, detail: null, loading: true }), [pathname]);34 const close = useCallback(() => setState({ path: pathname, slug: null, seed: null, detail: null, loading: false }), [pathname]);35 useEffect(() => {36 if (!slug) return;37 let cancelled = false;38 publicFetch<EventDetail>(`/api/v1/events/${encodeURIComponent(slug)}`)39 .then((d) => {40 if (!cancelled) setState((st) => (st.slug === slug ? { ...st, detail: d, loading: false } : st));41 })42 .catch(() => {43 if (!cancelled) setState((st) => (st.slug === slug ? { ...st, loading: false } : st));44 });45 return () => {46 cancelled = true;47 };48 }, [slug]);49 useEffect(() => {50 if (!slug) return;51 const onKey = (e: KeyboardEvent): void => {52 if (e.key === "Escape") close();53 };54 document.addEventListener("keydown", onKey);55 const prev = document.body.style.overflow;56 if (window.innerWidth < 1024) document.body.style.overflow = "hidden";57 return () => {58 document.removeEventListener("keydown", onKey);59 document.body.style.overflow = prev;60 };61 }, [slug, close]);62 const value = useMemo(() => ({ open, close }), [open, close]);63 return (64 <Ctx.Provider value={value}>65 {children}66 {slug && <Drawer slug={slug} seed={seed} detail={detail} loading={loading} onClose={close} />}67 </Ctx.Provider>68 );69}7071function Drawer({ slug, seed, detail, loading, onClose }: { slug: string; seed: EventItem | null; detail: EventDetail | null; loading: boolean; onClose: () => void }) {72 const ev = detail?.event ?? seed;73 const ref = useRef<HTMLDivElement>(null);74 useEffect(() => {75 ref.current?.focus();76 }, [slug]);77 return (78 <div className="fixed inset-0 z-50 flex justify-end" role="dialog" aria-modal="true" aria-label={ev?.title ?? "Event"}>79 <button type="button" aria-label="Close" onClick={onClose} className="scrim absolute inset-0 hidden cursor-default lg:block" />80 <div ref={ref} tabIndex={-1} className="relative flex h-full w-full flex-col bg-bg shadow-2xl outline-none animate-sheet-up lg:w-[520px] lg:animate-slide-in lg:border-l lg:border-line xl:w-[600px]">81 <header className="flex items-center gap-2 border-b border-line px-3 py-2">82 <span className="label">Intelligence panel</span>83 <span className="ml-auto flex items-center gap-1">84 {ev && <BookmarkButton eventId={ev.id} />}85 {ev && (86 <Link href={`/event/${ev.slug}`} className="inline-flex size-7 items-center justify-center rounded-md border border-line text-fg-muted hover:text-fg" title="Open full page" aria-label="Open full page">87 <Maximize2 className="size-3.5" />88 </Link>89 )}90 <button type="button" onClick={onClose} className="inline-flex size-7 items-center justify-center rounded-md border border-line text-fg-muted hover:text-fg" aria-label="Close">91 <X className="size-4" />92 </button>93 </span>94 </header>95 <div className="min-h-0 flex-1 overflow-y-auto px-3 py-3 pb-24 lg:pb-6">96 {!ev ? (97 <DrawerSkeleton />98 ) : (99 <>100 <div className="flex flex-wrap items-center gap-1.5">101 <Link href={`/source/${ev.source?.id ?? ev.source_id}`} className="font-mono text-[11px] font-semibold uppercase tracking-wide text-fg-muted hover:text-fg">{ev.source?.name}</Link>102 {ev.country && <Flag code={ev.country} className="text-[12px]" />}103 <StateBadge state={ev.cluster?.state} />104 {ev.silent_change && <Badge kind="silent" />}105 {ev.first_party === false ? <Badge kind="external" /> : <Badge kind="first-party" />}106 <EvidenceTag label={ev.evidence_label} />107 </div>108 <h2 className="mt-1.5 text-[17px] font-semibold leading-snug">{ev.title}</h2>109 <div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11.5px] text-fg-subtle">110 <TypeChip type={ev.event_type} />111 <span>detected {relTime(ev.detected_at)}</span>112 <span>·</span>113 <span className="font-mono">{utcDateTime(ev.detected_at)}</span>114 </div>115 <p className="mt-3 text-[13.5px] leading-relaxed">{ev.summary}</p>116117 {/* WHAT CHANGED */}118 {(ev.field_changes?.length ?? 0) > 0 && (119 <section className="mt-4">120 <div className="label mb-1.5">What changed</div>121 <FieldChanges items={ev.field_changes} max={8} />122 </section>123 )}124 {ev.why_it_matters && (125 <section className="mt-4">126 <div className="label mb-1">Why it matters <span className="normal-case tracking-normal text-fg-subtle">· analysis</span></div>127 <p className="text-[13px] leading-relaxed text-fg-muted">{ev.why_it_matters}</p>128 </section>129 )}130131 {/* SCORES */}132 <section className="mt-4 rounded-md border border-line bg-panel p-3">133 <div className="flex items-center gap-3">134 <Score value={ev.signal_score ?? ev.importance} size="lg" kind="signal" />135 <div className="min-w-0">136 <div className="label">WebSensor signal score</div>137 <div className="text-[12px] text-fg-muted">{(ev.signal_score ?? ev.importance) >= 80 ? "attention now" : (ev.signal_score ?? ev.importance) >= 60 ? "worth a look" : "informational"}</div>138 </div>139 </div>140 <div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-[12px]">141 <Mini label="Importance" v={ev.importance} />142 <Mini label="Confidence" v={ev.confidence} tone="info" />143 <Mini label="Novelty" v={ev.novelty} />144 <Mini label="Impact" v={ev.impact_score ?? 0} tone="high" />145 <Mini label="Velocity" v={ev.velocity_score ?? 0} tone="silent" />146 <Mini label="Anomaly" v={ev.anomaly_score ?? 0} tone="mid" />147 </div>148 {(ev.score_reasons?.length ?? 0) > 0 && (149 <ul className="mt-3 space-y-0.5 text-[12px]">150 {ev.score_reasons!.slice(0, 7).map((r, i) => (151 <li key={i} className="flex gap-2">152 <span className={`font-mono ${r.sign === "+" ? "text-signal" : "text-danger"}`}>{r.sign}</span>153 <span className="text-fg-muted">{r.text}</span>154 {r.points !== undefined && <span className="ml-auto font-mono text-fg-subtle tabular">{r.points > 0 ? "+" : ""}{r.points}</span>}155 </li>156 ))}157 </ul>158 )}159 {ev.change_class && <div className="mt-2 text-[11px] text-fg-subtle">Semantic class: <span className="text-fg-muted">{CLASS_LABELS[ev.change_class] ?? ev.change_class}</span></div>}160 </section>161162 {/* CLUSTER / PROPAGATION */}163 {ev.cluster && ev.cluster.event_count > 1 && (164 <section className="mt-4 rounded-md border border-line bg-panel p-3">165 <div className="flex items-center justify-between">166 <div className="label">Event cluster</div>167 <Link href={`/cluster/${ev.cluster.slug ?? ev.cluster.id}`} className="text-[11.5px] text-info hover:underline">propagation timeline →</Link>168 </div>169 <div className="mt-2 grid grid-cols-3 gap-2 text-center">170 <Cell v={ev.cluster.event_count} l="signals" />171 <Cell v={ev.cluster.first_party_count ?? 0} l="first-party" />172 <Cell v={ev.cluster.external_count ?? 0} l="external" />173 </div>174 {ev.cluster.lead_time_ms !== null && ev.cluster.lead_time_ms !== undefined && ev.cluster.lead_time_ms > 0 && (175 <p className="mt-2 text-[12px] text-fg-muted">176 <span className="font-semibold text-signal">WebSensor lead time</span> {fmtOffset(ev.cluster.lead_time_ms).replace("+", "")} before the first external report.177 </p>178 )}179 </section>180 )}181182 {/* EVIDENCE */}183 <section className="mt-4">184 <div className="label mb-1.5">Evidence</div>185 <div className="rounded-md border border-line bg-panel p-3 text-[12.5px]">186 <a href={ev.url} target="_blank" rel="noopener noreferrer nofollow" className="inline-flex max-w-full items-center gap-1 break-all font-mono text-[12px] text-info hover:underline">187 {ev.url} <ExternalLink className="size-3 shrink-0" />188 </a>189 {detail ? (190 <dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-[12px]">191 {detail.snapshots.map((s) => (192 <FragmentRow key={s.id} label={s.id === ev.old_snapshot_id ? "Before" : "After"} value={`${utcDateTime(s.captured_at)} · HTTP ${s.http_status ?? "—"} · ${s.canonical_hash?.slice(0, 12) ?? ""}`} />193 ))}194 <FragmentRow label="Observed from" value={ev.observed_from ? utcDateTime(ev.observed_from) : "—"} />195 <FragmentRow label="Published" value={ev.published_at ? utcDateTime(ev.published_at) : "—"} />196 <FragmentRow label="Processing" value={fmtMs(ev.processing_latency_ms)} />197 </dl>198 ) : (199 <Skeleton className="mt-2 h-10 w-full" />200 )}201 <div className="mt-2 flex flex-wrap gap-2 text-[11.5px]">202 <Link href={`/event/${ev.slug}#diff`} className="text-info hover:underline">Open diff</Link>203 {ev.old_snapshot_id && ev.new_snapshot_id && <Link href={`/compare?a=${ev.old_snapshot_id}&b=${ev.new_snapshot_id}`} className="text-info hover:underline">Compare snapshots</Link>}204 <Link href={`/sensor/${ev.sensor_id}`} className="text-info hover:underline">Sensor</Link>205 <Link href={`/url?u=${encodeURIComponent(ev.url)}`} className="text-info hover:underline">URL history</Link>206 </div>207 </div>208 </section>209210 {/* HISTORICAL CONTEXT */}211 {detail?.history && detail.history.length > 0 && (212 <section className="mt-4">213 <div className="label mb-1.5">Previous changes on this page</div>214 <ul className="divide-y divide-line rounded-md border border-line bg-panel">215 {detail.history.map((h) => (216 <li key={h.id} className="flex items-center gap-2 px-3 py-1.5 text-[12.5px]">217 <span className="w-[5.5rem] shrink-0 font-mono text-[11px] text-fg-subtle tabular">{relTime(h.detected_at)}</span>218 <Link href={`/event/${h.slug}`} className="min-w-0 flex-1 truncate hover:underline">{h.title}</Link>219 <Score value={h.importance} size="sm" />220 </li>221 ))}222 </ul>223 </section>224 )}225226 {/* RELATED */}227 {detail?.related && detail.related.length > 0 && (228 <section className="mt-4">229 <div className="label mb-1.5">Related signals</div>230 <ul className="divide-y divide-line rounded-md border border-line bg-panel">231 {detail.related.slice(0, 6).map((r) => (232 <li key={r.id} className="flex items-center gap-2 px-3 py-1.5 text-[12.5px]">233 <span className="w-[5.5rem] shrink-0 truncate font-mono text-[10.5px] uppercase text-fg-subtle">{r.source?.name}</span>234 <Link href={`/event/${r.slug}`} className="min-w-0 flex-1 truncate hover:underline">{r.title}</Link>235 <Score value={r.signal_score ?? r.importance} size="sm" kind="signal" />236 </li>237 ))}238 </ul>239 </section>240 )}241 {ev.entities?.length > 0 && (242 <div className="mt-4 flex flex-wrap gap-1">243 {ev.entities.map((x) => (244 <Chip key={x.id} href={`/entity/${x.id}`} tone={x.role === "subject" ? "signal" : "default"}>{x.name}</Chip>245 ))}246 </div>247 )}248 <p className="mt-4 text-[11px] text-fg-subtle">{typeLabel(ev.event_type)} · {loading ? "loading details…" : "raw evidence is immutable; analysis is labelled and versioned."}</p>249 </>250 )}251 </div>252 </div>253 </div>254 );255}256257function Mini({ label, v, tone }: { label: string; v: number; tone?: "signal" | "hot" | "high" | "mid" | "silent" | "info" }) {258 return (259 <div>260 <div className="flex items-baseline justify-between">261 <span className="text-fg-subtle">{label}</span>262 <span className="font-mono tabular">{fmtScore(v)}</span>263 </div>264 <Bar value={v} tone={tone ?? (v >= 80 ? "hot" : v >= 60 ? "high" : "signal")} />265 </div>266 );267}268function Cell({ v, l }: { v: number; l: string }) {269 return (270 <div className="rounded border border-line bg-panel-2 py-1.5">271 <div className="font-mono text-base font-semibold tabular">{v}</div>272 <div className="label !text-[9.5px]">{l}</div>273 </div>274 );275}276function FragmentRow({ label, value }: { label: string; value: string }) {277 return (278 <>279 <dt className="text-fg-subtle">{label}</dt>280 <dd className="truncate font-mono text-fg-muted tabular">{value}</dd>281 </>282 );283}284function DrawerSkeleton() {285 return (286 <div className="flex flex-col gap-3">287 <Skeleton className="h-3 w-32" />288 <Skeleton className="h-5 w-11/12" />289 <Skeleton className="h-5 w-3/4" />290 <Skeleton className="h-16 w-full" />291 <Skeleton className="h-28 w-full" />292 </div>293 );294}295296export function BookmarkButton({ eventId, label = false }: { eventId: string; label?: boolean }) {297 const [state, setState] = useState<"idle" | "on" | "busy">("idle");298 useEffect(() => {299 let c = false;300 ownerFetch<{ ids: string[] }>("/api/v1/bookmarks/ids")301 .then((r) => {302 if (!c && r.ids.includes(eventId)) setState("on");303 })304 .catch(() => undefined);305 return () => {306 c = true;307 };308 }, [eventId]);309 const toggle = async (): Promise<void> => {310 const was = state;311 setState("busy");312 try {313 if (was === "on") {314 await ownerFetch(`/api/v1/bookmarks/${encodeURIComponent(eventId)}`, { method: "DELETE" });315 setState("idle");316 } else {317 await ownerFetch("/api/v1/bookmarks", { method: "POST", body: JSON.stringify({ event_id: eventId }) });318 setState("on");319 }320 } catch {321 setState(was);322 }323 };324 return (325 <button type="button" onClick={toggle} disabled={state === "busy"} aria-pressed={state === "on"} title={state === "on" ? "Remove bookmark" : "Bookmark"} className={`inline-flex h-7 items-center gap-1 rounded-md border border-line px-1.5 text-[12px] ${state === "on" ? "text-signal" : "text-fg-muted hover:text-fg"}`}>326 {state === "on" ? <BookmarkCheck className="size-3.5" /> : <Bookmark className="size-3.5" />}327 {label && (state === "on" ? "Saved" : "Save")}328 </button>329 );330}331