import { useConnectionStore } from "@/stores/connection-store"; const API_BASE = "/api"; export interface FeatureNotInstalledError { type: "feature_not_installed"; feature: string; featureName: string; estimatedSize: string; } export function parseApiError( body: Record, fallbackStatus: number, ): string | FeatureNotInstalledError { if (body.code === "FEATURE_NOT_INSTALLED") { return { type: "feature_not_installed", feature: body.feature as string, featureName: body.featureName as string, estimatedSize: body.estimatedSize as string, }; } const error = typeof body.error === "string" ? body.error : ""; const details = body.details; if (!details) { return error || (body.message as string) || `Processing failed: ${fallbackStatus}`; } let detailsStr: string; if (typeof details === "string") { detailsStr = details; } else if (Array.isArray(details)) { detailsStr = details .map((d) => typeof d === "string" ? d : (d as Record)?.message || JSON.stringify(d), ) .join("; "); } else { detailsStr = JSON.stringify(details); } return error ? `${error}: ${detailsStr}` : detailsStr; } // ── Auth Headers ─────────────────────────────────────────────── function getToken(): string { try { return localStorage.getItem("snapotter-token") || ""; } catch { return ""; } } // Skip Authorization header when no token exists. // An empty Bearer token breaks forward-auth proxies (e.g. Authelia). export function formatHeaders(init?: HeadersInit): Headers { const headers = new Headers(init); const token = getToken(); if (token) { headers.set("Authorization", `Bearer ${token}`); } if (!token) { try { const consent = localStorage.getItem("snapotter-analytics-consent"); if (consent === "true" || consent === "false") { headers.set("X-Analytics-Consent", consent); } } catch { // localStorage unavailable } } return headers; } async function throwWithMessage(res: Response): Promise { let msg = `API error: ${res.status}`; try { const body = await res.json(); if (body.error) msg = body.error; else if (body.message) msg = body.message; } catch { // response wasn't JSON — use the default message } throw new Error(msg); } export async function apiGet(path: string): Promise { let res: Response; try { res = await fetch(`${API_BASE}${path}`, { headers: formatHeaders(), }); } catch (error) { if (error instanceof TypeError) { useConnectionStore.getState().setDisconnected(); } throw error; } if (!res.ok) await throwWithMessage(res); return res.json(); } export async function apiPost(path: string, body?: unknown): Promise { const headers = body !== undefined ? formatHeaders({ "Content-Type": "application/json" }) : formatHeaders(); let res: Response; try { res = await fetch(`${API_BASE}${path}`, { method: "POST", headers, body: body !== undefined ? JSON.stringify(body) : undefined, }); } catch (error) { if (error instanceof TypeError) { useConnectionStore.getState().setDisconnected(); } throw error; } if (!res.ok) await throwWithMessage(res); return res.json(); } export async function apiPut(path: string, body?: unknown): Promise { let res: Response; try { res = await fetch(`${API_BASE}${path}`, { method: "PUT", headers: formatHeaders({ "Content-Type": "application/json" }), body: body ? JSON.stringify(body) : undefined, }); } catch (error) { if (error instanceof TypeError) { useConnectionStore.getState().setDisconnected(); } throw error; } if (!res.ok) await throwWithMessage(res); return res.json(); } export async function apiDelete(path: string): Promise { let res: Response; try { res = await fetch(`${API_BASE}${path}`, { method: "DELETE", headers: formatHeaders(), }); } catch (error) { if (error instanceof TypeError) { useConnectionStore.getState().setDisconnected(); } throw error; } if (!res.ok) await throwWithMessage(res); return res.json(); } export function setToken(token: string) { localStorage.setItem("snapotter-token", token); } export function clearToken() { localStorage.removeItem("snapotter-token"); } // ── File Upload / Download ────────────────────────────────────── export async function apiUpload(files: File[]): Promise<{ jobId: string; files: Array<{ name: string; size: number; format: string }>; }> { const formData = new FormData(); for (const f of files) formData.append("files", f); let res: Response; try { res = await fetch("/api/v1/upload", { method: "POST", headers: formatHeaders(), body: formData, }); } catch (error) { if (error instanceof TypeError) { useConnectionStore.getState().setDisconnected(); } throw error; } if (!res.ok) throw new Error(`Upload failed: ${res.status}`); return res.json(); } export function getDownloadUrl(jobId: string, filename: string): string { return `/api/v1/download/${jobId}/${filename}`; } // ── Persistent File Management ────────────────────────────────── export interface UserFile { id: string; originalName: string; mimeType: string; size: number; width: number | null; height: number | null; version: number; toolChain: string[]; createdAt: string; } export interface UserFileDetail extends UserFile { versions: Array<{ id: string; version: number; size: number; toolChain: string[]; createdAt: string; }>; } export async function apiListFiles(params?: { search?: string; limit?: number; offset?: number; }): Promise<{ files: UserFile[]; total: number }> { const searchParams = new URLSearchParams(); if (params?.search) searchParams.set("search", params.search); if (params?.limit) searchParams.set("limit", String(params.limit)); if (params?.offset) searchParams.set("offset", String(params.offset)); const qs = searchParams.toString(); return apiGet(`/v1/files${qs ? `?${qs}` : ""}`); } export async function apiGetFileDetails(id: string): Promise { const res = await apiGet<{ file: UserFile; versions: UserFileDetail["versions"] }>( `/v1/files/${id}`, ); return { ...res.file, versions: res.versions }; } export function apiUploadUserFiles( files: File[], onProgress?: (percent: number) => void, ): Promise<{ files: Array<{ id: string; originalName: string; size: number; version: number }> }> { return new Promise((resolve, reject) => { const formData = new FormData(); for (const f of files) formData.append("files", f); const xhr = new XMLHttpRequest(); xhr.open("POST", "/api/v1/files/upload"); xhr.timeout = 120_000; const headers = formatHeaders(); headers.forEach((value, key) => { if (key.toLowerCase() !== "content-type") { xhr.setRequestHeader(key, value); } }); if (onProgress) { xhr.upload.onprogress = (e) => { if (e.lengthComputable) { onProgress(Math.round((e.loaded / e.total) * 100)); } }; } xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { try { resolve(JSON.parse(xhr.responseText)); } catch { reject(new Error("Invalid server response")); } } else { reject(new Error(`Upload failed: ${xhr.status}`)); } }; xhr.onerror = () => { useConnectionStore.getState().setDisconnected(); reject(new TypeError("Network error")); }; xhr.ontimeout = () => { reject(new Error("Upload timed out")); }; xhr.send(formData); }); } export async function apiDeleteUserFiles(ids: string[]): Promise<{ deleted: number }> { let res: Response; try { res = await fetch("/api/v1/files", { method: "DELETE", headers: formatHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ ids }), }); } catch (error) { if (error instanceof TypeError) { useConnectionStore.getState().setDisconnected(); } throw error; } if (!res.ok) throw new Error(`Delete failed: ${res.status}`); return res.json(); } export function getFileThumbnailUrl(id: string): string { return `/api/v1/files/${id}/thumbnail`; } export function getFileDownloadUrl(id: string): string { return `/api/v1/files/${id}/download`; } export function getFilePreviewUrl(id: string): string { return `/api/v1/files/${id}/preview`; } export async function apiDownloadBlob(jobId: string, filename: string): Promise { let res: Response; try { res = await fetch(getDownloadUrl(jobId, filename), { headers: formatHeaders(), }); } catch (error) { if (error instanceof TypeError) { useConnectionStore.getState().setDisconnected(); } throw error; } if (!res.ok) throw new Error(`Download failed: ${res.status}`); return res.blob(); }