}; function getCookie(name) { return document.cookie.split("; ").find(r => r.startsWith(name + "="))?.split("=").slice(1).join("=") || ""; } async function api(path, opts = {}) { const headers = Object.assign({}, opts.headers || {}); if (opts.json !== undefined) { headers["Content-Type"] = "application/json"; opts.body = JSON.stringify(opts.json); delete opts.json; } if ((opts.method || "GET").toUpperCase() !== "GET") headers["X-CSRF-Token"] = CSRF.get(); const res = await fetch(path, Object.assign({}, opts, { headers, credentials: "same-origin" })); let data = null; try { data = await res.json(); } catch { /* no body */ } if (res.status === 401 && !location.pathname.startsWith("/login")) { location.href = "/login?next=" + encodeURIComponent(location.pathname); throw new Error("Not authenticated"); } if (!res.ok) { const detail = data && (data.detail?.message || data.detail || data.error); const err = new Error(typeof detail === "string" ? detail : (res.status + " " + res.statusText)); err.status = res.status; err.data = data; throw err; } return data; } const GET = p => api(p); const POST = (p, json) => api(p, { method: "POST", json }); function b64Blob(b64, mime) { const bin = atob(b64); const arr = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); return new Blob([arr], { type: mime }); } function dlBlob(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement("a"); /* ---------- Toasts ---------- */ function toast(msg, kind = "info", ms = 4200) { let box = $("#toasts"); if (!box) { box = el("div", { id: "toasts" }); document.body.append(box); } const icons = { info: "ℹ", ok: "✓", err: "✕", warn: "⚠" }; const t = el("div", { class: "toast " + (kind === "info" ? "" : kind) }, el("span", { class: "n-ico" }, icons[kind] || "ℹ"), el("span", {}, msg)); box.append(t); setTimeout(() => { t.style.opacity = "0"; t.style.transition = "opacity .3s"; setTimeout(() => t.remove(), 320); }, ms); return t; } /* ---------- Modal / confirm ---------- */ function openModal(title, bodyNode, { buttons = [], wide = false, xw = false, onClose } = {}) { const back = el("div", { class: "modal-back open" }); const modal = el("div", { class: "modal" + (wide ? " wide" : "") + (xw ? " xw" : "") }); const close = () => { back.remove(); document.body.style.overflow = ""; onClose && onClose(); }; const hd = el("div", { class: "m-hd" }, el("h3", {}, title), el("button", { class: "x-btn", onclick: close }, "×")); const ft = el("div", { class: "m-ft" }); if (!buttons.length) buttons = [{ label: "Close", onClick: close }]; for (const b of buttons) { ft.append(el("button", { class: "btn " + (b.cls || "secondary"), onclick: () => b.onClick ? b.onClick(close, modal) : close() }, b.label)); } modal.append(hd, el("div", { class: "m-bd" }, bodyNode), ft); back.append(modal); back.addEventListener("mousedown", e => { if (e.target === back) close(); }); document.body.append(back); document.body.style.overflow = "hidden"; return { close, modal }; } function confirmDialog(message, { title = "Are you sure?", danger = true, confirmLabel = "Confirm", onConfirm } = {}) { const p = el("p", { class: "small" }, message); return openModal(title, p, { buttons: [ { label: "Cancel", cls: "secondary", onClick: c => c() }, { label: confirmLabel, cls: danger ? "danger" : "primary", onClick: c => { c(); onConfirm && onConfirm(); } }, ], }); } /* confirm wrapper for Clear-All style destructive API calls */ async function confirmedAction(message, fn, { title, confirmLabel = "Yes, proceed" } = {}) { return new Promise(resolve => { confirmDialog(message, { title, confirmLabel, onConfirm: async () => { try {