spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
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