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%
16.7 KB · 294 lines tsx
Raw Blame History
1"use client";23import Link from "next/link";4import { usePathname, useRouter, useSearchParams } from "next/navigation";5import { ArrowUp, Pause, Play, SlidersHorizontal, X } from "lucide-react";6import { useCallback, useEffect, useMemo, useRef, useState } from "react";7import { liveToEvent, type EventItem, type EventQuery, type LiveEvent, eventQueryString } from "@/lib/api";8import { CHANNELS, GROUP_LABELS, SAVED_VIEWS, agoIso, feedHref } from "@/lib/format";9import type { FeedFilters } from "@/lib/feed-filters";10import { publicFetch } from "@/lib/owner";11import { useLive, type LiveStatus } from "@/lib/use-live";12import { EventRow } from "./event-row";13import { DensityToggle, usePrefs } from "./prefs";14import { Chip, Empty, Kbd } from "./ui";1516const MAX_ROWS = 400;1718export function LiveDot({ status, paused = false }: { status: LiveStatus; paused?: boolean }) {19  const label = paused ? "PAUSED" : status === "live" ? "LIVE" : status === "connecting" ? "CONNECTING" : status === "reconnecting" ? "RECONNECTING" : "OFFLINE";20  const color = paused ? "bg-warn" : status === "live" ? "bg-signal animate-pulse-dot" : status === "offline" ? "bg-danger" : "bg-warn";21  return (22    <span className="inline-flex items-center gap-1.5 font-mono text-[11px] font-semibold tracking-wider text-fg-muted" aria-live="polite">23      <span className={`inline-block size-2 rounded-full ${color}`} />24      {label}25    </span>26  );27}2829export type { FeedFilters } from "@/lib/feed-filters";3031function matchesFilters(ev: EventItem, f: FeedFilters, fixedQuery?: EventQuery): boolean {32  const sig = ev.signal_score ?? ev.importance;33  if (f.signal_min !== undefined && sig < f.signal_min) return false;34  if (f.importance_min !== undefined && ev.importance < f.importance_min) return false;35  if (f.silent_change && !ev.silent_change) return false;36  if (f.first_party && ev.first_party === false) return false;37  if (f.confirmed && ev.evidence_label !== "CONFIRMED") return false;38  if (f.country && ev.country !== f.country) return false;39  if (f.language && ev.language !== f.language) return false;40  if (f.category && !ev.categories.includes(f.category)) return false;41  if (f.event_type && !f.event_type.split(",").includes(ev.event_type)) return false;42  if (f.q) return false; // text search results are not extended live43  if (fixedQuery?.source && ev.source_id !== fixedQuery.source) return false;44  if (fixedQuery?.entity && !ev.entities.some((x) => x.id === fixedQuery.entity)) return false;45  if (fixedQuery?.category && !ev.categories.includes(fixedQuery.category)) return false;46  if (fixedQuery?.silent_change && !ev.silent_change) return false;47  return true;48}4950/**51 * The live feed v2 (spec §31, §97–99): initial REST page, WebSocket prepends, URL-synced filters,52 * pause, "↑ N new events" without scroll jumps, density modes, cursor pagination, drawer on click.53 * `fixed` restricts the feed to one channel (used by /breaking, /silent, /category/*).54 */55export function LiveFeed({ initial, initialCursor, fixed, showTabs = true, title = "LIVE WEB", extraQuery, syncUrl = false, initialFilters, showFilters = true, compactHeader = false }: { initial: EventItem[]; initialCursor: string | null; fixed?: string; showTabs?: boolean; title?: string; extraQuery?: EventQuery; syncUrl?: boolean; initialFilters?: FeedFilters; showFilters?: boolean; compactHeader?: boolean }) {56  const router = useRouter();57  const pathname = usePathname();58  const sp = useSearchParams();59  const { paused, setPaused } = usePrefs();60  const [tab, setTab] = useState(fixed ?? initialFilters?.category ?? "all");61  const [filters, setFilters] = useState<FeedFilters>(initialFilters ?? {});62  const [items, setItems] = useState<EventItem[]>(initial);63  const [cursor, setCursor] = useState<string | null>(initialCursor);64  const [loading, setLoading] = useState(false);65  const [fresh, setFresh] = useState<Set<string>>(new Set());66  const [pending, setPending] = useState<EventItem[]>([]);67  const [atTop, setAtTop] = useState(true);68  const [now, setNow] = useState(() => Date.now());69  const [showFilterBar, setShowFilterBar] = useState(Boolean(initialFilters && Object.keys(initialFilters).length));70  const seen = useRef(new Set(initial.map((i) => i.id)));71  const topRef = useRef<HTMLDivElement>(null);72  const chan = useMemo(() => CHANNELS.find((c) => c.key === tab) ?? CHANNELS[0]!, [tab]);7374  // sync URL ← filters (spec §99)75  useEffect(() => {76    if (!syncUrl) return;77    const href = feedHref({ ...filters, category: tab !== "all" && tab !== "breaking" && tab !== "silent" ? tab : filters.category, silent_change: tab === "silent" ? true : filters.silent_change, signal_min: tab === "breaking" ? Math.max(80, filters.signal_min ?? 0) : filters.signal_min }, pathname);78    const current = `${pathname}${sp.toString() ? `?${sp.toString()}` : ""}`;79    if (href !== current) router.replace(href, { scroll: false });80    // eslint-disable-next-line react-hooks/exhaustive-deps81  }, [filters, tab, syncUrl, pathname]);8283  useEffect(() => {84    const t = setInterval(() => setNow(Date.now()), 10_000);85    return () => clearInterval(t);86  }, []);87  // Is the top of the list visible? If not, new events are buffered (spec §97).88  useEffect(() => {89    const el = topRef.current;90    if (!el) return;91    const io = new IntersectionObserver(([e]) => setAtTop(Boolean(e?.isIntersecting)), { rootMargin: "80px 0px 0px 0px" });92    io.observe(el);93    return () => io.disconnect();94  }, []);9596  const query = useMemo<EventQuery>(() => {97    const q: EventQuery = { ...(extraQuery ?? {}), ...(chan.query as EventQuery), ...filters, limit: 60 };98    if (chan.key === "breaking") {99      q.order = q.order ?? "signal";100      q.signal_min = Math.max(80, q.signal_min ?? 0);101      q.after = agoIso(48 * 3600e3);102    }103    return q;104  }, [chan, extraQuery, filters]);105106  const load = useCallback(107    async (reset: boolean, c: string | null) => {108      setLoading(true);109      try {110        const page = await publicFetch<{ items: EventItem[]; nextCursor: string | null }>(`/api/v1/events${eventQueryString({ ...query, cursor: reset ? undefined : (c ?? undefined) })}`);111        setItems((prev) => {112          const base = reset ? [] : prev;113          const ids = new Set(base.map((i) => i.id));114          const merged = [...base, ...page.items.filter((i) => !ids.has(i.id))];115          seen.current = new Set(merged.map((i) => i.id));116          return merged;117        });118        setCursor(page.nextCursor);119        if (reset) setPending([]);120      } catch {121        // keep current list122      } finally {123        setLoading(false);124      }125    },126    [query],127  );128129  const first = useRef(true);130  useEffect(() => {131    if (first.current) {132      first.current = false;133      return;134    }135    void load(true, null);136  }, [load]);137138  const flushPending = useCallback(() => {139    setPending((p) => {140      if (!p.length) return p;141      setItems((prev) => [...p, ...prev].slice(0, MAX_ROWS));142      setFresh((prevF) => new Set([...prevF, ...p.map((x) => x.id)]));143      setTimeout(() => setFresh(new Set()), 1800);144      return [];145    });146    topRef.current?.scrollIntoView({ block: "start", behavior: "smooth" });147  }, []);148149  const onEvent = useCallback(150    (e: LiveEvent) => {151      if (seen.current.has(e.id)) return;152      const ev = liveToEvent(e);153      if (!matchesFilters(ev, filters, extraQuery)) return;154      if (chan.key === "breaking" && (ev.signal_score ?? ev.importance) < 80) return;155      if (chan.key === "silent" && !ev.silent_change) return;156      seen.current.add(e.id);157      if (paused || !atTop || query.order === "signal" || query.order === "importance") {158        setPending((p) => [ev, ...p].slice(0, 200));159        return;160      }161      setItems((prev) => [ev, ...prev].slice(0, MAX_ROWS));162      setFresh((prev) => new Set([...prev, e.id]));163      setTimeout(164        () =>165          setFresh((prev) => {166            const n = new Set(prev);167            n.delete(e.id);168            return n;169          }),170        1800,171      );172    },173    [filters, extraQuery, chan.key, paused, atTop, query.order],174  );175  const wsChannel = extraQuery?.source ? `source:${extraQuery.source}` : extraQuery?.entity ? `entity:${extraQuery.entity}` : filters.country && chan.key === "all" ? `country:${filters.country}` : filters.group && chan.key === "all" ? `group:${filters.group}` : chan.ws;176  const wsChannels = useMemo(() => [wsChannel], [wsChannel]);177  const status = useLive(wsChannels, onEvent);178179  const setF = (patch: Partial<FeedFilters>): void => setFilters((f) => {180    const n = { ...f, ...patch };181    for (const k of Object.keys(n) as (keyof FeedFilters)[]) if (n[k] === undefined || n[k] === false || n[k] === "") delete n[k];182    return n;183  });184  const activeFilterCount = Object.keys(filters).length;185186  return (187    <section className="panel overflow-hidden" aria-label={title}>188      <header className={`flex flex-wrap items-center gap-2 border-b border-line px-3 ${compactHeader ? "py-1.5" : "py-2"}`}>189        <h2 className="label mr-1 !text-fg">{title}</h2>190        <LiveDot status={status} paused={paused} />191        <span className="hidden font-mono text-[11px] text-fg-subtle tabular sm:inline">{items.length} events</span>192        <span className="ml-auto flex items-center gap-1">193          {showFilters && (194            <button type="button" onClick={() => setShowFilterBar((v) => !v)} aria-expanded={showFilterBar} className={`inline-flex h-7 items-center gap-1 rounded-md border border-line px-2 text-[11.5px] ${activeFilterCount ? "border-signal/50 text-signal" : "text-fg-muted hover:text-fg"}`} title="Filters">195              <SlidersHorizontal className="size-3.5" /> <span className="hidden sm:inline">Filters</span>196              {activeFilterCount > 0 && <span className="font-mono">{activeFilterCount}</span>}197            </button>198          )}199          <button type="button" onClick={() => setPaused(!paused)} aria-pressed={paused} className={`inline-flex h-7 items-center gap-1 rounded-md border border-line px-2 text-[11.5px] ${paused ? "border-warn/50 text-warn" : "text-fg-muted hover:text-fg"}`} title={paused ? "Resume live" : "Pause live (backend keeps ingesting)"}>200            {paused ? <Play className="size-3.5" /> : <Pause className="size-3.5" />} <span className="hidden sm:inline">{paused ? "Resume" : "Pause"}</span>201          </button>202          <DensityToggle compact />203        </span>204      </header>205      {showTabs && !fixed && (206        <div className="flex gap-1 overflow-x-auto border-b border-line px-2 py-1.5 no-scrollbar" role="tablist" aria-label="Channels">207          {CHANNELS.map((c) => (208            <button key={c.key} type="button" role="tab" aria-selected={tab === c.key} onClick={() => setTab(c.key)} className={`whitespace-nowrap rounded-md px-2 py-1 text-[12px] ${tab === c.key ? "bg-panel-2 text-fg" : "text-fg-muted hover:text-fg"} ${c.key === "silent" ? "text-silent" : c.key === "breaking" ? "text-hot" : ""}`}>209              {c.label}210            </button>211          ))}212        </div>213      )}214      {showFilters && showFilterBar && (215        <div className="flex flex-wrap items-center gap-1.5 border-b border-line bg-panel-2/40 px-3 py-2 text-[12px]">216          <Toggle on={Boolean(filters.silent_change)} onClick={() => setF({ silent_change: !filters.silent_change || undefined })} tone="silent">Silent</Toggle>217          <Toggle on={Boolean(filters.first_party)} onClick={() => setF({ first_party: !filters.first_party || undefined })} tone="signal">First-party</Toggle>218          <Toggle on={Boolean(filters.confirmed)} onClick={() => setF({ confirmed: !filters.confirmed || undefined })} tone="ok">Confirmed</Toggle>219          <select aria-label="Minimum signal" value={filters.signal_min ?? ""} onChange={(e) => setF({ signal_min: e.target.value ? Number(e.target.value) : undefined })} className="h-6 rounded-md border border-line bg-panel px-1.5 text-[11.5px]">220            <option value="">signal ≥ any</option>221            <option value="50">signal ≥ 50</option>222            <option value="65">signal ≥ 65</option>223            <option value="80">signal ≥ 80</option>224            <option value="90">signal ≥ 90</option>225          </select>226          <select aria-label="Event group" value={filters.group ?? ""} onChange={(e) => setF({ group: e.target.value || undefined })} className="h-6 max-w-[11rem] rounded-md border border-line bg-panel px-1.5 text-[11.5px]">227            <option value="">all groups</option>228            {Object.entries(GROUP_LABELS).map(([k, v]) => (229              <option key={k} value={k}>{v}</option>230            ))}231          </select>232          <select aria-label="Order" value={filters.order ?? "recent"} onChange={(e) => setF({ order: e.target.value === "recent" ? undefined : (e.target.value as FeedFilters["order"]) })} className="h-6 rounded-md border border-line bg-panel px-1.5 text-[11.5px]">233            <option value="recent">newest first</option>234            <option value="signal">highest signal</option>235            <option value="importance">highest importance</option>236          </select>237          <input aria-label="Country" value={filters.country ?? ""} onChange={(e) => setF({ country: e.target.value.toUpperCase().slice(0, 3) || undefined })} placeholder="CC" maxLength={3} className="h-6 w-12 rounded-md border border-line bg-panel px-1.5 font-mono text-[11.5px] uppercase placeholder:text-fg-subtle" title="Country (ISO code)" />238          {filters.event_type && <Chip tone="info">type: {filters.event_type.replace(/_/g, " ")} <button type="button" aria-label="Clear type" onClick={() => setF({ event_type: undefined })}><X className="size-3" /></button></Chip>}239          {filters.q && <Chip tone="info">“{filters.q}” <button type="button" aria-label="Clear search" onClick={() => setF({ q: undefined })}><X className="size-3" /></button></Chip>}240          <span className="ml-auto flex items-center gap-1">241            <span className="text-[11px] text-fg-subtle">views:</span>242            {SAVED_VIEWS.slice(0, 4).map((v) => (243              <Link key={v.key} href={feedHref(v.query, "/live")} className="rounded-sm border border-line px-1.5 py-px text-[10.5px] text-fg-muted hover:text-fg">{v.label}</Link>244            ))}245            {activeFilterCount > 0 && (246              <button type="button" onClick={() => setFilters({})} className="rounded-sm px-1.5 py-px text-[10.5px] text-fg-subtle hover:text-fg">clear</button>247            )}248          </span>249        </div>250      )}251      <div ref={topRef} aria-hidden className="h-px" />252      {pending.length > 0 && (253        <div className="sticky top-12 z-20 flex justify-center py-1">254          <button type="button" onClick={flushPending} className="inline-flex items-center gap-1.5 rounded-full border border-signal/50 bg-panel px-3 py-1 text-[12px] font-medium text-signal shadow-lg hover:bg-signal-soft">255            <ArrowUp className="size-3.5" /> {pending.length} new event{pending.length === 1 ? "" : "s"}{paused ? " · paused" : ""}256          </button>257        </div>258      )}259      <div role="feed" aria-busy={loading}>260        {items.length === 0 && !loading && (261          <Empty>262            {activeFilterCount ? "No events match these filters. Live events that match will appear here." : "No events in this channel yet. Sensors are being checked continuously — meaningful changes will appear here the moment they are detected."}263          </Empty>264        )}265        {items.map((ev) => (266          <EventRow key={ev.id} ev={ev} fresh={fresh.has(ev.id)} now={now} replayed={Boolean((ev as EventItem & { replayed?: boolean }).replayed)} />267        ))}268      </div>269      <footer className="flex items-center justify-between px-3 py-2 text-[12px] text-fg-subtle">270        <span className="inline-flex items-center gap-2">271          Showing {items.length}{items.length >= MAX_ROWS ? ` (capped at ${MAX_ROWS})` : ""}272          <span className="hidden items-center gap-1 sm:inline-flex"><Kbd>⌘K</Kbd> search</span>273        </span>274        {cursor ? (275          <button type="button" disabled={loading} onClick={() => load(false, cursor)} className="rounded-md border border-line bg-panel-2 px-2.5 py-1 text-[12px] text-fg hover:border-line-strong disabled:opacity-50">276            {loading ? "Loading…" : "Load more"}277          </button>278        ) : (279          <Link href="/explore" className="hover:text-fg">Explore →</Link>280        )}281      </footer>282    </section>283  );284}285286function Toggle({ on, onClick, children, tone }: { on: boolean; onClick: () => void; children: React.ReactNode; tone: "silent" | "signal" | "ok" }) {287  const onCls = tone === "silent" ? "border-silent/50 bg-silent-soft text-silent" : tone === "ok" ? "border-ok/50 bg-ok/10 text-ok" : "border-signal/50 bg-signal-soft text-signal";288  return (289    <button type="button" aria-pressed={on} onClick={onClick} className={`h-6 rounded-md border px-2 text-[11.5px] ${on ? onCls : "border-line text-fg-muted hover:text-fg"}`}>290      {children}291    </button>292  );293}294