435 lines
16 KiB
JavaScript
435 lines
16 KiB
JavaScript
|
|
(() => {
|
||
|
|
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();
|
||
|
|
})();
|