Files
testprojekt/rss/js/main.js

910 lines
26 KiB
JavaScript
Raw Normal View History

2026-04-06 16:49:17 -04:00
/* ============================================================
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();
});