/** * Hoover — api.js * * JSON-friendly fetch wrapper with automatic header management. * Toast notification system with auto-dismiss. */ import { modelFetch } from './model.js?v=8'; import { requestUpdate } from './reactivity.js?v=8'; /** * JSON-friendly fetch wrapper. * * Automatically sets Content-Type for object bodies, parses JSON * responses, and normalises the result to { ok, data, error, status }. * * @param {string} url – Target URL * @param {object} [options] – Fetch options (method, body, headers, …) * @returns {Promise<{ok, data, error, status}>} */ export async function apiFetch(url, options = {}) { const { method = 'GET', body, ...opts } = options; const headers = { 'Accept': 'application/json', ...opts.headers }; if (body && typeof body === 'object' && !(body instanceof FormData)) { headers['Content-Type'] = 'application/json'; options.body = JSON.stringify(body); } try { const res = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts }); if (opts.signal?.aborted) { return { ok: false, data: null, error: 'Aborted', status: 0 }; } if (res.status === 401) { window.location.reload(); return { ok: false, data: null, error: 'Session expired', status: 401 }; } const json = await res.json(); if (!res.ok) { return { ok: false, data: null, error: json.error || `HTTP ${res.status}`, status: res.status }; } return { ok: json.ok, data: json.ok ? json.data : json, error: null, status: res.status }; } catch (e) { return { ok: false, data: null, error: e.message || 'Network error', status: 0 }; } } /** ─── Toast notifications ────────────────────────────────── */ /** Toast notification queue. Exported for ToastContainer component. */ export const _toasts = []; const _toastIds = { next: 1 }; /** * Show a toast notification. Auto-dismisses after `duration` ms. * * @param {string} message – Toast text * @param {string} [type] – 'info' | 'success' | 'error' | 'warning' * @param {number} [duration] – Auto-dismiss timeout in ms (0 = indefinite) * @returns {number} id */ export function toast(message, type = 'info', duration = 4000) { const id = _toastIds.next++; _toasts.push({ id, message, type, createdAt: Date.now(), duration }); requestUpdate(); if (duration > 0) setTimeout(() => dismissToast(id), duration); return id; } /** * Dismiss a toast by id. */ export function dismissToast(id) { const idx = _toasts.findIndex(t => t.id === id); if (idx !== -1) _toasts.splice(idx, 1); requestUpdate(); } /** * Create an abort-checking function from an AbortController. * * @deprecated Use model layer (`modelRegister` / `modelFetch`) for data * fetching with abort handling and loading state management. * @param {AbortController} ac * @returns {function} () => boolean */ export function checkAbort(ac) { return () => ac?.signal?.aborted || false; } /** * Standard data loading wrapper with state management and abort handling. * * Sets loading=true before, loading=false after, tracks errors. * * @deprecated Use model layer (`modelRegister` / `modelFetch`) for data * fetching with abort handling and loading state management. * @param {object} state - Reactive state object * @param {function} dataKey - (s) => any, current data to compare for refresh detection * @param {function} fetchFn - (state, signal, isAborted) => Promise * @param {object} [opts] - Additional options * @param {object} [opts.entry] - Component entry for requestId tracking * @param {AbortController} [opts.abortController] - Fresh abort controller */ export async function refactorLoad(state, dataKey, fetchFn, opts = {}) { const entry = opts.entry; const myId = entry ? entry.requestId : 0; const ab = opts.abortController; const isAborted = ab ? checkAbort(ab) : () => false; const signal = ab ? ab.signal : null; if (entry) { if (dataKey(state) !== undefined) state.refreshing = true; else state.loading = true; } state.error = null; try { await fetchFn(state, signal, isAborted); } catch (e) { if (!isAborted()) state.error = e.message || 'Request failed'; } finally { if (!isAborted()) { if (entry) { state.loading = false; state.refreshing = false; } } } } /** * Poll a URL until success or error condition is met. * * @param {object} opts * @param {string} opts.url - URL to poll * @param {function} opts.successKey - (data) => boolean, when true poll succeeds * @param {function} opts.onErrorKey - (data) => boolean, when true poll fails * @param {function} [opts.onComplete] - (data) => void, called on success * @param {function} [opts.onError] - (data) => void, called on failure * @param {number} [opts.interval] - Poll interval in ms (default: 3000) * @param {number} [opts.timeout] - Overall timeout in ms (default: 60000) */ export async function poll(opts) { const { url, successKey, onErrorKey, onComplete, onError, interval = 3000, timeout = 60000, } = opts; const start = Date.now(); const timer = setInterval(async () => { if (Date.now() - start > timeout) { clearInterval(timer); if (onError) onError(null); return; } const res = await apiFetch(url); if (!res.ok) { clearInterval(timer); if (onError) onError(res); return; } if (successKey(res.data)) { clearInterval(timer); if (onComplete) onComplete(res.data); } else if (onErrorKey(res.data)) { clearInterval(timer); if (onError) onError(res.data); } }, interval); } /** * Generate action button descriptors for modal form submission. * * Returns an array of action descriptors that can be spread into the * actions array passed to formModal. First item is the submit button. * * @param {object} opts * @param {string} opts.url - API URL to POST/PUT to * @param {string} [opts.method] - HTTP method (default: 'POST') * @param {function} [opts.body] - () => object, body builder * @param {function} [opts.validate] - (body) => string|null, validation function * @param {string} [opts.successMsg] - Success toast message * @param {string|string[]} [opts.refresh] - Model name(s) to refresh via modelFetch * @param {string} [opts.submitText] - Submit button text (default: 'Submit') * @returns {object[]} Array of action descriptors */ export function apiSubmit(opts) { const { url, method = 'POST', body, validate, successMsg = 'Saved', refresh, submitText = 'Submit', closeModal, } = opts; return [ { label: submitText, cls: 'btn-primary', action: 's', handler: async () => { const b = body ? body() : {}; if (validate) { const err = validate(b); if (err) { toast(err, 'error'); return; } } const res = await apiFetch(url, { method, body: b }); if (res.ok) { const synced = res.data?.synced; let msg = successMsg; if (synced && synced.length) { msg += ' (auto-synced: ' + synced.join(', ') + ')'; synced.forEach(s => modelFetch(s)); } toast(msg, 'success'); if (closeModal) closeModal(); if (refresh) { const models = Array.isArray(refresh) ? refresh : [refresh]; await Promise.all(models.map(m => modelFetch(m))); } } else { toast(res.error || 'Failed', 'error'); } }, }, ]; }