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%
4.7 KB · 123 lines typescript
Raw Blame History
1"use client";23import { useCallback, useEffect, useRef, useState } from "react";4import { adminFetch, type AdminQuery, type AdminResult } from "@/lib/admin-api";56export interface AdminQueryState<T> {7  data: T | null;8  meta: Record<string, unknown> | undefined;9  error: string | null;10  status: number;11  /** no result yet for the current path/query */12  loading: boolean;13  /** a background refresh is in flight while data is shown */14  refreshing: boolean;15  updatedAt: number | null;16  refresh: () => Promise<void>;17  /** optimistic local update */18  mutate: (fn: (prev: T | null) => T | null) => void;19}2021interface Options {22  /** auto-refresh interval, ms (null = off). Paused while the tab is hidden. */23  refreshMs?: number | null;24  enabled?: boolean;25  query?: AdminQuery;26  timeoutMs?: number;27}2829interface Slot<T> {30  key: string;31  data: T | null;32  meta: Record<string, unknown> | undefined;33  error: string | null;34  status: number;35  updatedAt: number | null;36  refreshing: boolean;37}3839/**40 * Tiny SWR-like hook over the admin proxy: fetch on mount / when `path` or the serialized `query` changes, optional41 * polling, manual refresh, optimistic `mutate`. Aborts in-flight requests on change / unmount. Never throws.42 * `loading` is derived (result key ≠ current key) so no state is written synchronously inside effects.43 */44export function useAdminQuery<T>(path: string | null, opts: Options = {}): AdminQueryState<T> {45  const { refreshMs = null, enabled = true, timeoutMs } = opts;46  const qs = JSON.stringify(opts.query ?? null);47  const key = `${path ?? ""}|${qs}`;48  const active = Boolean(path && enabled);49  const [slot, setSlot] = useState<Slot<T>>({ key: "", data: null, meta: undefined, error: null, status: 0, updatedAt: null, refreshing: false });50  const abortRef = useRef<AbortController | null>(null);51  const keyRef = useRef(key);52  useEffect(() => {53    keyRef.current = key;54  }, [key]);5556  // `load` never writes state before its first await (it is invoked from an effect); the `refreshing` flag is set57  // by `refresh()` / the poller only, which run from timers and event handlers.58  const load = useCallback((): Promise<void> => {59    if (!path || !enabled) return Promise.resolve();60    const myKey = `${path}|${qs}`;61    abortRef.current?.abort();62    const ctrl = new AbortController();63    abortRef.current = ctrl;64    return adminFetch<T>(path, { query: (JSON.parse(qs) as AdminQuery | null) ?? undefined, signal: ctrl.signal, timeoutMs }).then((res: AdminResult<T>) => {65      if (ctrl.signal.aborted || keyRef.current !== myKey) return;66      if (res.ok) setSlot({ key: myKey, data: res.data, meta: res.meta, error: null, status: res.status, updatedAt: Date.now(), refreshing: false });67      else setSlot((s) => ({ key: myKey, data: s.key === myKey ? s.data : null, meta: s.key === myKey ? s.meta : undefined, error: res.error, status: res.status, updatedAt: s.key === myKey ? s.updatedAt : null, refreshing: false }));68    });69  }, [path, enabled, qs, timeoutMs]);7071  useEffect(() => {72    if (!active) return;73    void load();74    return () => abortRef.current?.abort();75  }, [load, active]);7677  const refresh = useCallback(() => {78    setSlot((s) => ({ ...s, refreshing: true }));79    return load();80  }, [load]);8182  useEffect(() => {83    if (!refreshMs || !active) return;84    const tick = () => {85      if (document.visibilityState === "visible") void refresh();86    };87    const t = setInterval(tick, refreshMs);88    document.addEventListener("visibilitychange", tick);89    return () => {90      clearInterval(t);91      document.removeEventListener("visibilitychange", tick);92    };93  }, [refreshMs, refresh, active]);94  const mutate = useCallback((fn: (prev: T | null) => T | null) => setSlot((s) => ({ ...s, data: fn(s.data) })), []);9596  const current = slot.key === key;97  return {98    data: current ? slot.data : null,99    meta: current ? slot.meta : undefined,100    error: current ? slot.error : null,101    status: current ? slot.status : 0,102    loading: active && !current,103    refreshing: slot.refreshing,104    updatedAt: current ? slot.updatedAt : null,105    refresh,106    mutate,107  };108}109110/** Run a mutation through the proxy with a busy flag. */111export function useAdminMutation() {112  const [busy, setBusy] = useState<string | null>(null);113  const run = useCallback(async <T,>(key: string, path: string, init: { method?: "POST" | "PATCH" | "PUT" | "DELETE"; body?: unknown; query?: AdminQuery; timeoutMs?: number }): Promise<AdminResult<T>> => {114    setBusy(key);115    try {116      return await adminFetch<T>(path, { method: init.method ?? "POST", body: init.body ?? {}, query: init.query, timeoutMs: init.timeoutMs });117    } finally {118      setBusy((b) => (b === key ? null : b));119    }120  }, []);121  return { busy, run };122}123