SPB Git forge
7commits 1branches 0releases
229.0 KBsize
maindefault branch
12 days agolast push
TypeScript 91.8% HTML 3.2% JavaScript 3% SQL 1.4% CSS 0.7%
3.4 KB · 95 lines typescript
Raw Blame History
1"use client";23import { useCallback, useEffect, useRef, useState } from "react";45/** Client-side data layer: every call goes through the Next proxy (/api/*) which adds the API token. */6export async function api<T = unknown>(path: string, init?: RequestInit): Promise<T> {7  const res = await fetch(`/api${path}`, { ...init, headers: { "content-type": "application/json", ...(init?.headers ?? {}) }, cache: "no-store" });8  if (!res.ok) {9    let msg = `${res.status}`;10    try {11      msg = ((await res.json()) as { error?: string }).error ?? msg;12    } catch {13      /* ignore */14    }15    throw new Error(msg);16  }17  return (await res.json()) as T;18}1920export function useApi<T>(path: string | null, opts: { refreshMs?: number; deps?: unknown[] } = {}) {21  const [data, setData] = useState<T | null>(null);22  const [error, setError] = useState<string | null>(null);23  const [loading, setLoading] = useState(!!path);24  const [tick, setTick] = useState(0);25  const refresh = useCallback(() => setTick((t) => t + 1), []);26  useEffect(() => {27    if (!path) return;28    let alive = true;29    api<T>(path)30      .then((d) => {31        if (!alive) return;32        setData(d);33        setError(null);34      })35      .catch((e: Error) => alive && setError(e.message))36      .finally(() => alive && setLoading(false));37    const id = opts.refreshMs ? setInterval(() => setTick((t) => t + 1), opts.refreshMs) : undefined;38    return () => {39      alive = false;40      if (id) clearInterval(id);41    };42    // eslint-disable-next-line react-hooks/exhaustive-deps43  }, [path, tick, ...(opts.deps ?? [])]);44  return { data, error, loading, refresh };45}4647export interface LiveEvent {48  event_id: string;49  session_id: string;50  platform: string;51  event_type: string;52  step: number | null;53  ts: string;54  payload: Record<string, unknown>;55  provenance?: { surface: string; confidence: number }[] | null;56}5758/** Server-Sent Events subscription (live feed). Keeps the last `max` events. */59export function useLive(opts: { session?: string; types?: string[]; max?: number; enabled?: boolean } = {}) {60  const [events, setEvents] = useState<LiveEvent[]>([]);61  const [connected, setConnected] = useState(false);62  const [rate, setRate] = useState(0); // events per minute (rolling)63  const stamps = useRef<number[]>([]);64  const max = opts.max ?? 250;65  const enabled = opts.enabled !== false;66  useEffect(() => {67    if (!enabled) return;68    const params = new URLSearchParams();69    if (opts.session) params.set("session", opts.session);70    if (opts.types?.length) params.set("types", opts.types.join(","));71    const es = new EventSource(`/api/stream?${params}`);72    es.onopen = () => setConnected(true);73    es.onerror = () => setConnected(false);74    es.addEventListener("observation", (m) => {75      const ev = JSON.parse((m as MessageEvent).data) as LiveEvent;76      const now = Date.now();77      stamps.current.push(now);78      stamps.current = stamps.current.filter((t) => now - t < 60_000);79      setRate(stamps.current.length);80      setEvents((prev) => [ev, ...prev].slice(0, max));81    });82    const id = setInterval(() => {83      const now = Date.now();84      stamps.current = stamps.current.filter((t) => now - t < 60_000);85      setRate(stamps.current.length);86    }, 5000);87    return () => {88      es.close();89      clearInterval(id);90    };91    // eslint-disable-next-line react-hooks/exhaustive-deps92  }, [opts.session, opts.types?.join(","), enabled, max]);93  return { events, connected, rate };94}95