/* ============================================================ Shared app core: API client (CSRF), auth guard, layout chrome, toasts, modals, confirm dialogs, helpers. ============================================================ */ "use strict"; /* ---------- tiny DOM helpers ---------- */ const $ = (sel, root) => (root || document).querySelector(sel); const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel)); function el(tag, attrs = {}, ...children) { const node = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (k === "class") node.className = v; else if (k === "html") node.innerHTML = v; else if (k.startsWith("on") && typeof v === "function") node.addEventListener(k.slice(2), v); else if (v !== null && v !== undefined) node.setAttribute(k, v); } for (const c of children) { if (c === null || c === undefined) continue; node.append(c instanceof Node ? c : document.createTextNode(String(c))); } return node; } function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } function debounce(fn, ms = 300) { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; } function fmtBytes(n) { if (n == null) return "—"; if (n < 1024) return n + " B"; if (n < 1048576) return (n / 1024).toFixed(1) + " KB"; return (n / 1048576).toFixed(2) + " MB"; } function fmtDT(iso) { if (!iso) return "—"; const d = new Date(iso); return d.toLocaleString([], { year: "numeric", month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" }); } function fmtMoney(n) { if (n == null) return "—"; return "$" + Number(n).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function plural(n, w) { return `${n} ${w}${n === 1 ? "" : "s"}`; } /* ---------- API client ---------- */ const CSRF = { get() { return getCookie("mailer_csrf"); }, }; 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"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1500); } const PATCH = (p, json) => api(p, { method: "PATCH", json }); const PUT = (p, json) => api(p, { method: "PUT", json }); const DEL = p => api(p, { method: "DELETE" }); /* ---------- 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 || b.kind || "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 { const r = await fn(); toast(typeof r === "string" ? r : "Done", "ok"); resolve(r); } catch (e) { toast(e.message, "err", 6000); resolve(null); } } }); }); } /* ---------- Layout chrome ---------- */ const NAV = [ { section: "Overview" }, { id: "index", ico: "◧", label: "Dashboard", href: "/" }, { id: "campaigns", ico: "✦", label: "Campaigns", href: "/campaigns" }, { section: "Sending" }, { id: "gmail-accounts", ico: "✉", label: "Gmail Accounts", href: "/gmail-accounts" }, { id: "recipients", ico: "☰", label: "Recipients", href: "/recipients" }, { id: "send-test", ico: "➤", label: "Send Test", href: "/send-test" }, { id: "logs", ico: "≣", label: "Logs", href: "/logs" }, { section: "Content" }, { id: "subjects", ico: "T", label: "Subject Lines", href: "/subjects" }, { id: "templates", ico: "❑", label: "HTML Templates", href: "/templates" }, { id: "saved-templates", ico: "❒", label: "Saved Templates", href: "/saved-templates" }, { id: "attachments", ico: "◫", label: "Attachments", href: "/attachments" }, { id: "phones", ico: "☏", label: "Company Phones", href: "/phones" }, { section: "Compliance" }, { id: "suppression", ico: "⊘", label: "Suppression List", href: "/suppression" }, { id: "settings", ico: "⚙", label: "Settings", href: "/settings" }, ]; async function loadLayout() { let me = null; try { me = await GET("/auth/me"); } catch { me = null; } if (!me) { if (!location.pathname.startsWith("/login")) location.href = "/login"; return null; } const sb = el("aside", { class: "sidebar", id: "sidebar" }); sb.append(el("div", { class: "brand" }, el("span", { class: "brand-badge" }, "M"), el("span", {}, "Bulk Mailer"))); const nav = el("nav", {}); for (const item of NAV) { if (item.section) { nav.append(el("div", { class: "nav-section" }, item.section)); continue; } const active = location.pathname === item.href || (item.href !== "/" && location.pathname.startsWith(item.href)); nav.append(el("a", { class: "nav-link" + (active ? " active" : ""), href: item.href }, el("span", { class: "ico" }, item.ico), item.label)); } sb.append(nav); const foot = el("div", { class: "foot" }, el("div", { class: "user-line" }, "👤 ", me.email || "user"), el("button", { class: "btn sm secondary block mt-1", onclick: doLogout }, "Sign out")); sb.append(foot); const old = $("#sidebar"); if (old) old.remove(); document.body.prepend(sb); // scrim for mobile let scrim = $("#scrim"); if (!scrim) { scrim = el("div", { id: "scrim", onclick: () => { $("#sidebar").classList.remove("open"); scrim.classList.remove("show"); } }); document.body.append(scrim); } const mb = $("#menuBtn"); if (mb) mb.addEventListener("click", () => { $("#sidebar").classList.toggle("open"); scrim.classList.toggle("show"); }); // page-specific boot if (window.pageBoot) window.pageBoot(me); return me; } async function doLogout() { try { await POST("/auth/logout"); } catch {} location.href = "/login"; } document.addEventListener("DOMContentLoaded", loadLayout);