SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
5.2 KB · 133 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 { ErrorState } from "@/components/ui/states";10import { SkeletonRows } from "@/components/ui/skeleton";11import { apiGet, clientApiBase } from "@/lib/api";12import { useInterval } from "@/lib/hooks";1314export interface LiveQuery {15  type?: string;16  country?: string;17  operator?: string;18  min_significance?: number;19}2021interface Props {22  initial: EventDTO[];23  initialTotal: number;24  perPage: number;25  query: LiveQuery;26  /** SSR failed → show error state until polling succeeds */27  initialError?: string;28}2930/**31 * Change feed with polling. SSR passes the first page; the client polls /events?since= every 60 s,32 * shows an "N new updates" pill, and prepends them on demand. "Load more" pages with ?page=.33 */34export function LiveFeed({ initial, initialTotal, perPage, query, initialError }: Props) {35  const [events, setEvents] = useState<EventDTO[]>(initial);36  const [pending, setPending] = useState<EventDTO[]>([]);37  const [loadingMore, setLoadingMore] = useState(false);38  const [error, setError] = useState<string | null>(initialError ?? null);39  const [exhausted, setExhausted] = useState(initial.length >= initialTotal || initial.length < perPage);40  const [fresh, setFresh] = useState<Set<string>>(new Set());41  const sinceRef = useRef<string>(initial[0]?.detectedAt ?? new Date().toISOString());42  const seen = useRef(new Set(initial.map((e) => e.id)));4344  const [syncedInitial, setSyncedInitial] = useState(initial);45  if (syncedInitial !== initial) {46    setSyncedInitial(initial);47    setEvents(initial);48    setPending([]);49    setExhausted(initial.length >= initialTotal || initial.length < perPage);50    setError(initialError ?? null);51  }52  useEffect(() => {53    sinceRef.current = initial[0]?.detectedAt ?? new Date().toISOString();54    seen.current = new Set(initial.map((e) => e.id));55  }, [initial]);5657  const base = useMemo(() => clientApiBase(), []);5859  const poll = useCallback(async () => {60    if (typeof document !== "undefined" && document.hidden) return;61    const r = await apiGet<EventDTO[] | { items: EventDTO[] }>("/events", { ...query, since: sinceRef.current, per_page: 100 }, { base, timeoutMs: 8000 });62    if (!r.ok) return;63    const items = Array.isArray(r.data) ? r.data : (r.data.items ?? []);64    const news = items.filter((e) => !seen.current.has(e.id));65    if (!news.length) {66      setError(null);67      return;68    }69    for (const e of news) seen.current.add(e.id);70    const newest = news.reduce((a, e) => (e.detectedAt > a ? e.detectedAt : a), sinceRef.current);71    sinceRef.current = newest;72    setError(null);73    setPending((p) => [...news, ...p].sort((a, b) => (a.detectedAt < b.detectedAt ? 1 : -1)));74  }, [query, base]);7576  useInterval(poll, 60_000);77  useEffect(() => {78    if (initialError) void poll();79  }, [initialError, poll]);8081  const reveal = () => {82    setEvents((ev) => [...pending, ...ev]);83    setFresh(new Set(pending.map((e) => e.id)));84    setPending([]);85    window.scrollTo({ top: 0, behavior: "smooth" });86    setTimeout(() => setFresh(new Set()), 2500);87  };8889  const loadMore = async () => {90    setLoadingMore(true);91    const nextPage = Math.floor(events.length / perPage) + 1;92    const r = await apiGet<EventDTO[] | { items: EventDTO[] }>("/events", { ...query, page: nextPage, per_page: perPage }, { base });93    setLoadingMore(false);94    if (!r.ok) {95      setError(r.error);96      return;97    }98    const items = (Array.isArray(r.data) ? r.data : (r.data.items ?? [])).filter((e) => !seen.current.has(e.id));99    for (const e of items) seen.current.add(e.id);100    setEvents((ev) => [...ev, ...items]);101    if (items.length < perPage) setExhausted(true);102  };103104  return (105    <div className="relative">106      {pending.length > 0 && (107        <div className="sticky top-[5.5rem] z-[30] flex justify-center sm:top-[6rem]">108          <button type="button" onClick={reveal} className="inline-flex h-9 items-center gap-1.5 rounded-full border border-accent bg-accent px-4 text-[12.5px] font-medium text-white animate-fade-in">109            <ArrowUp className="size-3.5" /> {pending.length} new {pending.length === 1 ? "update" : "updates"}110          </button>111        </div>112      )}113      {error && events.length === 0 ? (114        <ErrorState compact detail={error} title="Change feed unavailable" description="Retrying automatically every minute." />115      ) : (116        <EventList events={events} groupByDay highlightIds={fresh} />117      )}118      <div className={cn("flex flex-col items-center gap-2 py-6", events.length === 0 && "hidden")}>119        {loadingMore ? (120          <SkeletonRows rows={3} className="w-full" />121        ) : exhausted ? (122          <span className="text-[12px] text-ink-4">End of feed</span>123        ) : (124          <Button onClick={loadMore} size="md">125            <RefreshCw className="size-3.5" /> Load more126          </Button>127        )}128        {error && events.length > 0 && <span className="text-[11.5px] text-warn">Could not load more ({error}).</span>}129      </div>130    </div>131  );132}133