import { useState, useEffect, useCallback } from "react"; const BASE = "/api/v1"; const API_INVALIDATE_EVENT = "inkos:api-invalidate"; interface ApiInvalidateDetail { readonly paths: ReadonlyArray; } export function buildApiUrl(path: string): string | null { const normalized = String(path ?? "").trim(); if (!normalized) return null; if (normalized.startsWith(`${BASE}/`) || normalized === BASE) { return normalized; } return normalized.startsWith("/") ? `${BASE}${normalized}` : `${BASE}/${normalized}`; } export function deriveInvalidationPaths(path: string): ReadonlyArray { const normalized = buildApiUrl(path); if (!normalized) return []; if (normalized === "/api/v1/books/create") { return ["/api/v1/books"]; } if (normalized === "/api/v1/project") { return ["/api/v1/project"]; } if (normalized.startsWith("/api/v1/project/")) { return ["/api/v1/project", normalized]; } const bookAction = normalized.match(/^\/api\/v1\/books\/([^/]+)\/(write-next|draft)$/); if (bookAction) { return ["/api/v1/books", `/api/v1/books/${bookAction[1]}`]; } const chapterAction = normalized.match(/^\/api\/v1\/books\/([^/]+)\/chapters\/\d+\/(approve|reject)$/); if (chapterAction) { return ["/api/v1/books", `/api/v1/books/${chapterAction[1]}`]; } if (/^\/api\/v1\/daemon\/(start|stop)$/.test(normalized)) { return ["/api/v1/daemon"]; } return []; } export function invalidateApiPaths(paths: ReadonlyArray): void { if (!paths.length || typeof window === "undefined") { return; } window.dispatchEvent(new CustomEvent(API_INVALIDATE_EVENT, { detail: { paths: [...new Set(paths)] }, })); } async function readErrorMessage(res: Response): Promise { const contentType = res.headers.get("content-type") ?? ""; if (contentType.includes("application/json")) { try { const json = await res.json() as { error?: unknown }; if (typeof json.error === "string" && json.error.trim()) { return json.error; } if ( json.error && typeof json.error === "object" && "message" in json.error && typeof (json.error as { message?: unknown }).message === "string" && (json.error as { message: string }).message.trim() ) { return (json.error as { message: string }).message; } } catch { // fall through } } return `${res.status} ${res.statusText}`.trim(); } export async function fetchJson( path: string, init: RequestInit = {}, deps?: { readonly fetchImpl?: typeof fetch }, ): Promise { const url = buildApiUrl(path); if (!url) { throw new Error("API path is required"); } const fetchImpl = deps?.fetchImpl ?? fetch; const res = await fetchImpl(url, init); if (!res.ok) { throw new Error(await readErrorMessage(res)); } if (res.status === 204) { return undefined as T; } const contentType = res.headers.get("content-type") ?? ""; if (!contentType.includes("application/json")) { const text = await res.text(); if (!text.trim()) { return undefined as T; } return JSON.parse(text) as T; } return await res.json() as T; } export function useApi(path: string) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const refetch = useCallback(async () => { const url = buildApiUrl(path); if (!url) { setData(null); setError(null); setLoading(false); return; } setLoading(true); setError(null); try { const json = await fetchJson(url); setData(json); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setLoading(false); } }, [path]); useEffect(() => { refetch(); }, [refetch]); useEffect(() => { const url = buildApiUrl(path); if (!url || typeof window === "undefined") { return; } const handleInvalidate = (event: Event) => { const detail = (event as CustomEvent).detail; if (!detail?.paths.includes(url)) return; void refetch(); }; window.addEventListener(API_INVALIDATE_EVENT, handleInvalidate); return () => { window.removeEventListener(API_INVALIDATE_EVENT, handleInvalidate); }; }, [path, refetch]); return { data, loading, error, refetch, mutate: setData }; } export async function postApi(path: string, body?: unknown): Promise { const result = await fetchJson(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: body ? JSON.stringify(body) : undefined, }); invalidateApiPaths(deriveInvalidationPaths(path)); return result; } export async function putApi(path: string, body?: unknown): Promise { const result = await fetchJson(path, { method: "PUT", headers: { "Content-Type": "application/json" }, body: body ? JSON.stringify(body) : undefined, }); invalidateApiPaths(deriveInvalidationPaths(path)); return result; }