"use client"; import type { EventDTO } from "@dci/core"; import { ArrowUp, RefreshCw } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { EventList } from "@/components/event-list"; import { Button } from "@/components/ui/button"; import { cn } from "@/components/ui/cn"; import { ErrorState } from "@/components/ui/states"; import { SkeletonRows } from "@/components/ui/skeleton"; import { apiGet, clientApiBase } from "@/lib/api"; import { useInterval } from "@/lib/hooks"; export interface LiveQuery { type?: string; country?: string; operator?: string; min_significance?: number; } interface Props { initial: EventDTO[]; initialTotal: number; perPage: number; query: LiveQuery; /** SSR failed → show error state until polling succeeds */ initialError?: string; } /** * Change feed with polling. SSR passes the first page; the client polls /events?since= every 60 s, * shows an "N new updates" pill, and prepends them on demand. "Load more" pages with ?page=. */ export function LiveFeed({ initial, initialTotal, perPage, query, initialError }: Props) { const [events, setEvents] = useState(initial); const [pending, setPending] = useState([]); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(initialError ?? null); const [exhausted, setExhausted] = useState(initial.length >= initialTotal || initial.length < perPage); const [fresh, setFresh] = useState>(new Set()); const sinceRef = useRef(initial[0]?.detectedAt ?? new Date().toISOString()); const seen = useRef(new Set(initial.map((e) => e.id))); const [syncedInitial, setSyncedInitial] = useState(initial); if (syncedInitial !== initial) { setSyncedInitial(initial); setEvents(initial); setPending([]); setExhausted(initial.length >= initialTotal || initial.length < perPage); setError(initialError ?? null); } useEffect(() => { sinceRef.current = initial[0]?.detectedAt ?? new Date().toISOString(); seen.current = new Set(initial.map((e) => e.id)); }, [initial]); const base = useMemo(() => clientApiBase(), []); const poll = useCallback(async () => { if (typeof document !== "undefined" && document.hidden) return; const r = await apiGet("/events", { ...query, since: sinceRef.current, per_page: 100 }, { base, timeoutMs: 8000 }); if (!r.ok) return; const items = Array.isArray(r.data) ? r.data : (r.data.items ?? []); const news = items.filter((e) => !seen.current.has(e.id)); if (!news.length) { setError(null); return; } for (const e of news) seen.current.add(e.id); const newest = news.reduce((a, e) => (e.detectedAt > a ? e.detectedAt : a), sinceRef.current); sinceRef.current = newest; setError(null); setPending((p) => [...news, ...p].sort((a, b) => (a.detectedAt < b.detectedAt ? 1 : -1))); }, [query, base]); useInterval(poll, 60_000); useEffect(() => { if (initialError) void poll(); }, [initialError, poll]); const reveal = () => { setEvents((ev) => [...pending, ...ev]); setFresh(new Set(pending.map((e) => e.id))); setPending([]); window.scrollTo({ top: 0, behavior: "smooth" }); setTimeout(() => setFresh(new Set()), 2500); }; const loadMore = async () => { setLoadingMore(true); const nextPage = Math.floor(events.length / perPage) + 1; const r = await apiGet("/events", { ...query, page: nextPage, per_page: perPage }, { base }); setLoadingMore(false); if (!r.ok) { setError(r.error); return; } const items = (Array.isArray(r.data) ? r.data : (r.data.items ?? [])).filter((e) => !seen.current.has(e.id)); for (const e of items) seen.current.add(e.id); setEvents((ev) => [...ev, ...items]); if (items.length < perPage) setExhausted(true); }; return (
{pending.length > 0 && (
)} {error && events.length === 0 ? ( ) : ( )}
{loadingMore ? ( ) : exhausted ? ( End of feed ) : ( )} {error && events.length > 0 && Could not load more ({error}).}
); }