SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
2.8 KB · 88 lines typescript
Raw Blame History
1"use client";23import { useCallback, useEffect, useRef, useState } from "react";4import { api, ApiClientError } from "@/lib/api";5import { useAdmin } from "./store";67interface QueryState<T> {8  path: string | null;9  data: T | null;10  error: ApiClientError | null;11  at: number;12}1314export interface QueryResult<T> {15  /** Latest data — kept (and flagged `stale`) while a new path is loading, so tables never flash. */16  data: T | null;17  error: ApiClientError | null;18  /** True until the first response for the current path arrives. */19  loading: boolean;20  /** `data` belongs to a previous path. */21  stale: boolean;22  /** A user-triggered refresh is in flight. */23  refreshing: boolean;24  refresh: () => Promise<void>;25  /** Replace the cached data locally (optimistic updates). */26  mutate: (fn: (prev: T) => T) => void;27  updatedAt: number;28}2930/** Client-side data hook for `/api/admin/*`. A 401 flips the admin gate back to the login form. */31export function useAdminQuery<T>(path: string | null, opts: { refreshMs?: number } = {}): QueryResult<T> {32  const [state, setState] = useState<QueryState<T>>({ path: null, data: null, error: null, at: 0 });33  const [refreshing, setRefreshing] = useState(false);34  const seq = useRef(0);35  const refreshMs = opts.refreshMs ?? 0;3637  const run = useCallback(async (p: string) => {38    const my = ++seq.current;39    try {40      const data = await api<T>(p);41      if (my !== seq.current) return;42      setState({ path: p, data, error: null, at: Date.now() });43    } catch (e) {44      if (my !== seq.current) return;45      const err = e instanceof ApiClientError ? e : new ApiClientError(0, "UNKNOWN", e instanceof Error ? e.message : "Unexpected error.");46      if (err.status === 401) useAdmin.getState().signedOut();47      setState((s) => ({ path: p, data: s.path === p ? s.data : null, error: err, at: Date.now() }));48    }49  }, []);5051  useEffect(() => {52    if (!path) return;53    const first = setTimeout(() => void run(path), 0);54    const t = refreshMs55      ? setInterval(() => {56          if (document.visibilityState === "visible") void run(path);57        }, refreshMs)58      : null;59    return () => {60      clearTimeout(first);61      if (t) clearInterval(t);62    };63  }, [path, run, refreshMs]);6465  const refresh = useCallback(async () => {66    if (!path) return;67    setRefreshing(true);68    await run(path);69    setRefreshing(false);70  }, [path, run]);7172  const mutate = useCallback((fn: (prev: T) => T) => {73    setState((s) => (s.data ? { ...s, data: fn(s.data) } : s));74  }, []);7576  const current = state.path === path;77  return {78    data: state.data,79    error: current ? state.error : null,80    loading: path !== null && !current,81    stale: path !== null && !current && state.data !== null,82    refreshing,83    refresh,84    mutate,85    updatedAt: state.at,86  };87}88