spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
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