/* ============================================================ VOR Bootstrap / Globals ============================================================ */ window.VOR = window.VOR || {}; VOR.locale = VOR.locale ?? 'en-US'; VOR.currency = VOR.currency ?? 'EUR'; /* ============================================================ Network / API ============================================================ */ VOR.post = async function (data = {}, target, method, opts = {}) { const { url = "/rss/php/handler.php", timeoutMs = 15000, credentials = "same-origin", } = opts; if (!target || !method) { return { ok: false, error: "Missing target/method" }; } const fd = new FormData(); const append = (k, v) => { if (v === undefined || v === null) return; if (v instanceof File || v instanceof Blob) { fd.append(k, v); return; } if (v instanceof FileList) { for (const f of v) fd.append(k, f); return; } if (Array.isArray(v)) { for (const item of v) append(`${k}[]`, item); return; } if (typeof v === "object") { fd.append(k, JSON.stringify(v)); return; } fd.append(k, String(v)); }; for (const [k, v] of Object.entries(data || {})) { append(k, v); } fd.append("target", target); fd.append("method", method); const ctrl = timeoutMs ? new AbortController() : null; const t = timeoutMs ? setTimeout(() => ctrl.abort(), timeoutMs) : null; try { const res = await fetch(url, { method: "POST", body: fd, credentials, signal: ctrl?.signal, }); try { const resp = await res.json(); if (!res.ok) { return { ok: false, error: resp?.message || `HTTP ${res.status}`, status: res.status, raw: resp, }; } if (!resp || typeof resp !== "object") { return { ok: false, error: "Bas json response", status: res.status, }; } if (resp.status == "success") { return { ok: true, data: resp.message, status: res.status, raw: resp, }; } return { ok: false, error: resp.message ?? "Failed", status: res.status, raw: resp, }; } catch (err) { return { ok: false, error: "Could not parse JSON", status: res.status }; } } catch (errno) { const msg = errno?.name === "AbortError" ? "Timeout" : errno?.message || String(errno); return { ok: false, error: msg }; } finally { if (t) clearTimeout(t); } }; /* ============================================================ UI: Table ============================================================ */ VOR.uiTable = function ({ el, headers = [], data = [], sortable = true, emptyText = "No data", classArr = ["table"], editRenderer = null, } = {}) { const container = typeof el === "string" ? document.querySelector(el) : el; if (!container) return; VOR.clearHtml(container); const table = VOR.createEl("table", classArr); const thead = VOR.createEl("thead"); const tbody = VOR.createEl("tbody"); let sortState = { key: null, dir: "asc" }; let openEditorRow = null; function closeEditor() { if (openEditorRow) { openEditorRow.remove(); openEditorRow = null; } } function sortData(rows) { if (!sortable || !sortState.key) return rows; return [...rows].sort((a, b) => { const v1 = a[sortState.key]; const v2 = b[sortState.key]; if (v1 === v2) return 0; if (v1 == null) return 1; if (v2 == null) return -1; if (typeof v1 === "number" && typeof v2 === "number") { return sortState.dir === "asc" ? v1 - v2 : v2 - v1; } return sortState.dir === "asc" ? String(v1).localeCompare(String(v2)) : String(v2).localeCompare(String(v1)); }); } function expandRow(tr, rowData) { if (openEditorRow && openEditorRow.previousSibling === tr) { closeEditor(); return; } closeEditor(); if (typeof editRenderer !== "function") return; const editorTr = VOR.createEl("tr", ["row-editor"]); const td = VOR.createEl("td", null, null, { colspan: headers.length }); const content = editRenderer(rowData); if (content instanceof HTMLElement) td.append(content); editorTr.append(td); tr.after(editorTr); openEditorRow = editorTr; } function renderBody(rows) { VOR.clearHtml(tbody); closeEditor(); if (!rows.length) { const tr = VOR.createEl("tr"); const td = VOR.createEl("td", ["empty"], emptyText, { colspan: headers.length, }); tr.append(td); tbody.append(tr); return; } rows.forEach((row) => { const tr = VOR.createEl("tr"); headers.forEach((h) => { const td = VOR.createEl("td"); const value = row[h.key]; if (h.action) { const btn = VOR.createEl( "button", ["table-action", `is-${h.action}`], h.format && typeof h.format === "function" ? h.format(value, row) : value ?? "" ); btn.addEventListener("click", (e) => { e.stopPropagation(); switch (h.action) { case "visit": if (h.actionConfig?.url) { const url = h.actionConfig.url(row); if (url) window.location.href = url; } break; case "toggle": if (h.actionConfig?.selector) { const sel = h.actionConfig.selector(row); if (sel) VOR.uiToggle(sel, true); } break; case "edit-expand": expandRow(tr, row); break; } }); td.append(btn); } else { const text = h.format && typeof h.format === "function" ? h.format(value, row) : value ?? ""; td.append(document.createTextNode(text)); } tr.append(td); }); tbody.append(tr); }); } function renderHeader() { const tr = VOR.createEl("tr"); headers.forEach((h) => { const th = VOR.createEl( "th", sortable && h.sortable !== false ? ["sortable"] : null, h.label ); if (sortable && h.sortable !== false) { th.addEventListener("click", () => { closeEditor(); if (sortState.key === h.key) { sortState.dir = sortState.dir === "asc" ? "desc" : "asc"; } else { sortState.key = h.key; sortState.dir = "asc"; } renderBody(sortData(data)); }); } tr.append(th); }); thead.append(tr); } renderHeader(); renderBody(data); table.append(thead, tbody); container.append(table); }; /* ============================================================ State Store ============================================================ */ VOR.state = (function () { const store = {}; return { set(key, value) { store[key] = value; }, get(key) { return store[key]; }, update(key, fn) { if (typeof fn === "function") { store[key] = fn(store[key]); } }, remove(key) { delete store[key]; }, }; })(); /* ============================================================ UI: Alerts / Confirm / Form / Dropdown / Pagination / Badge ============================================================ */ VOR.uiAlert = function (message, type = "info", timeout = 4000) { let container = document.querySelector(".vor-alert-stack"); if (!container) { container = VOR.createEl("div", ["vor-alert-stack"]); document.body.append(container); } const alert = VOR.createEl("div", ["vor-alert", `is-${type}`]); alert.append(document.createTextNode(message)); container.append(alert); setTimeout(() => { alert.remove(); if (!container.children.length) container.remove(); }, timeout); }; VOR.uiConfirm = function ({ title = "Confirm", text = "", confirmLabel = "Confirm", cancelLabel = "Cancel", onConfirm = null, } = {}) { const overlay = VOR.createEl("div", ["modal-overlay", "is-active"]); overlay.setAttribute("aria-hidden", "false"); const content = VOR.createEl("div", ["modal-content", "vor-modal-sm"]); const header = VOR.createEl("div", ["modal-header"]); const titleEl = VOR.createEl("h3", ["modal-title"], title); const closeBtn = VOR.createEl("button", ["btn-close"], "×", { type: "button", "aria-label": "Close" }); closeBtn.addEventListener("click", () => { overlay.remove(); document.body.classList.remove("lock-scroll"); }); header.append(titleEl, closeBtn); const body = VOR.createEl("div", ["modal-body"]); body.append(VOR.createEl("p", null, text)); const footer = VOR.createEl("div", ["modal-footer"]); const cancelBtn = VOR.createEl("button", ["btn-gray-light"], cancelLabel, { type: "button" }); const confirmBtn = VOR.createEl("button", ["btn-red"], confirmLabel, { type: "button" }); cancelBtn.addEventListener("click", () => { overlay.remove(); document.body.classList.remove("lock-scroll"); }); confirmBtn.addEventListener("click", () => { if (typeof onConfirm === "function") onConfirm(); overlay.remove(); document.body.classList.remove("lock-scroll"); }); footer.append(cancelBtn, confirmBtn); content.append(header, body, footer); overlay.append(content); document.body.append(overlay); document.body.classList.add("lock-scroll"); }; VOR.uiForm = function ({ fields = [], actions = [] } = {}) { const form = VOR.createEl("form", ["vor-form"]); fields.forEach((f) => { const group = VOR.createEl("div", ["form-group"]); if (f.label) { group.append(VOR.createEl("label", null, f.label)); } const input = VOR.createInput({ type: f.type || "text", name: f.name, value: f.value ?? null, options: f.options ?? null, classArr: ["input"], }); group.append(input); form.append(group); }); if (actions.length) { const actionWrap = VOR.createEl("div", ["form-actions"]); actions.forEach((a) => { const btn = VOR.createEl( "button", ["btn", a.variant ? `is-${a.variant}` : null].filter(Boolean), a.label, { type: a.type || "button" } ); if (a.onClick) btn.addEventListener("click", a.onClick); actionWrap.append(btn); }); form.append(actionWrap); } return form; }; VOR.uiDropdown = function ({ trigger, items = [] } = {}) { const menu = VOR.createEl("div", ["vor-dropdown"]); items.forEach((i) => { const item = VOR.createEl("div", ["dropdown-item"], i.label); item.addEventListener("click", () => { if (typeof i.action === "function") i.action(); menu.remove(); }); menu.append(item); }); document.body.append(menu); const rect = trigger.getBoundingClientRect(); menu.style.position = "absolute"; menu.style.top = `${rect.bottom + window.scrollY}px`; menu.style.left = `${rect.left + window.scrollX}px`; document.addEventListener("click", () => menu.remove(), { once: true }); }; VOR.uiPagination = function ({ page = 1, totalPages = 1, onChange = null } = {}) { const wrap = VOR.createEl("div", ["vor-pagination"]); for (let i = 1; i <= totalPages; i++) { const btn = VOR.createEl( "button", ["page-btn", i === page ? "is-active" : null].filter(Boolean), i ); btn.addEventListener("click", () => { if (typeof onChange === "function") onChange(i); }); wrap.append(btn); } return wrap; }; VOR.uiBadge = function (text, type = "neutral") { return VOR.createEl("span", ["badge", `is-${type}`], text); }; /* ============================================================ UI: Loading / Field Errors / Empty State ============================================================ */ VOR.uiLoader = function (el, state = true) { if (!el) return; if (state) { el.dataset.originalText = el.textContent; el.textContent = "Loading..."; el.disabled = true; } else { el.textContent = el.dataset.originalText || ""; el.disabled = false; } }; VOR.uiFieldError = function (formEl, errors = {}) { if (!formEl) return; formEl.querySelectorAll(".field-error").forEach((e) => e.remove()); Object.entries(errors).forEach(([name, msg]) => { const field = formEl.querySelector(`[name="${name}"]`); if (!field) return; const err = VOR.createEl("div", ["field-error"], msg); field.closest(".form-group")?.append(err); }); }; VOR.uiEmpty = function ({ title = "", text = "", action = null } = {}) { const wrap = VOR.createEl("div", ["vor-empty"]); if (title) wrap.append(VOR.createEl("h3", null, title)); if (text) wrap.append(VOR.createEl("p", null, text)); if (action) { const btn = VOR.createEl("button", ["btn"], action.label); btn.addEventListener("click", action.onClick); wrap.append(btn); } return wrap; }; /* ============================================================ DOM Helpers ============================================================ */ VOR.createEl = function (tag, classArr = null, text = null, attrs = null) { const el = document.createElement(tag); if (text !== null && text !== undefined) { el.append(document.createTextNode(String(text))); } if (classArr?.length) { el.classList.add(...classArr); } if (attrs) { for (const [k, v] of Object.entries(attrs)) { if (v === null || v === undefined) continue; if (k.startsWith("data-")) { el.setAttribute(k, String(v)); } else if (k.startsWith("on") && typeof v === "function") { el.addEventListener(k.slice(2), v); } else if (k in el) { el[k] = v; } else { el.setAttribute(k, String(v)); } } } return el; }; VOR.createInput = function ({ type = "text", classArr = null, placeholder = "", options = null, name, id = null, dataset = null, value = null, } = {}) { if (!name) { throw new Error("Create input: Name is required"); } const el = type === "select" ? VOR.createEl("select", classArr) : VOR.createEl("input", classArr); el.name = name; if (id) el.id = id; if (dataset) { for (const [k, v] of Object.entries(dataset)) { if (v !== undefined && v !== null) el.dataset[k] = String(v); } } if (type !== "select") { el.type = type; if (placeholder && !["checkbox", "radio", "hidden"].includes(type)) { el.placeholder = placeholder; } if (["checkbox", "radio"].includes(type)) { if (value === true) { el.checked = true; } else if (value !== null && value !== undefined) { el.value = String(value); } } else if (value !== null && value !== undefined) { el.value = String(value); } } else if (options) { for (const [val, txt] of Object.entries(options)) { const opt = document.createElement("option"); opt.value = String(val); opt.textContent = String(txt); if (value !== null && value !== undefined && String(val) === String(value)) { opt.selected = true; } el.appendChild(opt); } } return el; }; VOR.clearHtml = function (el) { if (!el) return el; el.replaceChildren(); return el; }; VOR.getDefaultId = function () { const id = window.location.pathname.replace(/\/$/, "").split("/").pop(); const num = parseInt(id, 10); return Number.isFinite(num) ? num : null; }; VOR.getRandomId = function () { return crypto?.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2); }; VOR.prettyNum = function ( val, { type = "decimal", locale = VOR.locale, currency = VOR.currency } = {} ) { if (val === null || val === undefined || val === "") return ""; const opts = { style: type }; if (type === "currency") opts.currency = currency; const n = typeof val === "string" ? Number(val) : val; return new Intl.NumberFormat(locale, opts).format(n); }; /* ============================================================ Theme / Modals / Toggles ============================================================ */ VOR.uiGetTheme = function () { const t = document.documentElement.getAttribute("data-theme"); return t === "dark" || t === "light" ? t : "light"; }; VOR.uiSetTheme = function (theme = "light") { const next = theme === "dark" ? "dark" : "light"; document.documentElement.setAttribute("data-theme", next); localStorage.setItem("vor_theme", next); }; VOR.uiToggleTheme = function () { const next = VOR.uiGetTheme() === "dark" ? "light" : "dark"; VOR.uiSetTheme(next); }; VOR.uiCloseAllModals = function () { document.querySelectorAll(".modal-overlay.is-active").forEach((m) => { m.classList.remove("is-active"); m.setAttribute("aria-hidden", "true"); }); document.body.classList.remove("lock-scroll"); }; VOR.uiToggle = function (selector, forceState = null) { const el = document.querySelector(selector); if (!el) return; const isDrawer = el.classList.contains("drawer"); const isModal = el.classList.contains("modal-overlay"); const activeClass = isDrawer ? "is-open" : "is-active"; const nextState = forceState !== null ? !!forceState : !el.classList.contains(activeClass); el.classList.toggle(activeClass, nextState); el.setAttribute("aria-hidden", nextState ? "false" : "true"); if (isModal) { document.body.classList.toggle("lock-scroll", nextState); } }; /* ============================================================ Global Event Wiring ============================================================ */ VOR.initGlobalEvents = function () { const app = document.body; app.addEventListener("click", (e) => { const toggleBtn = e.target.closest("[data-toggle]"); if (toggleBtn) { const sel = toggleBtn.dataset.toggle; if (sel) VOR.uiToggle(sel); return; } const themeBtn = e.target.closest("[data-theme-toggle]"); if (themeBtn) { const v = (themeBtn.dataset.themeToggle || "").trim(); if (v === "light" || v === "dark") { VOR.uiSetTheme(v); } else { VOR.uiToggleTheme(); } return; } const accordionHeader = e.target.closest(".accordion-header"); if (accordionHeader) { const item = accordionHeader.closest(".accordion-item"); if (item) item.classList.toggle("is-active"); return; } const chip = e.target.closest("[data-chip]"); if (chip) { const scope = chip.parentElement || document; scope .querySelectorAll("[data-chip].is-active") .forEach((c) => c.classList.remove("is-active")); chip.classList.add("is-active"); return; } const modalOverlay = e.target.closest(".modal-overlay"); if (modalOverlay && modalOverlay.classList.contains("is-active")) { if (e.target.closest(".modal-content")) return; modalOverlay.classList.remove("is-active"); modalOverlay.setAttribute("aria-hidden", "true"); document.body.classList.remove("lock-scroll"); return; } const drawerModal = e.target.closest(".drawer-modal"); if (drawerModal && drawerModal.classList.contains("is-active")) { if (e.target.closest(".drawer-panel")) return; if (drawerModal.dataset.closeBackdrop === "0") return; VOR.uiDrawerModalClose(drawerModal); return; } }); document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; const openModal = document.querySelector(".modal-overlay.is-active"); if (openModal) { openModal.classList.remove("is-active"); openModal.setAttribute("aria-hidden", "true"); if (!document.querySelector(".modal-overlay.is-active, .drawer-modal.is-active")) { document.body.classList.remove("lock-scroll"); } return; } const openDrawerModal = document.querySelector(".drawer-modal.is-active"); if (openDrawerModal) { if (openDrawerModal.dataset.closeEsc === "0") return; VOR.uiDrawerModalClose(openDrawerModal); return; } const openDrawer = document.querySelector(".drawer.is-open"); if (openDrawer) { openDrawer.classList.remove("is-open"); openDrawer.setAttribute("aria-hidden", "true"); } }); }; /* ============================================================ UI: Drawer Modal (off-canvas modal) ============================================================ */ VOR.uiDrawerModalOpen = function ({ id = "vor-drawer-modal", title = "", content = null, side = "right", width = null, onClose = null, closeOnBackdrop = true, closeOnEsc = true, showHeader = true, showFooter = false, footer = null, } = {}) { const existing = document.getElementById(id); if (existing) existing.remove(); const overlay = VOR.createEl("div", ["drawer-modal"], null, { id, "data-modal-type": "drawer", "data-close-backdrop": closeOnBackdrop ? "1" : "0", "data-close-esc": closeOnEsc ? "1" : "0", }); if (side === "left") overlay.classList.add("is-left"); const panel = VOR.createEl("div", ["drawer-panel"]); if (width) panel.style.width = String(width); if (showHeader) { const header = VOR.createEl("div", ["drawer-panel-header"]); const h = VOR.createEl("div"); if (title) h.append(VOR.createEl("div", ["h5"], title)); const closeBtn = VOR.createEl("button", ["btn-close"], "×", { type: "button", "aria-label": "Close", "data-drawer-close": "1", }); header.append(h, closeBtn); panel.append(header); } const body = VOR.createEl("div", ["drawer-panel-body"]); if (content instanceof HTMLElement) { body.append(content); } else if (typeof content === "string") { body.innerHTML = content; } else { } panel.append(body); if (showFooter) { const foot = VOR.createEl("div", ["drawer-panel-footer"]); if (footer instanceof HTMLElement) foot.append(footer); else if (typeof footer === "string") foot.innerHTML = footer; panel.append(foot); } overlay.append(panel); document.body.append(overlay); document.body.classList.add("lock-scroll"); requestAnimationFrame(() => { overlay.classList.add("is-active"); }); overlay.addEventListener("click", (e) => { if (e.target.closest("[data-drawer-close]")) { VOR.uiDrawerModalClose(id, onClose); } }); overlay.__vorOnClose = typeof onClose === "function" ? onClose : null; setTimeout(() => { const focusable = panel.querySelector( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); focusable?.focus?.(); }, 0); return { overlay, panel, body }; }; VOR.uiDrawerModalClose = function (id = "vor-drawer-modal", onClose = null) { const overlay = typeof id === "string" ? document.getElementById(id) : id; if (!overlay) return; overlay.classList.remove("is-active"); const cb = typeof onClose === "function" ? onClose : overlay.__vorOnClose; setTimeout(() => { overlay.remove(); if (!document.querySelector(".modal-overlay.is-active, .drawer-modal.is-active")) { document.body.classList.remove("lock-scroll"); } if (typeof cb === "function") cb(); }, 180); }; /* ============================================================ Boot ============================================================ */ document.addEventListener("DOMContentLoaded", () => { const savedTheme = localStorage.getItem("vor_theme"); VOR.uiSetTheme(savedTheme === "dark" ? "dark" : "light"); VOR.initGlobalEvents(); });