Initial commit
This commit is contained in:
910
rss/js/main.js
Normal file
910
rss/js/main.js
Normal file
@@ -0,0 +1,910 @@
|
||||
/* ============================================================
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user