feat(setting): Implemented the settings administrative menu
This commit is contained in:
526
static/js/admin/boxes.js
Normal file
526
static/js/admin/boxes.js
Normal file
@@ -0,0 +1,526 @@
|
||||
(() => {
|
||||
const menuController = window.WarpBoxUI?.bindMenuBar?.() || {
|
||||
close() {
|
||||
document.querySelectorAll(".menu-item.is-open").forEach((item) => {
|
||||
item.classList.remove("is-open");
|
||||
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toastTarget = document.getElementById("toast");
|
||||
const dataNode = document.getElementById("boxes-data");
|
||||
const tableBody = document.getElementById("boxes-table-body");
|
||||
const emptyState = document.getElementById("boxes-empty-state");
|
||||
const searchInput = document.getElementById("boxes-search");
|
||||
const statusFilter = document.getElementById("boxes-status-filter");
|
||||
const flagFilter = document.getElementById("boxes-flag-filter");
|
||||
const sortFilter = document.getElementById("boxes-sort");
|
||||
const pageSizeFilter = document.getElementById("boxes-page-size");
|
||||
const selectAll = document.getElementById("boxes-select-all");
|
||||
const prevPageButton = document.getElementById("boxes-prev-page");
|
||||
const nextPageButton = document.getElementById("boxes-next-page");
|
||||
const pageLabel = document.getElementById("boxes-page-label");
|
||||
const rangeLabel = document.getElementById("boxes-range-label");
|
||||
const selectedLabel = document.getElementById("boxes-selected-label");
|
||||
const footerSummary = document.getElementById("boxes-footer-summary");
|
||||
const detailFileList = document.getElementById("detail-file-list");
|
||||
|
||||
if (!dataNode || !tableBody || !searchInput || !detailFileList) return;
|
||||
|
||||
const statEls = {
|
||||
total: document.querySelector("[data-stat-total]"),
|
||||
ready: document.querySelector("[data-stat-ready]"),
|
||||
uploading: document.querySelector("[data-stat-uploading]"),
|
||||
expired: document.querySelector("[data-stat-expired]")
|
||||
};
|
||||
|
||||
const detailEls = {
|
||||
boxId: document.getElementById("detail-box-id"),
|
||||
status: document.getElementById("detail-status"),
|
||||
created: document.getElementById("detail-created"),
|
||||
expires: document.getElementById("detail-expires"),
|
||||
retention: document.getElementById("detail-retention"),
|
||||
files: document.getElementById("detail-files"),
|
||||
size: document.getElementById("detail-size"),
|
||||
flags: document.getElementById("detail-flags"),
|
||||
open: document.getElementById("detail-open"),
|
||||
zip: document.getElementById("detail-zip")
|
||||
};
|
||||
|
||||
function showToast(message, type = "info", duration = 2200) {
|
||||
if (window.WarpBoxUI) {
|
||||
window.WarpBoxUI.toast(message, type, { target: toastTarget, duration });
|
||||
return;
|
||||
}
|
||||
if (!toastTarget) return;
|
||||
toastTarget.textContent = message;
|
||||
toastTarget.classList.add("is-visible");
|
||||
window.setTimeout(() => toastTarget.classList.remove("is-visible"), duration);
|
||||
}
|
||||
|
||||
function parseData() {
|
||||
try {
|
||||
return JSON.parse(dataNode.textContent || "[]");
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const state = {
|
||||
boxes: parseData(),
|
||||
selected: new Set(),
|
||||
activeId: null,
|
||||
page: 1
|
||||
};
|
||||
|
||||
function pageSize() {
|
||||
return Number(pageSizeFilter.value || 10);
|
||||
}
|
||||
|
||||
function allBoxes() {
|
||||
return state.boxes.slice();
|
||||
}
|
||||
|
||||
function sortBoxes(boxes) {
|
||||
const sorted = boxes.slice();
|
||||
switch (sortFilter.value) {
|
||||
case "name":
|
||||
sorted.sort((a, b) => a.id.localeCompare(b.id));
|
||||
break;
|
||||
case "largest":
|
||||
sorted.sort((a, b) => compareSizeLabel(a.total_size_label, b.total_size_label));
|
||||
break;
|
||||
case "expires":
|
||||
sorted.sort((a, b) => compareExpiry(a.expires_at_iso, b.expires_at_iso));
|
||||
break;
|
||||
default:
|
||||
sorted.sort((a, b) => (b.created_at_iso || "").localeCompare(a.created_at_iso || ""));
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function compareSizeLabel(left, right) {
|
||||
return sizeLabelToBytes(right) - sizeLabelToBytes(left);
|
||||
}
|
||||
|
||||
function sizeLabelToBytes(label) {
|
||||
const match = String(label || "").trim().match(/^([\d.]+)\s*([KMGT]?i?B|B)$/i);
|
||||
if (!match) return 0;
|
||||
const value = Number(match[1]);
|
||||
const unit = match[2].toUpperCase();
|
||||
const map = { B: 1, KIB: 1024, MIB: 1024 ** 2, GIB: 1024 ** 3, TIB: 1024 ** 4 };
|
||||
return value * (map[unit] || 1);
|
||||
}
|
||||
|
||||
function compareExpiry(left, right) {
|
||||
if (!left && !right) return 0;
|
||||
if (!left) return 1;
|
||||
if (!right) return -1;
|
||||
return left.localeCompare(right);
|
||||
}
|
||||
|
||||
function filteredBoxes() {
|
||||
const query = searchInput.value.trim().toLowerCase();
|
||||
const status = statusFilter.value;
|
||||
const flag = flagFilter.value;
|
||||
|
||||
return sortBoxes(allBoxes().filter((box) => {
|
||||
const matchesSearch = !query || String(box.search_text || "").includes(query);
|
||||
const matchesStatus = status === "all" || box.status === status;
|
||||
const matchesFlag = flag === "all" || (box.flags || []).includes(flag);
|
||||
return matchesSearch && matchesStatus && matchesFlag;
|
||||
}));
|
||||
}
|
||||
|
||||
function pagedBoxes(boxes) {
|
||||
const size = pageSize();
|
||||
const pages = Math.max(1, Math.ceil(boxes.length / size));
|
||||
if (state.page > pages) state.page = pages;
|
||||
if (state.page < 1) state.page = 1;
|
||||
const start = (state.page - 1) * size;
|
||||
return {
|
||||
items: boxes.slice(start, start + size),
|
||||
start,
|
||||
pages
|
||||
};
|
||||
}
|
||||
|
||||
function selectedBoxes() {
|
||||
return allBoxes().filter((box) => state.selected.has(box.id));
|
||||
}
|
||||
|
||||
function currentActiveBox() {
|
||||
const boxes = allBoxes();
|
||||
return boxes.find((box) => box.id === state.activeId) || null;
|
||||
}
|
||||
|
||||
function ensureActiveBox(filtered) {
|
||||
if (filtered.length === 0) {
|
||||
state.activeId = null;
|
||||
return null;
|
||||
}
|
||||
if (!filtered.some((box) => box.id === state.activeId)) {
|
||||
state.activeId = filtered[0].id;
|
||||
}
|
||||
return filtered.find((box) => box.id === state.activeId) || filtered[0];
|
||||
}
|
||||
|
||||
function renderSummary(filtered) {
|
||||
const total = filtered.length;
|
||||
const ready = filtered.filter((box) => box.status === "ready").length;
|
||||
const uploading = filtered.filter((box) => box.status === "uploading").length;
|
||||
const expired = filtered.filter((box) => box.status === "expired" || box.status === "consumed").length;
|
||||
statEls.total.textContent = String(total);
|
||||
statEls.ready.textContent = String(ready);
|
||||
statEls.uploading.textContent = String(uploading);
|
||||
statEls.expired.textContent = String(expired);
|
||||
footerSummary.textContent = `${allBoxes().length} boxes loaded`;
|
||||
selectedLabel.textContent = `Selected: ${state.selected.size}`;
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const filtered = filteredBoxes();
|
||||
const active = ensureActiveBox(filtered);
|
||||
const page = pagedBoxes(filtered);
|
||||
|
||||
tableBody.innerHTML = "";
|
||||
page.items.forEach((box) => tableBody.appendChild(buildRow(box)));
|
||||
emptyState.hidden = page.items.length !== 0;
|
||||
|
||||
const startIndex = filtered.length ? page.start + 1 : 0;
|
||||
const endIndex = page.start + page.items.length;
|
||||
rangeLabel.textContent = `Showing ${startIndex}-${endIndex} of ${filtered.length}`;
|
||||
pageLabel.textContent = `Page ${state.page} / ${page.pages}`;
|
||||
prevPageButton.disabled = state.page <= 1;
|
||||
nextPageButton.disabled = state.page >= page.pages;
|
||||
selectAll.checked = page.items.length > 0 && page.items.every((box) => state.selected.has(box.id));
|
||||
|
||||
renderSummary(filtered);
|
||||
renderDetails(active);
|
||||
}
|
||||
|
||||
function buildRow(box) {
|
||||
const row = document.createElement("tr");
|
||||
if (box.id === state.activeId) row.classList.add("is-selected");
|
||||
|
||||
row.innerHTML = `
|
||||
<td><input type="checkbox" class="boxes-row-check"${state.selected.has(box.id) ? " checked" : ""}></td>
|
||||
<td title="${escapeAttr(box.id)}">${box.id}</td>
|
||||
<td><span class="boxes-status-pill ${box.status}">${box.status_label}</span></td>
|
||||
<td>${box.complete_files}/${box.file_count}</td>
|
||||
<td>${box.total_size_label}</td>
|
||||
<td>${box.retention_label || "Not set"}</td>
|
||||
<td>${box.expires_at_label || "Not set"}</td>
|
||||
<td><div class="boxes-flags-cell">${renderFlags(box.flags)}</div></td>
|
||||
<td><div class="boxes-action-cell">${renderRowActions(box)}</div></td>
|
||||
`;
|
||||
|
||||
row.addEventListener("click", (event) => {
|
||||
if (event.target.closest("button") || event.target.closest("a") || event.target.closest("input")) return;
|
||||
state.activeId = box.id;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
row.querySelector(".boxes-row-check")?.addEventListener("change", (event) => {
|
||||
if (event.target.checked) {
|
||||
state.selected.add(box.id);
|
||||
} else {
|
||||
state.selected.delete(box.id);
|
||||
}
|
||||
selectedLabel.textContent = `Selected: ${state.selected.size}`;
|
||||
syncSelectAllForPage();
|
||||
});
|
||||
|
||||
row.querySelector('[data-row-action="focus"]')?.addEventListener("click", () => {
|
||||
state.activeId = box.id;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderFlags(flags) {
|
||||
if (!flags || !flags.length) return '<span class="boxes-flag">none</span>';
|
||||
return flags.map((flag) => `<span class="boxes-flag">${escapeHtml(flag)}</span>`).join("");
|
||||
}
|
||||
|
||||
function renderRowActions(box) {
|
||||
const parts = [
|
||||
`<a class="win98-button boxes-row-button" href="${escapeAttr(box.open_url)}" target="_blank" rel="noreferrer">Open</a>`,
|
||||
`<button class="win98-button boxes-row-button" type="button" data-row-action="focus">View</button>`
|
||||
];
|
||||
if (box.zip_available && box.zip_url) {
|
||||
parts.push(`<a class="win98-button boxes-row-button" href="${escapeAttr(box.zip_url)}" target="_blank" rel="noreferrer">ZIP</a>`);
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function renderDetails(box) {
|
||||
if (!box) {
|
||||
detailEls.boxId.textContent = "-";
|
||||
detailEls.status.textContent = "-";
|
||||
detailEls.created.textContent = "-";
|
||||
detailEls.expires.textContent = "-";
|
||||
detailEls.retention.textContent = "-";
|
||||
detailEls.files.textContent = "-";
|
||||
detailEls.size.textContent = "-";
|
||||
detailEls.flags.textContent = "-";
|
||||
detailEls.open.href = "#";
|
||||
detailEls.zip.href = "#";
|
||||
detailEls.zip.setAttribute("aria-disabled", "true");
|
||||
detailFileList.innerHTML = '<div class="boxes-file-card">No box selected.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
detailEls.boxId.textContent = box.id;
|
||||
detailEls.status.textContent = box.status_label;
|
||||
detailEls.created.textContent = box.created_at_label || "Not set";
|
||||
detailEls.expires.textContent = box.expires_at_label || "Not set";
|
||||
detailEls.retention.textContent = box.retention_label || "Not set";
|
||||
detailEls.files.textContent = `${box.complete_files}/${box.file_count} complete`;
|
||||
detailEls.size.textContent = box.total_size_label;
|
||||
detailEls.flags.textContent = (box.flags || []).join(", ") || "none";
|
||||
detailEls.open.href = box.open_url || "#";
|
||||
|
||||
if (box.zip_available && box.zip_url) {
|
||||
detailEls.zip.href = box.zip_url;
|
||||
detailEls.zip.removeAttribute("aria-disabled");
|
||||
detailEls.zip.style.pointerEvents = "";
|
||||
detailEls.zip.style.opacity = "";
|
||||
} else {
|
||||
detailEls.zip.href = "#";
|
||||
detailEls.zip.setAttribute("aria-disabled", "true");
|
||||
detailEls.zip.style.pointerEvents = "none";
|
||||
detailEls.zip.style.opacity = ".55";
|
||||
}
|
||||
|
||||
renderFiles(box.files || []);
|
||||
}
|
||||
|
||||
function renderFiles(files) {
|
||||
if (!files.length) {
|
||||
detailFileList.innerHTML = '<div class="boxes-file-card">No file inventory available for this box.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
detailFileList.innerHTML = files.map((file) => `
|
||||
<div class="boxes-file-card">
|
||||
<div class="boxes-file-row">
|
||||
<div class="boxes-file-name" title="${escapeAttr(file.name)}">${escapeHtml(file.name)}</div>
|
||||
<span class="boxes-status-pill ${escapeAttr(file.status || "legacy")}">${escapeHtml(file.status_label || file.status || "Unknown")}</span>
|
||||
</div>
|
||||
<div class="boxes-file-meta">
|
||||
<span>${escapeHtml(file.size_label || "0 B")}</span>
|
||||
<span>${escapeHtml(file.mime_type || "application/octet-stream")}</span>
|
||||
${file.is_complete && file.download_path ? `<a class="boxes-file-link" href="${escapeAttr(file.download_path)}" target="_blank" rel="noreferrer">download</a>` : "<span>pending</span>"}
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function syncSelectAllForPage() {
|
||||
const filtered = filteredBoxes();
|
||||
const page = pagedBoxes(filtered);
|
||||
selectAll.checked = page.items.length > 0 && page.items.every((box) => state.selected.has(box.id));
|
||||
selectedLabel.textContent = `Selected: ${state.selected.size}`;
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
searchInput.value = "";
|
||||
statusFilter.value = "all";
|
||||
flagFilter.value = "all";
|
||||
sortFilter.value = "newest";
|
||||
pageSizeFilter.value = "10";
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
}
|
||||
|
||||
async function runBulkAction(action, ids, deltaSeconds = 0) {
|
||||
if (!ids.length) {
|
||||
showToast("Select one or more boxes first", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/admin/boxes/actions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action, box_ids: ids, delta_seconds: deltaSeconds })
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
const message = payload.error || payload.message || "Action failed";
|
||||
const warning = Array.isArray(payload.warnings) && payload.warnings.length ? ` (${payload.warnings[0]})` : "";
|
||||
showToast(`${message}${warning}`, "error", 3200);
|
||||
return;
|
||||
}
|
||||
|
||||
state.boxes = Array.isArray(payload.boxes) ? payload.boxes : state.boxes;
|
||||
state.selected.clear();
|
||||
if (state.activeId && !state.boxes.some((box) => box.id === state.activeId)) {
|
||||
state.activeId = null;
|
||||
}
|
||||
renderTable();
|
||||
|
||||
let message = payload.message || "Action complete";
|
||||
if (Array.isArray(payload.warnings) && payload.warnings.length) {
|
||||
message += ` (${payload.warnings.length} warning${payload.warnings.length === 1 ? "" : "s"})`;
|
||||
}
|
||||
showToast(message, Array.isArray(payload.warnings) && payload.warnings.length ? "warning" : "success", 2800);
|
||||
} catch (_) {
|
||||
showToast("Network error while updating boxes", "error", 3200);
|
||||
}
|
||||
}
|
||||
|
||||
function selectedIDsOrActive() {
|
||||
if (state.selected.size) return Array.from(state.selected);
|
||||
const active = currentActiveBox();
|
||||
return active ? [active.id] : [];
|
||||
}
|
||||
|
||||
async function runCommand(command) {
|
||||
switch (command) {
|
||||
case "refresh":
|
||||
window.location.reload();
|
||||
return;
|
||||
case "export":
|
||||
exportVisibleCSV();
|
||||
showToast("Visible boxes exported");
|
||||
return;
|
||||
case "status-ready":
|
||||
statusFilter.value = "ready";
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
return;
|
||||
case "status-expired":
|
||||
statusFilter.value = "expired";
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
return;
|
||||
case "clear-filters":
|
||||
clearFilters();
|
||||
showToast("Filters cleared");
|
||||
return;
|
||||
case "expire":
|
||||
case "active-expire":
|
||||
await runBulkAction("expire", selectedIDsOrActive());
|
||||
return;
|
||||
case "extend-day":
|
||||
case "active-extend-day":
|
||||
await runBulkAction("bump", selectedIDsOrActive(), 24 * 60 * 60);
|
||||
return;
|
||||
case "extend-week":
|
||||
case "active-extend-week":
|
||||
await runBulkAction("bump", selectedIDsOrActive(), 7 * 24 * 60 * 60);
|
||||
return;
|
||||
case "delete":
|
||||
case "active-delete":
|
||||
if (!window.confirm("Delete selected boxes? This removes stored files.")) return;
|
||||
await runBulkAction("delete", selectedIDsOrActive());
|
||||
return;
|
||||
case "help-scope":
|
||||
showToast("Ownership filter waits for account + box owner data in backend", "info", 3400);
|
||||
return;
|
||||
case "help-flags":
|
||||
showToast("Flags: protected, one-time, zip off, legacy, consumed", "info", 3200);
|
||||
return;
|
||||
default:
|
||||
showToast(`Unknown command: ${command}`, "warning");
|
||||
}
|
||||
}
|
||||
|
||||
function exportVisibleCSV() {
|
||||
const rows = filteredBoxes().map((box) => ([
|
||||
box.id,
|
||||
box.status_label,
|
||||
box.file_count,
|
||||
box.total_size_label,
|
||||
box.retention_label,
|
||||
box.expires_at_label,
|
||||
(box.flags || []).join("|")
|
||||
]));
|
||||
const csv = [
|
||||
["box_id", "status", "files", "size", "retention", "expires", "flags"],
|
||||
...rows
|
||||
].map((row) => row.map(csvCell).join(",")).join("\n");
|
||||
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = "warpbox-boxes.csv";
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function csvCell(value) {
|
||||
const text = String(value ?? "");
|
||||
if (/[",\n]/.test(text)) return `"${text.replaceAll('"', '""')}"`;
|
||||
return text;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value).replaceAll("'", "'");
|
||||
}
|
||||
|
||||
[searchInput, statusFilter, flagFilter, sortFilter].forEach((control) => {
|
||||
control.addEventListener(control.tagName === "INPUT" ? "input" : "change", () => {
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
});
|
||||
|
||||
pageSizeFilter.addEventListener("change", () => {
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
selectAll?.addEventListener("change", () => {
|
||||
const filtered = filteredBoxes();
|
||||
const page = pagedBoxes(filtered);
|
||||
page.items.forEach((box) => {
|
||||
if (selectAll.checked) state.selected.add(box.id);
|
||||
else state.selected.delete(box.id);
|
||||
});
|
||||
renderTable();
|
||||
});
|
||||
|
||||
prevPageButton?.addEventListener("click", () => {
|
||||
state.page -= 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
nextPageButton?.addEventListener("click", () => {
|
||||
state.page += 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
menuController.close();
|
||||
await runCommand(button.dataset.command);
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", async (event) => {
|
||||
if (event.key === "Escape") menuController.close();
|
||||
if (event.key === "F5") {
|
||||
event.preventDefault();
|
||||
await runCommand("refresh");
|
||||
}
|
||||
});
|
||||
|
||||
if (state.boxes.length > 0) {
|
||||
state.activeId = state.boxes[0].id;
|
||||
}
|
||||
renderTable();
|
||||
})();
|
||||
434
static/js/admin/settings.js
Normal file
434
static/js/admin/settings.js
Normal file
@@ -0,0 +1,434 @@
|
||||
(() => {
|
||||
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
|
||||
const rowsNode = document.getElementById("settings-rows");
|
||||
const searchInput = document.getElementById("settingsSearch");
|
||||
const categoryButtons = Array.from(document.querySelectorAll(".settings-category-button"));
|
||||
const groups = Array.from(document.querySelectorAll(".settings-group"));
|
||||
const saveButton = document.getElementById("saveButton");
|
||||
const exportButton = document.getElementById("exportButton");
|
||||
const importButton = document.getElementById("importButton");
|
||||
const resetButton = document.getElementById("resetButton");
|
||||
const importInput = document.getElementById("settingsImportInput");
|
||||
const dirtyChip = document.getElementById("dirtyChip");
|
||||
const actionSummary = document.getElementById("actionSummary");
|
||||
const visibleCount = document.getElementById("visibleCount");
|
||||
const editableCount = document.getElementById("editableCount");
|
||||
const unsavedCount = document.getElementById("unsavedCount");
|
||||
const lockedCount = document.getElementById("lockedCount");
|
||||
const statusLeft = document.getElementById("statusLeft");
|
||||
const statusMiddle = document.getElementById("statusMiddle");
|
||||
const statusRight = document.getElementById("statusRight");
|
||||
const popupClose = document.getElementById("doc-popup-close");
|
||||
const toastTarget = document.getElementById("toast");
|
||||
|
||||
if (!rowsNode || !searchInput || !saveButton) return;
|
||||
|
||||
const state = {
|
||||
currentCategory: "all",
|
||||
showChangedOnly: false,
|
||||
showLockedOnly: false
|
||||
};
|
||||
|
||||
function parseRows() {
|
||||
try {
|
||||
return JSON.parse(rowsNode.textContent || "[]");
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const rowData = parseRows().reduce((map, row) => {
|
||||
map[row.key] = row;
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
const rows = Array.from(document.querySelectorAll(".setting-row")).map((row) => ({
|
||||
element: row,
|
||||
input: row.querySelector(".setting-input"),
|
||||
hint: row.querySelector('[data-role="hint"]'),
|
||||
badge: row.querySelector('[data-role="source-badge"]'),
|
||||
key: row.dataset.key,
|
||||
label: row.dataset.label,
|
||||
category: row.dataset.category,
|
||||
envName: row.dataset.envName,
|
||||
type: row.dataset.type,
|
||||
minimum: Number(row.dataset.minimum || 0),
|
||||
locked: row.classList.contains("is-locked")
|
||||
}));
|
||||
|
||||
function showToast(message, type = "info", duration = 2400) {
|
||||
window.WarpBoxUI?.toast?.(message, type, { target: toastTarget, duration });
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return window.WarpBoxUI?.htmlEscape?.(value) || String(value ?? "");
|
||||
}
|
||||
|
||||
function currentValue(row) {
|
||||
if (!row.input) return row.element.dataset.original || "";
|
||||
return String(row.input.value ?? "").trim();
|
||||
}
|
||||
|
||||
function isDirty(row) {
|
||||
return !row.locked && currentValue(row) !== (row.element.dataset.original || "");
|
||||
}
|
||||
|
||||
function validateRow(row) {
|
||||
if (row.locked || !row.input) {
|
||||
row.element.classList.remove("is-invalid");
|
||||
return true;
|
||||
}
|
||||
|
||||
const value = currentValue(row);
|
||||
let valid = true;
|
||||
|
||||
if (row.type === "int" || row.type === "int64") {
|
||||
if (!/^\d+$/.test(value)) valid = false;
|
||||
else if (Number(value) < row.minimum) valid = false;
|
||||
} else if (row.type === "bool") {
|
||||
valid = value === "true" || value === "false";
|
||||
}
|
||||
|
||||
row.element.classList.toggle("is-invalid", !valid);
|
||||
return valid;
|
||||
}
|
||||
|
||||
function rowMatchesSearch(row) {
|
||||
const query = searchInput.value.trim().toLowerCase();
|
||||
if (!query) return true;
|
||||
const data = [
|
||||
row.label,
|
||||
row.envName,
|
||||
row.element.dataset.description,
|
||||
row.key
|
||||
].join(" ").toLowerCase();
|
||||
return data.includes(query);
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
let visible = 0;
|
||||
|
||||
groups.forEach((group) => {
|
||||
let groupVisible = 0;
|
||||
group.querySelectorAll(".setting-row").forEach((node) => {
|
||||
const row = rows.find((item) => item.element === node);
|
||||
const categoryMatch = state.currentCategory === "all" || row.category === state.currentCategory;
|
||||
const searchMatch = rowMatchesSearch(row);
|
||||
const changedMatch = !state.showChangedOnly || isDirty(row);
|
||||
const lockedMatch = !state.showLockedOnly || row.locked;
|
||||
const show = categoryMatch && searchMatch && changedMatch && lockedMatch;
|
||||
node.classList.toggle("is-hidden", !show);
|
||||
if (show) {
|
||||
visible += 1;
|
||||
groupVisible += 1;
|
||||
}
|
||||
});
|
||||
group.hidden = groupVisible === 0;
|
||||
});
|
||||
|
||||
visibleCount.textContent = String(visible);
|
||||
statusMiddle.textContent = `category: ${state.currentCategory}`;
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
let dirty = 0;
|
||||
let editable = 0;
|
||||
let locked = 0;
|
||||
let invalid = 0;
|
||||
|
||||
rows.forEach((row) => {
|
||||
if (row.locked) locked += 1;
|
||||
else editable += 1;
|
||||
if (isDirty(row)) dirty += 1;
|
||||
if (!validateRow(row)) invalid += 1;
|
||||
});
|
||||
|
||||
editableCount.textContent = String(editable);
|
||||
lockedCount.textContent = String(locked);
|
||||
unsavedCount.textContent = String(dirty);
|
||||
dirtyChip.textContent = `${dirty} unsaved`;
|
||||
dirtyChip.classList.toggle("is-dirty", dirty > 0);
|
||||
saveButton.disabled = dirty === 0 || invalid > 0;
|
||||
|
||||
if (invalid > 0) {
|
||||
actionSummary.textContent = `${invalid} invalid setting value(s) must be fixed before save.`;
|
||||
statusLeft.textContent = "Invalid values";
|
||||
statusRight.textContent = "fix before save";
|
||||
} else if (dirty > 0) {
|
||||
actionSummary.textContent = `${dirty} unsaved change(s) ready to save or export.`;
|
||||
statusLeft.textContent = "Unsaved changes";
|
||||
statusRight.textContent = "draft ready";
|
||||
} else {
|
||||
actionSummary.textContent = "No unsaved changes.";
|
||||
statusLeft.textContent = "No unsaved changes";
|
||||
statusRight.textContent = "admin only";
|
||||
}
|
||||
}
|
||||
|
||||
function updateView() {
|
||||
updateStats();
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function setCategory(category) {
|
||||
state.currentCategory = category;
|
||||
categoryButtons.forEach((button) => button.classList.toggle("is-active", button.dataset.category === category));
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function draftValues() {
|
||||
const values = {};
|
||||
rows.forEach((row) => {
|
||||
if (!row.locked) values[row.key] = currentValue(row);
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
function updateRowFromPayload(payload) {
|
||||
const row = rows.find((item) => item.key === payload.key);
|
||||
if (!row) return;
|
||||
|
||||
row.element.dataset.original = payload.value;
|
||||
row.element.dataset.default = payload.default_value || "";
|
||||
row.element.dataset.source = payload.source || "default";
|
||||
row.element.dataset.sourceBadge = payload.source_badge || payload.source || "default";
|
||||
row.element.dataset.description = payload.description || "";
|
||||
row.element.dataset.minimum = String(payload.minimum || 0);
|
||||
row.element.classList.toggle("is-locked", Boolean(payload.locked));
|
||||
row.locked = Boolean(payload.locked);
|
||||
row.minimum = Number(payload.minimum || 0);
|
||||
|
||||
if (row.input) {
|
||||
row.input.value = payload.value ?? "";
|
||||
row.input.disabled = Boolean(payload.locked);
|
||||
}
|
||||
if (row.hint) {
|
||||
row.hint.textContent = payload.locked
|
||||
? "Locked by environment or hard runtime implication."
|
||||
: (payload.default_value ? `Default: ${payload.default_value}` : "");
|
||||
}
|
||||
if (row.badge) {
|
||||
row.badge.textContent = payload.source_badge || payload.source || "default";
|
||||
row.badge.className = `settings-badge ${badgeClass(payload.source_badge || payload.source || "default")}`;
|
||||
}
|
||||
rowData[payload.key] = payload;
|
||||
}
|
||||
|
||||
function badgeClass(source) {
|
||||
if (source === "default") return "badge-default";
|
||||
if (source === "environment") return "badge-env";
|
||||
if (source === "db override") return "badge-db";
|
||||
return "badge-hard";
|
||||
}
|
||||
|
||||
function hydrateRows(payloadRows) {
|
||||
if (!Array.isArray(payloadRows)) return;
|
||||
payloadRows.forEach(updateRowFromPayload);
|
||||
updateView();
|
||||
}
|
||||
|
||||
async function postJSON(url, body) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "Request failed");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
try {
|
||||
const payload = await postJSON("/admin/settings/save", { values: draftValues() });
|
||||
hydrateRows(payload.rows);
|
||||
showToast(payload.message || "Settings saved", payload.warnings?.length ? "warning" : "success");
|
||||
} catch (error) {
|
||||
showToast(error.message, "error", 3200);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetDefaults() {
|
||||
if (!window.confirm("Reset all editable settings to built-in defaults?")) return;
|
||||
try {
|
||||
const payload = await postJSON("/admin/settings/reset", {});
|
||||
hydrateRows(payload.rows);
|
||||
showToast(payload.message || "Defaults restored", "success");
|
||||
} catch (error) {
|
||||
showToast(error.message, "error", 3200);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportSettings() {
|
||||
try {
|
||||
const response = await fetch("/admin/settings/export");
|
||||
if (!response.ok) throw new Error("Could not export settings");
|
||||
const payload = await response.json();
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `warpbox-settings-${new Date().toISOString().replaceAll(":", "-")}.json`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast("Settings JSON exported");
|
||||
} catch (error) {
|
||||
showToast(error.message, "error", 3200);
|
||||
}
|
||||
}
|
||||
|
||||
async function importSettingsFile(file) {
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const payload = JSON.parse(text);
|
||||
const result = await postJSON("/admin/settings/import", payload);
|
||||
hydrateRows(result.rows);
|
||||
showToast(result.message || "Settings imported", result.warnings?.length ? "warning" : "success", 3200);
|
||||
} catch (error) {
|
||||
showToast(error.message || "Could not import settings JSON", "error", 3200);
|
||||
} finally {
|
||||
importInput.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function discardUnsaved() {
|
||||
rows.forEach((row) => {
|
||||
if (!row.input) return;
|
||||
row.input.value = row.element.dataset.original || "";
|
||||
});
|
||||
updateView();
|
||||
showToast("Unsaved changes discarded");
|
||||
}
|
||||
|
||||
function explainSources() {
|
||||
window.WarpBoxUI?.openPopup?.(
|
||||
"Setting Sources",
|
||||
`
|
||||
<ul>
|
||||
<li><strong>default</strong>: built-in application value.</li>
|
||||
<li><strong>environment</strong>: loaded from an environment variable.</li>
|
||||
<li><strong>db override</strong>: saved from the admin settings page.</li>
|
||||
<li><strong>hard env</strong>: visible here, but locked for safety.</li>
|
||||
</ul>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
function explainReset() {
|
||||
window.WarpBoxUI?.openPopup?.(
|
||||
"Reset Behavior",
|
||||
`
|
||||
<p>Reset defaults writes built-in WarpBox defaults as admin overrides for editable settings.</p>
|
||||
<p>Environment-only settings stay locked and unchanged.</p>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
function showRowInfo(row) {
|
||||
window.WarpBoxUI?.openPopup?.(
|
||||
row.label,
|
||||
`
|
||||
<p><strong>Environment variable:</strong> ${escapeHtml(row.envName || "n/a")}</p>
|
||||
<p><strong>Current source:</strong> ${escapeHtml(row.badge?.textContent || row.element.dataset.sourceBadge || "default")}</p>
|
||||
<p><strong>Description:</strong> ${escapeHtml(row.element.dataset.description || "No description available.")}</p>
|
||||
${row.element.dataset.default ? `<p><strong>Default value:</strong> ${escapeHtml(row.element.dataset.default)}</p>` : ""}
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
async function runCommand(command) {
|
||||
switch (command) {
|
||||
case "save":
|
||||
await saveChanges();
|
||||
return;
|
||||
case "export":
|
||||
await exportSettings();
|
||||
return;
|
||||
case "import":
|
||||
importInput.click();
|
||||
return;
|
||||
case "discard":
|
||||
discardUnsaved();
|
||||
return;
|
||||
case "show-all":
|
||||
state.showChangedOnly = false;
|
||||
state.showLockedOnly = false;
|
||||
applyFilters();
|
||||
showToast("Showing all matching settings");
|
||||
return;
|
||||
case "show-changed":
|
||||
state.showChangedOnly = !state.showChangedOnly;
|
||||
if (state.showChangedOnly) state.showLockedOnly = false;
|
||||
applyFilters();
|
||||
showToast(state.showChangedOnly ? "Showing changed settings only" : "Showing all matching settings");
|
||||
return;
|
||||
case "show-locked":
|
||||
state.showLockedOnly = !state.showLockedOnly;
|
||||
if (state.showLockedOnly) state.showChangedOnly = false;
|
||||
applyFilters();
|
||||
showToast(state.showLockedOnly ? "Showing locked settings only" : "Showing all matching settings");
|
||||
return;
|
||||
case "reset-defaults":
|
||||
await resetDefaults();
|
||||
return;
|
||||
case "reload":
|
||||
window.location.reload();
|
||||
return;
|
||||
case "legend":
|
||||
explainSources();
|
||||
return;
|
||||
case "reset-help":
|
||||
explainReset();
|
||||
return;
|
||||
default:
|
||||
showToast(`Unknown command: ${command}`, "warning");
|
||||
}
|
||||
}
|
||||
|
||||
rows.forEach((row) => {
|
||||
row.input?.addEventListener(row.input.tagName === "SELECT" ? "change" : "input", updateView);
|
||||
row.element.querySelector(".row-reset")?.addEventListener("click", () => {
|
||||
if (row.locked || !row.input) return;
|
||||
row.input.value = row.element.dataset.default || row.element.dataset.original || "";
|
||||
updateView();
|
||||
});
|
||||
row.element.querySelector(".row-info")?.addEventListener("click", () => showRowInfo(row));
|
||||
});
|
||||
|
||||
searchInput.addEventListener("input", applyFilters);
|
||||
categoryButtons.forEach((button) => button.addEventListener("click", () => setCategory(button.dataset.category)));
|
||||
saveButton.addEventListener("click", saveChanges);
|
||||
exportButton.addEventListener("click", exportSettings);
|
||||
importButton.addEventListener("click", () => importInput.click());
|
||||
resetButton.addEventListener("click", resetDefaults);
|
||||
importInput.addEventListener("change", (event) => importSettingsFile(event.target.files?.[0]));
|
||||
popupClose?.addEventListener("click", () => window.WarpBoxUI?.closePopup?.());
|
||||
document.getElementById("modal-backdrop")?.addEventListener("click", () => window.WarpBoxUI?.closePopup?.());
|
||||
|
||||
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
menuController.close();
|
||||
await runCommand(button.dataset.command);
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", async (event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
|
||||
event.preventDefault();
|
||||
await saveChanges();
|
||||
}
|
||||
if (event.key === "F5") {
|
||||
event.preventDefault();
|
||||
window.location.reload();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
menuController.close();
|
||||
window.WarpBoxUI?.closePopup?.();
|
||||
}
|
||||
});
|
||||
|
||||
updateView();
|
||||
})();
|
||||
Reference in New Issue
Block a user