Files
testprojekt/rss/js/main.js
2026-04-06 16:49:17 -04:00

910 lines
26 KiB
JavaScript
Raw Permalink 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.

/* ============================================================
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();
});