SPB Git

spb/akos

Public
3.4 KB · 74 lines typescript
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
Files · da58f809c01c422e65c786b05d6ffa3079c9feba
Menu

Theme