"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { adminFetch, type AdminQuery, type AdminResult } from "@/lib/admin-api"; export interface AdminQueryState { data: T | null; meta: Record | undefined; error: string | null; status: number; /** no result yet for the current path/query */ loading: boolean; /** a background refresh is in flight while data is shown */ refreshing: boolean; updatedAt: number | null; refresh: () => Promise; /** optimistic local update */ mutate: (fn: (prev: T | null) => T | null) => void; } interface Options { /** auto-refresh interval, ms (null = off). Paused while the tab is hidden. */ refreshMs?: number | null; enabled?: boolean; query?: AdminQuery; timeoutMs?: number; } interface Slot { key: string; data: T | null; meta: Record | undefined; error: string | null; status: number; updatedAt: number | null; refreshing: boolean; } /** * Tiny SWR-like hook over the admin proxy: fetch on mount / when `path` or the serialized `query` changes, optional * polling, manual refresh, optimistic `mutate`. Aborts in-flight requests on change / unmount. Never throws. * `loading` is derived (result key ≠ current key) so no state is written synchronously inside effects. */ export function useAdminQuery(path: string | null, opts: Options = {}): AdminQueryState { const { refreshMs = null, enabled = true, timeoutMs } = opts; const qs = JSON.stringify(opts.query ?? null); const key = `${path ?? ""}|${qs}`; const active = Boolean(path && enabled); const [slot, setSlot] = useState>({ key: "", data: null, meta: undefined, error: null, status: 0, updatedAt: null, refreshing: false }); const abortRef = useRef(null); const keyRef = useRef(key); useEffect(() => { keyRef.current = key; }, [key]); // `load` never writes state before its first await (it is invoked from an effect); the `refreshing` flag is set // by `refresh()` / the poller only, which run from timers and event handlers. const load = useCallback((): Promise => { if (!path || !enabled) return Promise.resolve(); const myKey = `${path}|${qs}`; abortRef.current?.abort(); const ctrl = new AbortController(); abortRef.current = ctrl; return adminFetch(path, { query: (JSON.parse(qs) as AdminQuery | null) ?? undefined, signal: ctrl.signal, timeoutMs }).then((res: AdminResult) => { if (ctrl.signal.aborted || keyRef.current !== myKey) return; if (res.ok) setSlot({ key: myKey, data: res.data, meta: res.meta, error: null, status: res.status, updatedAt: Date.now(), refreshing: false }); 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 })); }); }, [path, enabled, qs, timeoutMs]); useEffect(() => { if (!active) return; void load(); return () => abortRef.current?.abort(); }, [load, active]); const refresh = useCallback(() => { setSlot((s) => ({ ...s, refreshing: true })); return load(); }, [load]); useEffect(() => { if (!refreshMs || !active) return; const tick = () => { if (document.visibilityState === "visible") void refresh(); }; const t = setInterval(tick, refreshMs); document.addEventListener("visibilitychange", tick); return () => { clearInterval(t); document.removeEventListener("visibilitychange", tick); }; }, [refreshMs, refresh, active]); const mutate = useCallback((fn: (prev: T | null) => T | null) => setSlot((s) => ({ ...s, data: fn(s.data) })), []); const current = slot.key === key; return { data: current ? slot.data : null, meta: current ? slot.meta : undefined, error: current ? slot.error : null, status: current ? slot.status : 0, loading: active && !current, refreshing: slot.refreshing, updatedAt: current ? slot.updatedAt : null, refresh, mutate, }; } /** Run a mutation through the proxy with a busy flag. */ export function useAdminMutation() { const [busy, setBusy] = useState(null); const run = useCallback(async (key: string, path: string, init: { method?: "POST" | "PATCH" | "PUT" | "DELETE"; body?: unknown; query?: AdminQuery; timeoutMs?: number }): Promise> => { setBusy(key); try { return await adminFetch(path, { method: init.method ?? "POST", body: init.body ?? {}, query: init.query, timeoutMs: init.timeoutMs }); } finally { setBusy((b) => (b === key ? null : b)); } }, []); return { busy, run }; }