- Rework the admin UI (topbar, sidebar, cards, tables, badges, stat tiles) into a dark theme with a light/dark toggle, matching the visual style requested for the project. - Fix Dockerfile: chown /data to the distroless nonroot uid/gid before the VOLUME declaration so a freshly created named volume is seeded with writable permissions instead of root:root (was causing "unable to open database file" on first run). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QTXFrNP1imCW7jtfL5SZrw
123 lines
4.7 KiB
JavaScript
123 lines
4.7 KiB
JavaScript
// vibeproxy admin UI — minimal vanilla JS, no framework.
|
|
(function () {
|
|
"use strict";
|
|
|
|
// Light/dark theme toggle, persisted in localStorage.
|
|
var themeBtn = document.querySelector("[data-theme-toggle]");
|
|
if (themeBtn) {
|
|
var icon = themeBtn.querySelector("i");
|
|
function syncIcon() {
|
|
var isLight = document.documentElement.getAttribute("data-bs-theme") === "light";
|
|
icon.className = isLight ? "bi bi-sun" : "bi bi-moon-stars";
|
|
}
|
|
syncIcon();
|
|
themeBtn.addEventListener("click", function () {
|
|
var current = document.documentElement.getAttribute("data-bs-theme") === "light" ? "light" : "dark";
|
|
var next = current === "dark" ? "light" : "dark";
|
|
document.documentElement.setAttribute("data-bs-theme", next);
|
|
localStorage.setItem("vp-theme", next);
|
|
syncIcon();
|
|
});
|
|
}
|
|
|
|
// Generic delete-confirmation modal. Any element with
|
|
// data-confirm-delete-form="<form id>" and data-confirm-message="..."
|
|
// opens #confirmDeleteModal and, on confirm, submits the named form.
|
|
document.addEventListener("click", function (e) {
|
|
var trigger = e.target.closest("[data-confirm-delete-form]");
|
|
if (!trigger) return;
|
|
e.preventDefault();
|
|
var formId = trigger.getAttribute("data-confirm-delete-form");
|
|
var message = trigger.getAttribute("data-confirm-message") || "Are you sure? This cannot be undone.";
|
|
var modalEl = document.getElementById("confirmDeleteModal");
|
|
if (!modalEl) return;
|
|
modalEl.querySelector(".modal-body").textContent = message;
|
|
var confirmBtn = modalEl.querySelector("[data-confirm-action]");
|
|
confirmBtn.onclick = function () {
|
|
var form = document.getElementById(formId);
|
|
if (form) form.submit();
|
|
};
|
|
var modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
|
modal.show();
|
|
});
|
|
|
|
// Dynamic add/remove rows for repeatable form sections (backends,
|
|
// custom headers), driven by a <template id="{tableId}RowTemplate">.
|
|
document.querySelectorAll("[data-add-row]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
var tableId = btn.getAttribute("data-add-row");
|
|
var table = document.getElementById(tableId);
|
|
var tmpl = document.getElementById(tableId + "RowTemplate");
|
|
if (!table || !tmpl) return;
|
|
var tbody = table.querySelector("tbody");
|
|
tbody.appendChild(tmpl.content.firstElementChild.cloneNode(true));
|
|
});
|
|
});
|
|
document.addEventListener("click", function (e) {
|
|
var btn = e.target.closest("[data-remove-row]");
|
|
if (!btn) return;
|
|
var tr = btn.closest("tr");
|
|
if (tr) tr.remove();
|
|
});
|
|
|
|
// Live client-side filter for any table with data-searchable="true",
|
|
// driven by an <input data-search-target="tableId">.
|
|
document.querySelectorAll("[data-search-target]").forEach(function (input) {
|
|
input.addEventListener("input", function () {
|
|
var table = document.getElementById(input.getAttribute("data-search-target"));
|
|
if (!table) return;
|
|
var q = input.value.trim().toLowerCase();
|
|
table.querySelectorAll("tbody tr").forEach(function (row) {
|
|
row.style.display = row.textContent.toLowerCase().indexOf(q) === -1 ? "none" : "";
|
|
});
|
|
});
|
|
});
|
|
|
|
// Domain / CIDR chip inputs: comma or newline separated list rendered
|
|
// into a hidden textarea, e.g. for proxy host domains.
|
|
document.querySelectorAll("[data-chip-input]").forEach(function (wrap) {
|
|
var hidden = wrap.querySelector("textarea");
|
|
var display = wrap.querySelector(".vp-chip-list");
|
|
var entry = wrap.querySelector("input[type=text]");
|
|
if (!hidden || !display || !entry) return;
|
|
|
|
function items() {
|
|
return hidden.value.split("\n").map(function (s) { return s.trim(); }).filter(Boolean);
|
|
}
|
|
function renderChips() {
|
|
display.innerHTML = "";
|
|
items().forEach(function (item, idx) {
|
|
var chip = document.createElement("span");
|
|
chip.className = "badge text-bg-secondary vp-chip";
|
|
chip.textContent = item;
|
|
var x = document.createElement("button");
|
|
x.type = "button";
|
|
x.className = "btn-close btn-close-white btn-sm ms-2";
|
|
x.setAttribute("aria-label", "Remove");
|
|
x.addEventListener("click", function () {
|
|
var list = items();
|
|
list.splice(idx, 1);
|
|
hidden.value = list.join("\n");
|
|
renderChips();
|
|
});
|
|
chip.appendChild(x);
|
|
display.appendChild(chip);
|
|
});
|
|
}
|
|
entry.addEventListener("keydown", function (e) {
|
|
if (e.key === "Enter" || e.key === ",") {
|
|
e.preventDefault();
|
|
var v = entry.value.trim().replace(/,$/, "");
|
|
if (v) {
|
|
var list = items();
|
|
list.push(v);
|
|
hidden.value = list.join("\n");
|
|
entry.value = "";
|
|
renderChips();
|
|
}
|
|
}
|
|
});
|
|
renderChips();
|
|
});
|
|
})();
|