1import { useCallback, useEffect, useRef, useState } from "react";2import { api, ApiError, type Job } from "./api";34export function useFetch<T>(path: string | null, deps: unknown[] = []) {5 const [data, setData] = useState<T | null>(null);6 const [error, setError] = useState<ApiError | null>(null);7 const [loading, setLoading] = useState<boolean>(!!path);8 const [tick, setTick] = useState(0);9 const reload = useCallback(() => setTick((t) => t + 1), []);10 useEffect(() => {11 if (!path) { setData(null); setLoading(false); return; }12 const ctl = new AbortController();13 setLoading(true); setError(null);14 api<T>(path, { signal: ctl.signal }).then((d) => { setData(d); setLoading(false); }).catch((e) => { if (ctl.signal.aborted) return; setError(e instanceof ApiError ? e : new ApiError(0, "network", String(e))); setLoading(false); });15 return () => ctl.abort();16 // eslint-disable-next-line react-hooks/exhaustive-deps17 }, [path, tick, ...deps]);18 return { data, error, loading, reload, setData };19}2021/** Poll a job until it reaches a terminal state. */22export function useJob(jobId: string | null, onDone?: (j: Job) => void) {23 const [job, setJob] = useState<Job | null>(null);24 const cb = useRef(onDone); cb.current = onDone;25 useEffect(() => {26 if (!jobId) { setJob(null); return; }27 let stop = false;28 const poll = async () => {29 try {30 const j = await api<Job>(`jobs/${jobId}`);31 if (stop) return;32 setJob(j);33 if (["done", "dead", "cancelled", "failed"].includes(j.state)) { cb.current?.(j); return; }34 } catch { /* retry */ }35 if (!stop) setTimeout(poll, 1500);36 };37 poll();38 return () => { stop = true; };39 }, [jobId]);40 return job;41}4243export function useTheme() {44 const [theme, setTheme] = useState<string>(() => document.documentElement.getAttribute("data-theme") ?? "auto");45 const toggle = () => {46 const dark = theme === "dark" || (theme === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches);47 const next = dark ? "light" : "dark";48 document.documentElement.setAttribute("data-theme", next);49 try { localStorage.setItem("akos-theme", next); } catch { /* ignore */ }50 setTheme(next);51 };52 return { theme, toggle };53}5455export function fmtDate(s: string | null | undefined): string {56 if (!s) return "—";57 const d = new Date(s);58 if (isNaN(d.getTime())) return s;59 return d.toLocaleString(undefined, { year: "2-digit", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });60}61export function ago(s: string | null | undefined): string {62 if (!s) return "—";63 const ms = Date.now() - new Date(s).getTime();64 if (isNaN(ms)) return s;65 const m = Math.round(ms / 60000);66 if (m < 1) return "just now"; if (m < 60) return `${m} min ago`; const h = Math.round(m / 60); if (h < 48) return `${h} h ago`; return `${Math.round(h / 24)} d ago`;67}68export function fmtBytes(n: number | null | undefined): string {69 if (n === null || n === undefined) return "—";70 if (n < 1024) return `${n} B`; if (n < 1048576) return `${(n / 1024).toFixed(1)} KB`; if (n < 1073741824) return `${(n / 1048576).toFixed(1)} MB`; return `${(n / 1073741824).toFixed(2)} GB`;71}72export const short = (s: string | null | undefined, n = 8) => (s ? s.slice(0, n) : "—");73export const num = (v: unknown) => (typeof v === "number" ? v.toLocaleString() : v === null || v === undefined ? "—" : String(v));74