Files
vacuum-wall/webui/static/hoover/api.js
T
2026-06-17 03:41:08 +00:00

99 lines
3.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Hoover — api.js
*
* JSON-friendly fetch wrapper with automatic header management.
* Toast notification system with auto-dismiss.
* ToastContainer component for rendering queued toasts.
*/
import { h } from './vdom.js';
/**
* 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 });
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);
}
/**
* Render the queued toast notifications.
*
* @returns {VNode} Toast container (empty text node when no toasts)
*/
export function ToastContainer() {
if (!_toasts.length) return h('#text', '');
const clsMap = { info: 'toast-info', success: 'toast-success', error: 'toast-error', warning: 'toast-warning' };
return h('div', { class: 'toast-container' },
..._toasts.map(t =>
h('div', { class: `toast ${clsMap[t.type] || clsMap.info}`, 'on:click': () => dismissToast(t.id) },
h('span', null, t.message),
h('button', { class: 'toast-close', 'on:click': (e) => { e.stopPropagation(); dismissToast(t.id); } }, '\u00d7'),
),
),
);
}