// 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) continue // Array values serialize as a REPEATED key (k=a&k=b) — e.g. the // gallery's tag_or OR-groups, read server-side via request.args.getlist. if (Array.isArray(v)) { for (const item of v) { if (item !== undefined && item !== null) search.append(k, String(item)) } } else { 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) } }