export const BASE = import.meta.env.VITE_API_URL ?? ""; const ACCESS = "ns_access"; const REFRESH = "ns_refresh"; export class ApiError extends Error { status: number; code?: string; constructor(status: number, message: string, code?: string) { super(message); this.status = status; this.code = code; } } function read(key: string): string | null { try { return localStorage.getItem(key); } catch { return null; } } export const tokens = { get access() { return read(ACCESS); }, get refresh() { return read(REFRESH); }, set(access: string, refresh?: string) { try { localStorage.setItem(ACCESS, access); if (refresh) localStorage.setItem(REFRESH, refresh); } catch { /* private mode: session only */ } }, clear() { try { localStorage.removeItem(ACCESS); localStorage.removeItem(REFRESH); } catch { /* ignore */ } }, }; async function parseError(res: Response): Promise { let message = res.statusText || "Something went wrong"; let code: string | undefined; try { const body = await res.json(); const detail = body.detail; if (typeof detail === "string") message = detail; else if (detail?.message) { message = detail.message; code = detail.code; } } catch { /* non JSON */ } return new ApiError(res.status, message, code); } async function tryRefresh(): Promise { const refresh = tokens.refresh; if (!refresh) return false; const res = await fetch(`${BASE}/api/auth/refresh`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: refresh }), }); if (!res.ok) return false; tokens.set((await res.json()).access_token); return true; } export async function api(path: string, init: RequestInit = {}, retry = true): Promise { const headers = new Headers(init.headers); if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json"); if (tokens.access) headers.set("Authorization", `Bearer ${tokens.access}`); const res = await fetch(`${BASE}${path}`, { ...init, headers }); if (res.status === 401 && retry && (await tryRefresh())) return api(path, init, false); if (!res.ok) throw await parseError(res); return (await res.json()) as T; } export const post = (path: string, body?: unknown) => api(path, { method: "POST", body: body === undefined ? undefined : JSON.stringify(body) }); export const patch = (path: string, body: unknown) => api(path, { method: "PATCH", body: JSON.stringify(body) }); export const put = (path: string, body: unknown) => api(path, { method: "PUT", body: JSON.stringify(body) }); export const del = (path: string) => api(path, { method: "DELETE" }); /** Query string from an object, skipping empty values. */ export function qs(params: Record): string { const q = new URLSearchParams(); for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "") q.set(k, String(v)); const out = q.toString(); return out ? `?${out}` : ""; } /** Direct browser upload to storage (R2, or the API's local disk store) through a presigned PUT. */ const TYPES_BY_EXT: Record = { md: "text/markdown", markdown: "text/markdown", txt: "text/plain", html: "text/html", htm: "text/html", pdf: "application/pdf", mp3: "audio/mpeg", m4a: "audio/mp4", wav: "audio/wav", webm: "audio/webm", ogg: "audio/ogg", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", svg: "image/svg+xml", }; /** Browsers leave File.type empty for some extensions (.md on Windows) and use vendor names for others. */ function contentType(file: File): string { const ext = file.name.split(".").pop()?.toLowerCase() ?? ""; const base = file.type.split(";")[0]; if (base === "audio/x-m4a") return "audio/mp4"; if (base === "audio/x-wav") return "audio/wav"; return base || TYPES_BY_EXT[ext] || "application/octet-stream"; } export async function uploadFile(file: File): Promise<{ upload_id: string; key: string }> { const start = await post<{ upload_id: string; upload_url: string; headers: Record }>( "/api/storage/uploads", { filename: file.name, content_type: contentType(file), size_bytes: file.size }, ); const put = await fetch(start.upload_url, { method: "PUT", headers: start.headers, body: file }); if (!put.ok) throw new ApiError(put.status, "Upload to storage failed"); return post(`/api/storage/uploads/${start.upload_id}/complete`); }