914 lines
33 KiB
JavaScript
914 lines
33 KiB
JavaScript
(() => {
|
|
const body = document.body;
|
|
const media = window.matchMedia("(max-width: 900px)");
|
|
const sidebarPreferenceKey = "lumi-sidebar-collapsed";
|
|
|
|
if (!media.matches && window.localStorage.getItem(sidebarPreferenceKey) === "true") {
|
|
body.classList.add("sidebar-collapsed");
|
|
}
|
|
|
|
document.querySelectorAll("[data-sidebar-toggle]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
if (media.matches) {
|
|
body.classList.toggle("sidebar-open");
|
|
} else {
|
|
body.classList.toggle("sidebar-collapsed");
|
|
window.localStorage.setItem(
|
|
sidebarPreferenceKey,
|
|
body.classList.contains("sidebar-collapsed") ? "true" : "false"
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
document.querySelector("[data-sidebar-dismiss]")?.addEventListener("click", () => {
|
|
body.classList.remove("sidebar-open");
|
|
});
|
|
|
|
document.querySelectorAll(".nav-link").forEach((link) => {
|
|
link.addEventListener("click", () => {
|
|
if (body.classList.contains("sidebar-open")) {
|
|
body.classList.remove("sidebar-open");
|
|
}
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll(".nav-section").forEach((section) => {
|
|
const summary = section.querySelector("summary");
|
|
summary?.setAttribute("aria-expanded", section.open ? "true" : "false");
|
|
section.addEventListener("toggle", () => {
|
|
summary?.setAttribute("aria-expanded", section.open ? "true" : "false");
|
|
if (!section.open) return;
|
|
document.querySelectorAll(".nav-section[open]").forEach((other) => {
|
|
if (other !== section) {
|
|
other.open = false;
|
|
other.querySelector("summary")?.setAttribute("aria-expanded", "false");
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
media.addEventListener?.("change", () => {
|
|
body.classList.remove("sidebar-open");
|
|
if (media.matches) {
|
|
body.classList.remove("sidebar-collapsed");
|
|
} else if (window.localStorage.getItem(sidebarPreferenceKey) === "true") {
|
|
body.classList.add("sidebar-collapsed");
|
|
}
|
|
});
|
|
|
|
const editToggles = Array.from(
|
|
document.querySelectorAll("[data-edit-toggle]")
|
|
);
|
|
if (editToggles.length) {
|
|
const editRows = Array.from(document.querySelectorAll("[data-edit-row]"));
|
|
const updateToggleStates = () => {
|
|
editToggles.forEach((button) => {
|
|
const key = button.dataset.editToggle;
|
|
const row = editRows.find((item) => item.dataset.editRow === key);
|
|
const isOpen = row?.classList.contains("is-open");
|
|
button.setAttribute("aria-expanded", isOpen ? "true" : "false");
|
|
});
|
|
};
|
|
|
|
editToggles.forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const key = button.dataset.editToggle;
|
|
const target = editRows.find((item) => item.dataset.editRow === key);
|
|
const willOpen = target ? !target.classList.contains("is-open") : false;
|
|
editRows.forEach((row) => {
|
|
row.classList.remove("is-open");
|
|
});
|
|
if (target && willOpen) {
|
|
target.classList.add("is-open");
|
|
}
|
|
updateToggleStates();
|
|
});
|
|
});
|
|
}
|
|
|
|
document.querySelectorAll("[data-table]").forEach((table) => {
|
|
const tbody = table.tBodies[0];
|
|
if (!tbody) {
|
|
return;
|
|
}
|
|
let rows = Array.from(tbody.rows);
|
|
const tableId = table.getAttribute("data-table");
|
|
const isCommandTable = tableId === "commands";
|
|
const isPageable =
|
|
table.dataset.pageable !== undefined && table.dataset.pageable !== "false";
|
|
const pageSizes = (table.dataset.pageSizes || "25,50,100,250")
|
|
.split(",")
|
|
.map((value) => Number(value.trim()))
|
|
.filter((value) => Number.isFinite(value) && value > 0);
|
|
const defaultSize = Number(table.dataset.pageSize) || pageSizes[0] || 25;
|
|
const sizeSelect = document.querySelector(`[data-table-size="${tableId}"]`);
|
|
const pagination = document.querySelector(
|
|
`[data-table-pagination="${tableId}"]`
|
|
);
|
|
const prevButton = pagination?.querySelector("[data-page-prev]");
|
|
const nextButton = pagination?.querySelector("[data-page-next]");
|
|
const pageLabel = pagination?.querySelector("[data-page-label]");
|
|
let currentPage = 1;
|
|
let currentPageSize = defaultSize;
|
|
|
|
const buildCommandGroups = () => {
|
|
const groupMap = new Map();
|
|
rows.forEach((row) => {
|
|
const key = row.dataset.commandRoot;
|
|
if (!key) {
|
|
return;
|
|
}
|
|
groupMap.set(key, { root: row, subRows: [] });
|
|
});
|
|
rows.forEach((row) => {
|
|
const parent = row.dataset.commandParent;
|
|
if (!parent) {
|
|
return;
|
|
}
|
|
const group = groupMap.get(parent);
|
|
if (group) {
|
|
group.subRows.push(row);
|
|
}
|
|
});
|
|
return groupMap;
|
|
};
|
|
|
|
const commandGroups = isCommandTable ? buildCommandGroups() : null;
|
|
let highlightTimeout = null;
|
|
|
|
const setGroupExpanded = (group, expanded) => {
|
|
if (!group || !group.root) {
|
|
return;
|
|
}
|
|
group.root.dataset.expanded = expanded ? "true" : "false";
|
|
group.root.classList.toggle("is-expanded", expanded);
|
|
group.subRows.forEach((row) => {
|
|
row.classList.toggle("is-visible", expanded);
|
|
});
|
|
const toggle = group.root.querySelector("[data-command-toggle]");
|
|
if (toggle) {
|
|
toggle.setAttribute("aria-expanded", expanded ? "true" : "false");
|
|
}
|
|
};
|
|
|
|
const clearCommandHighlights = () => {
|
|
tbody.querySelectorAll(".command-highlight").forEach((row) => {
|
|
row.classList.remove("command-highlight");
|
|
});
|
|
};
|
|
|
|
const highlightCommandRow = (row) => {
|
|
if (!row || !tbody.contains(row)) {
|
|
return;
|
|
}
|
|
clearCommandHighlights();
|
|
row.classList.add("command-highlight");
|
|
if (highlightTimeout) {
|
|
window.clearTimeout(highlightTimeout);
|
|
}
|
|
highlightTimeout = window.setTimeout(() => {
|
|
row.classList.remove("command-highlight");
|
|
}, 2200);
|
|
};
|
|
|
|
const revealAnchorRow = () => {
|
|
if (!isCommandTable || !commandGroups) {
|
|
return;
|
|
}
|
|
const anchor = window.location.hash.slice(1);
|
|
if (!anchor) {
|
|
return;
|
|
}
|
|
const target = document.getElementById(anchor);
|
|
if (!target || !tbody.contains(target)) {
|
|
return;
|
|
}
|
|
if (target.dataset.commandParent) {
|
|
const group = commandGroups.get(target.dataset.commandParent);
|
|
if (group) {
|
|
setGroupExpanded(group, true);
|
|
}
|
|
}
|
|
highlightCommandRow(target);
|
|
};
|
|
|
|
if (isCommandTable && commandGroups) {
|
|
commandGroups.forEach((group) => {
|
|
setGroupExpanded(group, false);
|
|
});
|
|
|
|
tbody.querySelectorAll("[data-command-toggle]").forEach((button) => {
|
|
button.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
const rootRow = button.closest("tr");
|
|
if (!rootRow) {
|
|
return;
|
|
}
|
|
const key = rootRow.dataset.commandRoot;
|
|
const group = key ? commandGroups.get(key) : null;
|
|
if (!group) {
|
|
return;
|
|
}
|
|
const expanded = rootRow.dataset.expanded === "true";
|
|
setGroupExpanded(group, !expanded);
|
|
});
|
|
});
|
|
|
|
revealAnchorRow();
|
|
window.addEventListener("hashchange", () => {
|
|
revealAnchorRow();
|
|
});
|
|
}
|
|
|
|
const filterInput = document.querySelector(
|
|
`[data-table-filter="${tableId}"]`
|
|
);
|
|
const filterSelect = document.querySelector(
|
|
`[data-table-filter-select="${tableId}"]`
|
|
);
|
|
const filterKey = filterSelect?.dataset.filterKey || "filter";
|
|
|
|
const refreshRows = () => {
|
|
rows = Array.from(tbody.rows);
|
|
};
|
|
|
|
const getFilteredRows = () => {
|
|
const term = (filterInput?.value || "").trim().toLowerCase();
|
|
const filterValue = (filterSelect?.value || "").trim().toLowerCase();
|
|
return rows.filter((row) => {
|
|
const haystack = (row.dataset.search || row.textContent || "")
|
|
.toLowerCase()
|
|
.trim();
|
|
const matchesTerm = !term || haystack.includes(term);
|
|
const rowFilter = (row.dataset[filterKey] || "").toLowerCase();
|
|
const matchesFilter = !filterValue || rowFilter === filterValue;
|
|
return matchesTerm && matchesFilter;
|
|
});
|
|
};
|
|
|
|
const applyPagination = () => {
|
|
if (!isPageable || isCommandTable) {
|
|
return;
|
|
}
|
|
refreshRows();
|
|
const filtered = getFilteredRows();
|
|
const totalPages = Math.max(
|
|
1,
|
|
Math.ceil(filtered.length / currentPageSize)
|
|
);
|
|
currentPage = Math.min(currentPage, totalPages);
|
|
const start = (currentPage - 1) * currentPageSize;
|
|
const end = start + currentPageSize;
|
|
const visible = new Set(filtered.slice(start, end));
|
|
|
|
rows.forEach((row) => {
|
|
row.style.display = visible.has(row) ? "" : "none";
|
|
});
|
|
|
|
if (pageLabel) {
|
|
pageLabel.textContent = `Page ${currentPage} of ${totalPages}`;
|
|
}
|
|
if (prevButton) {
|
|
prevButton.disabled = currentPage <= 1;
|
|
}
|
|
if (nextButton) {
|
|
nextButton.disabled = currentPage >= totalPages;
|
|
}
|
|
};
|
|
if (filterInput) {
|
|
filterInput.addEventListener("input", () => {
|
|
const term = filterInput.value.trim().toLowerCase();
|
|
const filterValue = (filterSelect?.value || "").trim().toLowerCase();
|
|
if (!isCommandTable || !commandGroups) {
|
|
if (isPageable) {
|
|
currentPage = 1;
|
|
applyPagination();
|
|
return;
|
|
}
|
|
rows.forEach((row) => {
|
|
const haystack = (row.dataset.search || row.textContent || "")
|
|
.toLowerCase()
|
|
.trim();
|
|
const rowFilter = (row.dataset[filterKey] || "").toLowerCase();
|
|
const matchesTerm = !term || haystack.includes(term);
|
|
const matchesFilter = !filterValue || rowFilter === filterValue;
|
|
row.style.display = matchesTerm && matchesFilter ? "" : "none";
|
|
});
|
|
return;
|
|
}
|
|
|
|
commandGroups.forEach((group) => {
|
|
const root = group.root;
|
|
const rootHaystack = (root.dataset.search || root.textContent || "")
|
|
.toLowerCase()
|
|
.trim();
|
|
const rootMatches = rootHaystack.includes(term);
|
|
const subMatches = group.subRows.filter((row) => {
|
|
const haystack = (row.dataset.search || row.textContent || "")
|
|
.toLowerCase()
|
|
.trim();
|
|
return haystack.includes(term);
|
|
});
|
|
|
|
const showGroup = !term || rootMatches || subMatches.length > 0;
|
|
root.style.display = showGroup ? "" : "none";
|
|
if (!term) {
|
|
const expanded = root.dataset.expanded === "true";
|
|
group.subRows.forEach((row) => {
|
|
row.style.display = expanded ? "" : "none";
|
|
});
|
|
return;
|
|
}
|
|
if (subMatches.length > 0) {
|
|
setGroupExpanded(group, true);
|
|
}
|
|
group.subRows.forEach((row) => {
|
|
row.style.display = subMatches.includes(row) ? "" : "none";
|
|
});
|
|
});
|
|
});
|
|
}
|
|
if (filterSelect) {
|
|
filterSelect.addEventListener("change", () => {
|
|
if (isPageable) {
|
|
currentPage = 1;
|
|
applyPagination();
|
|
} else if (filterInput) {
|
|
filterInput.dispatchEvent(new Event("input"));
|
|
}
|
|
});
|
|
}
|
|
|
|
const headers = table.querySelectorAll("th[data-sort]");
|
|
headers.forEach((header) => {
|
|
header.addEventListener("click", () => {
|
|
const key = header.dataset.sort;
|
|
const currentKey = table.dataset.sortKey;
|
|
const currentDir = table.dataset.sortDir || "asc";
|
|
const nextDir = currentKey === key && currentDir === "asc" ? "desc" : "asc";
|
|
table.dataset.sortKey = key;
|
|
table.dataset.sortDir = nextDir;
|
|
|
|
const compare = (a, b) => {
|
|
const aValue = (a.dataset[key] || "").toString();
|
|
const bValue = (b.dataset[key] || "").toString();
|
|
const aNumber = Number(aValue);
|
|
const bNumber = Number(bValue);
|
|
if (!Number.isNaN(aNumber) && !Number.isNaN(bNumber)) {
|
|
return aNumber - bNumber;
|
|
}
|
|
return aValue.localeCompare(bValue);
|
|
};
|
|
|
|
if (isCommandTable && commandGroups) {
|
|
const roots = Array.from(commandGroups.values()).map((group) => group.root);
|
|
const sorted = roots.slice().sort(compare);
|
|
if (nextDir === "desc") {
|
|
sorted.reverse();
|
|
}
|
|
sorted.forEach((root) => {
|
|
const group = commandGroups.get(root.dataset.commandRoot);
|
|
tbody.appendChild(root);
|
|
group?.subRows.forEach((row) => tbody.appendChild(row));
|
|
});
|
|
return;
|
|
}
|
|
|
|
const sorted = rows.slice().sort(compare);
|
|
if (nextDir === "desc") {
|
|
sorted.reverse();
|
|
}
|
|
sorted.forEach((row) => tbody.appendChild(row));
|
|
refreshRows();
|
|
if (isPageable) {
|
|
currentPage = 1;
|
|
applyPagination();
|
|
}
|
|
});
|
|
});
|
|
|
|
if (isPageable && sizeSelect) {
|
|
sizeSelect.value = currentPageSize.toString();
|
|
sizeSelect.addEventListener("change", () => {
|
|
const nextSize = Number(sizeSelect.value);
|
|
if (Number.isFinite(nextSize) && nextSize > 0) {
|
|
currentPageSize = nextSize;
|
|
currentPage = 1;
|
|
applyPagination();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (isPageable && prevButton && nextButton) {
|
|
prevButton.addEventListener("click", () => {
|
|
if (currentPage > 1) {
|
|
currentPage -= 1;
|
|
applyPagination();
|
|
}
|
|
});
|
|
nextButton.addEventListener("click", () => {
|
|
currentPage += 1;
|
|
applyPagination();
|
|
});
|
|
}
|
|
|
|
if (isPageable) {
|
|
applyPagination();
|
|
}
|
|
});
|
|
|
|
const logList = document.querySelector("[data-log-list]");
|
|
if (logList) {
|
|
const entries = Array.from(logList.querySelectorAll("[data-log-entry]"));
|
|
const searchInput = document.querySelector("[data-log-search]");
|
|
const levelSelect = document.querySelector("[data-log-level]");
|
|
const rangeSelect = document.querySelector("[data-log-range]");
|
|
const limitSelect = document.querySelector("[data-log-limit]");
|
|
|
|
const applyLogFilters = () => {
|
|
const term = (searchInput?.value || "").trim().toLowerCase();
|
|
|
|
entries.forEach((entry) => {
|
|
const haystack = (entry.dataset.search || entry.textContent || "")
|
|
.toLowerCase()
|
|
.trim();
|
|
|
|
const matchesTerm = !term || haystack.includes(term);
|
|
entry.style.display = matchesTerm ? "" : "none";
|
|
});
|
|
};
|
|
|
|
searchInput?.addEventListener("input", applyLogFilters);
|
|
|
|
applyLogFilters();
|
|
|
|
const reloadLogView = () => {
|
|
const url = new URL(window.location.href);
|
|
const rangeValue = rangeSelect?.value || "all";
|
|
const levelValue = levelSelect?.value || "all";
|
|
const limitValue = limitSelect?.value || "50";
|
|
url.searchParams.set("range", rangeValue);
|
|
url.searchParams.set("level", levelValue);
|
|
url.searchParams.set("limit", limitValue);
|
|
window.location.assign(url.toString());
|
|
};
|
|
|
|
levelSelect?.addEventListener("change", reloadLogView);
|
|
rangeSelect?.addEventListener("change", reloadLogView);
|
|
limitSelect?.addEventListener("change", reloadLogView);
|
|
}
|
|
|
|
const logModal = document.querySelector("[data-log-modal]");
|
|
const logModalOpen = document.querySelector("[data-log-download]");
|
|
if (logModal && logModalOpen) {
|
|
const closeButtons = logModal.querySelectorAll("[data-modal-close]");
|
|
const closeModal = () => {
|
|
logModal.classList.remove("is-open");
|
|
logModal.setAttribute("aria-hidden", "true");
|
|
};
|
|
const openModal = () => {
|
|
logModal.classList.add("is-open");
|
|
logModal.setAttribute("aria-hidden", "false");
|
|
};
|
|
logModalOpen.addEventListener("click", openModal);
|
|
closeButtons.forEach((button) => {
|
|
button.addEventListener("click", closeModal);
|
|
});
|
|
logModal.addEventListener("click", (event) => {
|
|
if (event.target === logModal) {
|
|
closeModal();
|
|
}
|
|
});
|
|
window.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape" && logModal.classList.contains("is-open")) {
|
|
closeModal();
|
|
}
|
|
});
|
|
}
|
|
|
|
const compareToggle = document.querySelector("[data-compare-toggle]");
|
|
if (compareToggle) {
|
|
const defaultLabel = compareToggle.textContent.trim();
|
|
const altLabel = compareToggle.getAttribute("data-compare-label") || "Back";
|
|
compareToggle.addEventListener("click", () => {
|
|
const active = document.body.classList.toggle("stats-compare-mode");
|
|
compareToggle.textContent = active ? altLabel : defaultLabel;
|
|
});
|
|
}
|
|
|
|
const healthEndpoint = "/health";
|
|
let connectionLost = false;
|
|
const checkConnection = async () => {
|
|
try {
|
|
const response = await fetch(healthEndpoint, { cache: "no-store" });
|
|
if (response.ok) {
|
|
if (connectionLost) {
|
|
window.LumiInteractions?.showEventNotice?.({
|
|
message: "Connection restored. Refresh manually if you need newer page data."
|
|
}, "info");
|
|
}
|
|
connectionLost = false;
|
|
} else {
|
|
connectionLost = true;
|
|
}
|
|
} catch {
|
|
connectionLost = true;
|
|
}
|
|
};
|
|
|
|
window.addEventListener("online", () => {
|
|
checkConnection();
|
|
});
|
|
|
|
window.addEventListener("offline", () => {
|
|
connectionLost = true;
|
|
});
|
|
|
|
window.setInterval(checkConnection, 5000);
|
|
|
|
const destructivePattern =
|
|
/(?:^|\/)(?:delete|remove|clear|reset|renew|uninstall|cleanup|archive|revoke|unlink|unset)(?:\/|$)/i;
|
|
const highImpactPattern =
|
|
/(?:\/plugins\/[^/]+\/uninstall|\/models\/[^/]+\/delete|\/storage\/cleanup|\/navigation\/reset|\/updates\/|\/remove-data)/i;
|
|
const destructiveModal = document.querySelector("[data-destructive-modal]");
|
|
const destructiveTitle = destructiveModal?.querySelector("[data-destructive-title]");
|
|
const destructiveDescription = destructiveModal?.querySelector("[data-destructive-description]");
|
|
const destructiveConfirm = destructiveModal?.querySelector("[data-destructive-confirm]");
|
|
const destructiveStates = new WeakMap();
|
|
let activeDestructive = null;
|
|
let activeCallbackConfirm = null;
|
|
|
|
const destructiveAction = (form, submitter = null) => {
|
|
try {
|
|
const action = submitter?.formAction || form.action;
|
|
return new URL(action, window.location.origin).pathname;
|
|
} catch {
|
|
return "";
|
|
}
|
|
};
|
|
|
|
const actionCopy = (action) => {
|
|
const normalized = String(action || "").toLowerCase();
|
|
if (normalized.includes("/delete")) return { title: "Confirm deletion", label: "Delete" };
|
|
if (normalized.includes("/uninstall")) return { title: "Confirm uninstall", label: "Uninstall" };
|
|
if (normalized.includes("/cleanup")) return { title: "Confirm cleanup", label: "Clean selected" };
|
|
if (normalized.includes("/reset")) return { title: "Confirm reset", label: "Reset" };
|
|
if (normalized.includes("/remove")) return { title: "Confirm removal", label: "Remove" };
|
|
if (normalized.includes("/update")) return { title: "Confirm update", label: "Update" };
|
|
if (normalized.includes("/restart")) return { title: "Confirm restart", label: "Restart" };
|
|
return { title: "Confirm action", label: "Confirm" };
|
|
};
|
|
|
|
const isDestructiveForm = (form, submitter = null) => {
|
|
if (!form || form.dataset.noDestructiveConfirm !== undefined) return false;
|
|
if (form.dataset.updateAction !== undefined) return false;
|
|
return String(form.method || "get").toLowerCase() === "post" &&
|
|
destructivePattern.test(destructiveAction(form, submitter));
|
|
};
|
|
|
|
const resetDestructive = (form) => {
|
|
const state = destructiveStates.get(form);
|
|
if (state?.timer) window.clearInterval(state.timer);
|
|
if (state?.expiryTimer) window.clearTimeout(state.expiryTimer);
|
|
state?.inline?.remove();
|
|
form.querySelector('input[name="confirmation_token"]')?.remove();
|
|
destructiveStates.delete(form);
|
|
if (activeDestructive?.form === form) {
|
|
destructiveModal?.classList.remove("is-open");
|
|
destructiveModal?.setAttribute("aria-hidden", "true");
|
|
activeDestructive = null;
|
|
}
|
|
if (form.dataset.syntheticConfirmation === "true") form.remove();
|
|
};
|
|
|
|
const resetCallbackConfirm = (result = false) => {
|
|
const active = activeCallbackConfirm;
|
|
if (!active) return;
|
|
activeCallbackConfirm = null;
|
|
destructiveModal?.classList.remove("is-open");
|
|
destructiveModal?.setAttribute("aria-hidden", "true");
|
|
destructiveConfirm?.removeEventListener("click", active.onConfirm);
|
|
active.resolve(result);
|
|
active.returnFocus?.focus?.();
|
|
};
|
|
|
|
window.LumiConfirm = {
|
|
destructive({ title = "Confirm action", text = "This action cannot be undone.", label = "Confirm", danger = true } = {}) {
|
|
if (!destructiveModal || !destructiveConfirm) {
|
|
return Promise.resolve(window.confirm(text));
|
|
}
|
|
if (activeDestructive?.form) resetDestructive(activeDestructive.form);
|
|
if (activeCallbackConfirm) resetCallbackConfirm(false);
|
|
return new Promise((resolve) => {
|
|
const returnFocus = document.activeElement;
|
|
destructiveTitle.textContent = title;
|
|
destructiveDescription.textContent = text;
|
|
destructiveConfirm.disabled = false;
|
|
destructiveConfirm.textContent = label;
|
|
destructiveConfirm.classList.toggle("danger", danger);
|
|
destructiveModal.classList.add("is-open");
|
|
destructiveModal.setAttribute("aria-hidden", "false");
|
|
const onConfirm = () => resetCallbackConfirm(true);
|
|
activeCallbackConfirm = { resolve, returnFocus, onConfirm };
|
|
destructiveConfirm.addEventListener("click", onConfirm);
|
|
destructiveConfirm.focus();
|
|
});
|
|
}
|
|
};
|
|
|
|
const submitDestructive = (form, submitter, token) => {
|
|
let tokenField = form.querySelector('input[name="confirmation_token"]');
|
|
if (!tokenField) {
|
|
tokenField = document.createElement("input");
|
|
tokenField.type = "hidden";
|
|
tokenField.name = "confirmation_token";
|
|
form.append(tokenField);
|
|
}
|
|
tokenField.value = token;
|
|
const state = destructiveStates.get(form) || {};
|
|
state.confirmed = true;
|
|
destructiveStates.set(form, state);
|
|
form.requestSubmit(submitter?.form === form ? submitter : undefined);
|
|
};
|
|
|
|
const confirmLabel = (form, submitter = null) => form.dataset.confirmLabel || submitter?.dataset?.confirmLabel || actionCopy(destructiveAction(form, submitter)).label;
|
|
|
|
const startCountdown = ({ form, button, token, notBefore, expiresAt, submitter }) => {
|
|
const state = destructiveStates.get(form) || {};
|
|
const update = () => {
|
|
const remaining = Math.max(0, Math.ceil((notBefore - Date.now()) / 1000));
|
|
button.disabled = remaining > 0;
|
|
button.textContent = remaining > 0 ? `${confirmLabel(form, submitter)} in ${remaining}` : confirmLabel(form, submitter);
|
|
if (!remaining && state.timer) {
|
|
window.clearInterval(state.timer);
|
|
state.timer = null;
|
|
}
|
|
};
|
|
update();
|
|
state.timer = window.setInterval(update, 200);
|
|
state.expiryTimer = window.setTimeout(() => resetDestructive(form), Math.max(0, expiresAt - Date.now()));
|
|
button.addEventListener("click", () => {
|
|
if (!button.disabled) submitDestructive(form, submitter, token);
|
|
}, { once: true });
|
|
destructiveStates.set(form, state);
|
|
};
|
|
|
|
const issueDestructiveConfirmation = async (form, submitter) => {
|
|
if (destructiveStates.has(form)) return;
|
|
const action = destructiveAction(form, submitter);
|
|
const state = { confirmed: false, inline: null, timer: null, expiryTimer: null };
|
|
destructiveStates.set(form, state);
|
|
const copy = actionCopy(action);
|
|
const message = submitter?.dataset?.confirmText || form.dataset.confirmText || form.dataset.confirmForm || "This action cannot be undone.";
|
|
const mode = form.dataset.confirmMode || (highImpactPattern.test(action) ? "modal" : "inline");
|
|
let confirmButton;
|
|
|
|
if (mode === "modal" && destructiveModal && destructiveConfirm) {
|
|
if (activeDestructive?.form) resetDestructive(activeDestructive.form);
|
|
activeDestructive = { form };
|
|
destructiveTitle.textContent = submitter?.dataset?.confirmTitle || form.dataset.confirmTitle || copy.title;
|
|
destructiveDescription.textContent = message;
|
|
destructiveConfirm.disabled = true;
|
|
destructiveConfirm.classList.add("danger");
|
|
destructiveConfirm.textContent = "Preparing...";
|
|
destructiveModal.classList.add("is-open");
|
|
destructiveModal.setAttribute("aria-hidden", "false");
|
|
confirmButton = destructiveConfirm;
|
|
} else {
|
|
const inline = document.createElement("span");
|
|
inline.className = "destructive-inline-confirm";
|
|
const label = document.createElement("span");
|
|
label.textContent = message;
|
|
const cancel = document.createElement("button");
|
|
cancel.type = "button";
|
|
cancel.className = "button subtle";
|
|
cancel.textContent = "Cancel";
|
|
cancel.addEventListener("click", () => resetDestructive(form));
|
|
confirmButton = document.createElement("button");
|
|
confirmButton.type = "button";
|
|
confirmButton.className = "button danger";
|
|
confirmButton.disabled = true;
|
|
confirmButton.textContent = "Preparing...";
|
|
inline.append(label, cancel, confirmButton);
|
|
form.insertAdjacentElement("afterend", inline);
|
|
state.inline = inline;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch("/api/destructive-confirmations", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
|
body: JSON.stringify({ action })
|
|
});
|
|
const payload = await response.json();
|
|
if (!response.ok) throw new Error(payload.error || "Unable to prepare confirmation.");
|
|
if (destructiveStates.get(form) !== state) return;
|
|
startCountdown({
|
|
form,
|
|
button: confirmButton,
|
|
token: payload.token,
|
|
notBefore: payload.not_before,
|
|
expiresAt: payload.expires_at,
|
|
submitter
|
|
});
|
|
} catch (error) {
|
|
if (destructiveStates.get(form) !== state) return;
|
|
confirmButton.disabled = true;
|
|
confirmButton.textContent = "Confirmation unavailable";
|
|
confirmButton.title = error.message;
|
|
}
|
|
};
|
|
|
|
document.addEventListener("submit", (event) => {
|
|
const form = event.target;
|
|
if (!(form instanceof HTMLFormElement) || !isDestructiveForm(form, event.submitter)) return;
|
|
const state = destructiveStates.get(form);
|
|
if (state?.confirmed) {
|
|
state.confirmed = false;
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
issueDestructiveConfirmation(form, event.submitter);
|
|
}, true);
|
|
|
|
document.addEventListener("click", (event) => {
|
|
const button = event.target.closest("[data-confirm-action]");
|
|
if (!button) return;
|
|
const action = button.dataset.confirmAction || "";
|
|
if (!destructivePattern.test(action)) return;
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
const form = document.createElement("form");
|
|
form.method = "post";
|
|
form.action = action;
|
|
form.hidden = true;
|
|
form.dataset.confirmMode = "modal";
|
|
form.dataset.syntheticConfirmation = "true";
|
|
form.dataset.confirmTitle = button.dataset.confirmTitle || "Confirm destructive action";
|
|
form.dataset.confirmText = button.dataset.confirmText || "This action cannot be undone.";
|
|
form.dataset.confirmLabel = button.dataset.confirmLabel || "Confirm";
|
|
document.body.append(form);
|
|
issueDestructiveConfirmation(form, null);
|
|
}, true);
|
|
|
|
document.querySelectorAll("[data-destructive-cancel]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
if (activeDestructive?.form) resetDestructive(activeDestructive.form);
|
|
else resetCallbackConfirm(false);
|
|
});
|
|
});
|
|
destructiveModal?.addEventListener("click", (event) => {
|
|
if (event.target === destructiveModal && activeDestructive?.form) {
|
|
resetDestructive(activeDestructive.form);
|
|
} else if (event.target === destructiveModal) {
|
|
resetCallbackConfirm(false);
|
|
}
|
|
});
|
|
window.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape" && activeDestructive?.form) {
|
|
resetDestructive(activeDestructive.form);
|
|
} else if (event.key === "Escape") {
|
|
resetCallbackConfirm(false);
|
|
}
|
|
});
|
|
|
|
const updateLog = document.querySelector("[data-update-progress-log]");
|
|
if (updateLog) {
|
|
const appendUpdateLog = (message, level = "info") => {
|
|
const row = document.createElement("div");
|
|
row.className = `update-progress-entry ${level}`;
|
|
row.textContent = message;
|
|
updateLog.prepend(row);
|
|
};
|
|
try {
|
|
const source = new EventSource("/admin/updates/events");
|
|
[
|
|
"update:queued",
|
|
"update:checking",
|
|
"update:metadata",
|
|
"update:snapshot",
|
|
"update:recovery_marker",
|
|
"update:download",
|
|
"update:apply",
|
|
"update:verify",
|
|
"update:restart_required",
|
|
"update:complete",
|
|
"update:failed",
|
|
"update:revert",
|
|
"recovery:plugin_disabled",
|
|
"recovery:retry_startup"
|
|
].forEach((eventName) => {
|
|
source.addEventListener(eventName, (event) => {
|
|
const payload = JSON.parse(event.data || "{}");
|
|
const target = payload.plugin_id ? `plugin ${payload.plugin_id}` : payload.target || "recovery";
|
|
appendUpdateLog(`${eventName.replace("update:", "").replace("recovery:", "recovery ")}: ${target}`, eventName.includes("failed") ? "danger" : "info");
|
|
});
|
|
});
|
|
} catch {
|
|
appendUpdateLog("Live update stream is unavailable.", "danger");
|
|
}
|
|
|
|
document.querySelectorAll("form[data-update-action]").forEach((form) => {
|
|
form.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const submitter = event.submitter || form.querySelector("button[type='submit']");
|
|
const confirmed = form.dataset.confirmMode === "modal"
|
|
? await window.LumiConfirm?.destructive?.({
|
|
title: form.dataset.confirmTitle || "Confirm update action",
|
|
text: form.dataset.confirmText || "This update action will change local files.",
|
|
label: form.dataset.confirmLabel || submitter?.textContent || "Confirm"
|
|
})
|
|
: true;
|
|
if (!confirmed) {
|
|
window.LumiStateButton?.reset?.(submitter);
|
|
return;
|
|
}
|
|
const originalText = submitter?.textContent;
|
|
const isStateButton = submitter?.matches?.("[data-lumi-state-button]");
|
|
if (isStateButton) {
|
|
window.LumiStateButton?.setState?.(submitter, "loading", { busy: true });
|
|
} else if (submitter) {
|
|
submitter.disabled = true;
|
|
submitter.textContent = "Working...";
|
|
}
|
|
appendUpdateLog(`Started ${submitter?.textContent?.trim() || "update action"}.`);
|
|
try {
|
|
const response = await fetch(form.action, {
|
|
method: form.method || "POST",
|
|
body: new FormData(form),
|
|
headers: { Accept: "application/json" }
|
|
});
|
|
const result = await response.json();
|
|
if (!response.ok || result.ok === false) throw new Error(result.error || "Update action failed.");
|
|
if (isStateButton) window.LumiStateButton?.success?.(submitter);
|
|
else if (submitter) submitter.textContent = "Done";
|
|
appendUpdateLog(result.message || "Update action completed.", "success");
|
|
if (result.refresh_after_ms) {
|
|
appendUpdateLog(`Lumi will refresh in ${Math.round(result.refresh_after_ms / 1000)} seconds.`, "success");
|
|
window.setTimeout(() => window.location.reload(), Number(result.refresh_after_ms));
|
|
}
|
|
} catch (error) {
|
|
if (isStateButton) window.LumiStateButton?.error?.(submitter);
|
|
else if (submitter) submitter.textContent = "Failed";
|
|
appendUpdateLog(error.message, "danger");
|
|
} finally {
|
|
if (!isStateButton && submitter) {
|
|
window.setTimeout(() => {
|
|
submitter.disabled = false;
|
|
submitter.textContent = originalText;
|
|
}, 2500);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
document.querySelectorAll("[data-copy]").forEach((button) => {
|
|
button.addEventListener("click", async () => {
|
|
const text = button.getAttribute("data-copy") || "";
|
|
if (!text) {
|
|
return;
|
|
}
|
|
const label = button.querySelector("[data-copy-label]");
|
|
const originalLabel = label ? label.textContent : "";
|
|
|
|
const markCopied = () => {
|
|
button.classList.add("copied");
|
|
if (label) {
|
|
label.textContent = "Copied";
|
|
}
|
|
window.setTimeout(() => {
|
|
button.classList.remove("copied");
|
|
if (label) {
|
|
label.textContent = originalLabel;
|
|
}
|
|
}, 1200);
|
|
};
|
|
|
|
try {
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
await navigator.clipboard.writeText(text);
|
|
markCopied();
|
|
return;
|
|
}
|
|
} catch {
|
|
// Fall back to legacy copy.
|
|
}
|
|
|
|
const tempInput = document.createElement("input");
|
|
tempInput.value = text;
|
|
document.body.appendChild(tempInput);
|
|
tempInput.select();
|
|
try {
|
|
document.execCommand("copy");
|
|
markCopied();
|
|
} catch {
|
|
// Ignore copy errors.
|
|
} finally {
|
|
tempInput.remove();
|
|
}
|
|
});
|
|
});
|
|
})();
|