// Thin fetch wrapper. Centralized so every call has consistent error handling // and json parsing. Errors throw an ApiError with the response status + body. export class ApiError extends Error { constructor(message, status, body) { super(message) this.name = 'ApiError' this.status = status this.body = body } } async function request(method, url, { body, params, signal } = {}) { let fullUrl = url if (params) { const search = new URLSearchParams() for (const [k, v] of Object.entries(params)) { if (v !== undefined && v !== null) search.set(k, String(v)) } const qs = search.toString() if (qs) fullUrl += (url.includes('?') ? '&' : '?') + qs } const headers = {} const init = { method, headers, signal } if (body !== undefined) { if (body instanceof FormData) { // Let the browser set Content-Type with the multipart boundary. // JSON-stringifying FormData produces "{}" and the backend sees // an empty multipart payload (this broke FC-5 IR/GS ingest from // the UI for several releases). init.body = body } else { headers['Content-Type'] = 'application/json' init.body = JSON.stringify(body) } } const r = await fetch(fullUrl, init) const text = await r.text() let parsed try { parsed = text ? JSON.parse(text) : null } catch { parsed = text } if (!r.ok) { const msg = (parsed && parsed.error) || `${r.status} ${r.statusText}` throw new ApiError(msg, r.status, parsed) } return parsed } export function useApi() { return { get: (url, opts) => request('GET', url, opts), post: (url, opts) => request('POST', url, opts), patch: (url, opts) => request('PATCH', url, opts), delete: (url, opts) => request('DELETE', url, opts) } }