1"use client";2import { useCallback, useEffect, useRef, useState } from "react";34/** Generic async loader with abort + refetch. `deps` re-run the fetch. */5export function useAsync<T>(fn: (signal: AbortSignal) => Promise<T>, deps: unknown[], opts: { enabled?: boolean } = {}) {6 const enabled = opts.enabled ?? true;7 const [data, setData] = useState<T | null>(null);8 const [error, setError] = useState<unknown>(null);9 const [loading, setLoading] = useState<boolean>(enabled);10 const [tick, setTick] = useState(0);11 const fnRef = useRef(fn);12 fnRef.current = fn;1314 useEffect(() => {15 if (!enabled) {16 setLoading(false);17 return;18 }19 const ctl = new AbortController();20 let alive = true;21 setLoading(true);22 fnRef23 .current(ctl.signal)24 .then((d) => {25 if (!alive) return;26 setData(d);27 setError(null);28 })29 .catch((e) => {30 if (!alive || ctl.signal.aborted) return;31 setError(e);32 })33 .finally(() => {34 if (alive) setLoading(false);35 });36 return () => {37 alive = false;38 ctl.abort();39 };40 // eslint-disable-next-line react-hooks/exhaustive-deps41 }, [...deps, tick, enabled]);4243 const reload = useCallback(() => setTick((t) => t + 1), []);44 return { data, error, loading, reload, setData };45}4647export function useDebounced<T>(value: T, ms = 300): T {48 const [v, setV] = useState(value);49 useEffect(() => {50 const t = window.setTimeout(() => setV(value), ms);51 return () => window.clearTimeout(t);52 }, [value, ms]);53 return v;54}5556/** Read a query-string parameter client-side (static export cannot use dynamic routes). */57export function useQueryParam(name: string): string | null {58 const [v, setV] = useState<string | null>(null);59 useEffect(() => {60 const read = () => setV(new URLSearchParams(window.location.search).get(name));61 read();62 window.addEventListener("popstate", read);63 return () => window.removeEventListener("popstate", read);64 }, [name]);65 return v;66}6768export function useInterval(cb: () => void, ms: number | null) {69 const ref = useRef(cb);70 ref.current = cb;71 useEffect(() => {72 if (ms === null) return;73 const id = window.setInterval(() => ref.current(), ms);74 return () => window.clearInterval(id);75 }, [ms]);76}7778export function useMediaQuery(q: string): boolean {79 const [m, setM] = useState(false);80 useEffect(() => {81 const mq = window.matchMedia(q);82 const on = () => setM(mq.matches);83 on();84 mq.addEventListener("change", on);85 return () => mq.removeEventListener("change", on);86 }, [q]);87 return m;88}8990export function useLocalStorage<T>(key: string, initial: T): [T, (v: T) => void] {91 const [v, setV] = useState<T>(initial);92 useEffect(() => {93 try {94 const raw = window.localStorage.getItem(key);95 if (raw !== null) setV(JSON.parse(raw) as T);96 } catch {97 /* ignore */98 }99 }, [key]);100 const set = useCallback(101 (nv: T) => {102 setV(nv);103 try {104 window.localStorage.setItem(key, JSON.stringify(nv));105 } catch {106 /* ignore */107 }108 },109 [key],110 );111 return [v, set];112}113