SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
6 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
5.4 KB · 127 lines tsx
Raw Blame History
1"use client";23import type { EventDTO } from "@dci/core";4import { ArrowUp, RefreshCw } from "lucide-react";5import { useCallback, useEffect, useMemo, useRef, useState } from "react";6import { EventList } from "@/components/event-list";7import { Button } from "@/components/ui/button";8import { cn } from "@/components/ui/cn";9import { SkeletonRows } from "@/components/ui/skeleton";10import { ErrorState } from "@/components/ui/states";11import { apiGet, clientApiBase, type Query } from "@/lib/api";12import { useInterval } from "@/lib/hooks";1314interface Props {15  initial: EventDTO[];16  initialTotal: number;17  perPage: number;18  /** the exact /events query (already includes dedupe / min_significance) */19  apiQuery: Query;20  initialError?: string;21}2223/**24 * Live feed 2.0: SSR first page, then polls /events?since= every 60 s with the SAME query (families, entity,25 * significance band, source kind, AI, dedupe), shows an "N new updates" pill and prepends on demand; "Load more" pages.26 */27export function LiveFeed2({ initial, initialTotal, perPage, apiQuery, initialError }: Props) {28  const [events, setEvents] = useState<EventDTO[]>(initial);29  const [pending, setPending] = useState<EventDTO[]>([]);30  const [loadingMore, setLoadingMore] = useState(false);31  const [error, setError] = useState<string | null>(initialError ?? null);32  const [exhausted, setExhausted] = useState(initial.length >= initialTotal || initial.length < perPage);33  const [fresh, setFresh] = useState<Set<string>>(new Set());34  const sinceRef = useRef<string>(initial[0]?.detectedAt ?? new Date().toISOString());35  const seen = useRef(new Set(initial.map((e) => e.id)));3637  const [syncedInitial, setSyncedInitial] = useState(initial);38  if (syncedInitial !== initial) {39    setSyncedInitial(initial);40    setEvents(initial);41    setPending([]);42    setExhausted(initial.length >= initialTotal || initial.length < perPage);43    setError(initialError ?? null);44  }45  useEffect(() => {46    sinceRef.current = initial[0]?.detectedAt ?? new Date().toISOString();47    seen.current = new Set(initial.map((e) => e.id));48  }, [initial]);4950  const base = useMemo(() => clientApiBase(), []);51  const sig = JSON.stringify(apiQuery);52  const query = useMemo<Query>(() => JSON.parse(sig) as Query, [sig]);5354  const poll = useCallback(async () => {55    if (typeof document !== "undefined" && document.hidden) return;56    const r = await apiGet<EventDTO[] | { items: EventDTO[] }>("/events", { ...query, since: sinceRef.current, per_page: 100 }, { base, timeoutMs: 8000 });57    if (!r.ok) return;58    const items = Array.isArray(r.data) ? r.data : (r.data.items ?? []);59    const news = items.filter((e) => !seen.current.has(e.id));60    if (!news.length) {61      setError(null);62      return;63    }64    for (const e of news) seen.current.add(e.id);65    sinceRef.current = news.reduce((a, e) => (e.detectedAt > a ? e.detectedAt : a), sinceRef.current);66    setError(null);67    setPending((p) => [...news, ...p].sort((a, b) => (a.detectedAt < b.detectedAt ? 1 : -1)));68  }, [query, base]);6970  useInterval(poll, 60_000);71  useEffect(() => {72    if (initialError) void poll();73  }, [initialError, poll]);7475  const reveal = () => {76    setEvents((ev) => [...pending, ...ev]);77    setFresh(new Set(pending.map((e) => e.id)));78    setPending([]);79    window.scrollTo({ top: 0, behavior: "smooth" });80    setTimeout(() => setFresh(new Set()), 2500);81  };8283  const loadMore = async () => {84    setLoadingMore(true);85    const nextPage = Math.floor(events.length / perPage) + 1;86    const r = await apiGet<EventDTO[] | { items: EventDTO[] }>("/events", { ...query, page: nextPage, per_page: perPage }, { base });87    setLoadingMore(false);88    if (!r.ok) {89      setError(r.error);90      return;91    }92    const items = (Array.isArray(r.data) ? r.data : (r.data.items ?? [])).filter((e) => !seen.current.has(e.id));93    for (const e of items) seen.current.add(e.id);94    setEvents((ev) => [...ev, ...items]);95    if (items.length < perPage) setExhausted(true);96  };9798  return (99    <div className="relative">100      {pending.length > 0 && (101        <div className="sticky top-[6.5rem] z-[30] flex justify-center sm:top-[7rem]">102          <button type="button" onClick={reveal} className="inline-flex h-10 items-center gap-1.5 rounded-full border border-accent bg-accent px-4 text-[12.5px] font-medium text-white animate-fade-in">103            <ArrowUp className="size-3.5" /> {pending.length} new {pending.length === 1 ? "update" : "updates"}104          </button>105        </div>106      )}107      {error && events.length === 0 ? (108        <ErrorState compact detail={error} title="Change feed unavailable" description="Retrying automatically every minute." />109      ) : (110        <EventList events={events} groupByDay highlightIds={fresh} emptyTitle="No change matches these filters" emptyDescription="Minor changes are hidden by default — switch on “Show all changes” or loosen a filter." />111      )}112      <div className={cn("flex flex-col items-center gap-2 py-6", events.length === 0 && "hidden")}>113        {loadingMore ? (114          <SkeletonRows rows={3} className="w-full" />115        ) : exhausted ? (116          <span className="text-[12px] text-ink-4">End of feed</span>117        ) : (118          <Button onClick={loadMore} size="md">119            <RefreshCw className="size-3.5" /> Load more120          </Button>121        )}122        {error && events.length > 0 && <span className="text-[11.5px] text-warn">Could not load more ({error}).</span>}123      </div>124    </div>125  );126}127