Web UI: Server and account menus keep the top bar on one line
Server options, ACLs, TSIG keys, Exposure, Certificates, Encrypted DNS and the audit log are in a Server menu, Account, Users and Log out under the user's name; the right-hand group stays together when it wraps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1cec6cc393
commit
5fcf4082c9
3 files changed
+92
-15
No files matched your search
@@ -45,21 +45,25 @@ const routes = [
|
||||
{ pattern: /^encrypted-dns$/, page: encdns, nav: "encdns" },
|
||||
];
|
||||
|
||||
// The top bar: the pages used most, then a menu for the server's
|
||||
// configuration and security. ["key", "#/href", "Label", "admin"?]
|
||||
const navItems = [
|
||||
["dashboard", "#/", "Dashboard"],
|
||||
["stats", "#/stats", "Statistics"],
|
||||
["zones", "#/zones", "Zones"],
|
||||
["blocking", "#/blocking", "Blocking"],
|
||||
["views", "#/views", "Views"],
|
||||
["exposure", "#/exposure", "Exposure"],
|
||||
["certificates", "#/certificates", "Certificates", "admin"],
|
||||
["encdns", "#/encrypted-dns", "Encrypted DNS", "admin"],
|
||||
["acls", "#/acls", "ACLs"],
|
||||
["keys", "#/keys", "TSIG keys"],
|
||||
["options", "#/options", "Server options"],
|
||||
["blocking", "#/blocking", "Blocking"],
|
||||
["changes", "#/changes", "Changes"],
|
||||
["history", "#/history", "History"],
|
||||
["audit", "#/audit", "Audit log"],
|
||||
{ menu: "Server", items: [
|
||||
["options", "#/options", "Server options"],
|
||||
["acls", "#/acls", "ACLs"],
|
||||
["keys", "#/keys", "TSIG keys"],
|
||||
["exposure", "#/exposure", "Exposure"],
|
||||
["certificates", "#/certificates", "Certificates", "admin"],
|
||||
["encdns", "#/encrypted-dns", "Encrypted DNS", "admin"],
|
||||
["audit", "#/audit", "Audit log"],
|
||||
] },
|
||||
];
|
||||
|
||||
const app = document.getElementById("app");
|
||||
@@ -168,12 +172,16 @@ function layout() {
|
||||
logo(30),
|
||||
wordmark({ rule: false, height: 19, cls: "brand-name" })),
|
||||
navEl,
|
||||
h("div", { class: "spacer" }),
|
||||
modeEl = h("span"),
|
||||
h("form", { class: "search", onsubmit: (e) => { e.preventDefault(); if (q.value.trim()) location.hash = "#/search?q=" + encodeURIComponent(q.value.trim()); } }, q),
|
||||
h("a", { href: "#/account", class: "user", title: (me.permissions || []).join("\n") }, me.user.username),
|
||||
me.admin ? h("a", { href: "#/users" }, "Users") : null,
|
||||
h("button", { class: "small", onclick: logout }, "Log out"));
|
||||
// The right-hand group stays together, on the right, also when it
|
||||
// has to wrap below the navigation.
|
||||
h("div", { class: "header-right" },
|
||||
modeEl = h("span"),
|
||||
h("form", { class: "search", onsubmit: (e) => { e.preventDefault(); if (q.value.trim()) location.hash = "#/search?q=" + encodeURIComponent(q.value.trim()); } }, q),
|
||||
menu(h("span", { class: "user", title: (me.permissions || []).join("\n") }, me.user.username), [
|
||||
h("a", { href: "#/account" }, "Account"),
|
||||
me.admin ? h("a", { href: "#/users" }, "Users") : null,
|
||||
h("button", { type: "button", class: "menu-item", onclick: logout }, "Log out"),
|
||||
], { right: true, active: ["account", "users"].includes(current.nav), label: "Account menu" })));
|
||||
mount(app, h("div", { class: "shell" }, header, barEl, mainEl, footer(me.build)));
|
||||
modeHolder = modeEl;
|
||||
renderNav();
|
||||
@@ -196,9 +204,33 @@ function footer(b) {
|
||||
}
|
||||
|
||||
function renderNav() {
|
||||
mount(navEl, navItems.filter((n) => n[3] !== "admin" || state.me.admin).map(([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label)));
|
||||
const allowed = (n) => n[3] !== "admin" || state.me.admin;
|
||||
const link = ([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label);
|
||||
mount(navEl, navItems.map((n) => {
|
||||
if (!n.menu) return allowed(n) ? link(n) : null;
|
||||
const items = n.items.filter(allowed);
|
||||
return menu(n.menu, items.map(link), { active: items.some((i) => i[0] === current.nav), label: n.menu + " menu" });
|
||||
}));
|
||||
}
|
||||
|
||||
// menu is a drop-down: a button and its links. It closes when one is
|
||||
// picked, on a click elsewhere and on Escape.
|
||||
function menu(label, items, opts = {}) {
|
||||
const d = h("details", { class: "menu" + (opts.right ? " right" : "") },
|
||||
h("summary", { class: opts.active ? "active" : "", "aria-label": opts.label || null }, label, h("span", { class: "caret", "aria-hidden": "true" }, "▾")),
|
||||
h("div", { class: "menu-list", role: "menu" }, items.filter(Boolean)));
|
||||
d.addEventListener("click", (e) => { if (e.target.closest(".menu-list a, .menu-list button")) d.open = false; });
|
||||
return d;
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
for (const d of document.querySelectorAll("details.menu[open]")) if (!d.contains(e.target)) d.open = false;
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Escape") return;
|
||||
for (const d of document.querySelectorAll("details.menu[open]")) { d.open = false; d.querySelector("summary").focus(); }
|
||||
});
|
||||
|
||||
// renderBar shows the pending-changes bar (only while there are pending
|
||||
// changes) and the small review/live switch in the header.
|
||||
function renderBar() {
|
||||
|
||||
@@ -529,3 +529,31 @@ table.pick-list tr.pick.selected { background: var(--accent-soft); }
|
||||
.filter-row .chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.chip.toggle-chip { opacity: 0.55; font-size: 13px; padding: 3px 12px; }
|
||||
.chip.toggle-chip.on { opacity: 1; text-decoration: none; background: var(--accent-soft); border-color: var(--accent); }
|
||||
|
||||
/* Drop-down menus in the top bar (Server, the user). */
|
||||
details.menu { position: relative; }
|
||||
details.menu > summary { list-style: none; cursor: pointer; padding: 6px 10px; border-radius: var(--radius); color: var(--text); white-space: nowrap; }
|
||||
details.menu > summary::-webkit-details-marker { display: none; }
|
||||
details.menu > summary:hover, details.menu[open] > summary { background: var(--info-soft); }
|
||||
details.menu > summary.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||
details.menu > summary .caret { margin-left: 4px; font-size: 0.8em; opacity: 0.7; }
|
||||
details.menu > summary .user { color: inherit; }
|
||||
details.menu .menu-list {
|
||||
position: absolute; top: calc(100% + 4px); left: 0; z-index: 20; min-width: 190px;
|
||||
display: flex; flex-direction: column; padding: 4px;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
details.menu.right .menu-list { left: auto; right: 0; }
|
||||
details.menu .menu-list a, details.menu .menu-list .menu-item {
|
||||
display: block; padding: 7px 10px; border-radius: var(--radius); color: var(--text); text-align: left;
|
||||
background: none; border: 0; font: inherit; cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
details.menu .menu-list a:hover, details.menu .menu-list .menu-item:hover { background: var(--info-soft); text-decoration: none; }
|
||||
details.menu .menu-list a.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||
nav.main { align-items: center; }
|
||||
header.top .header-right { display: flex; align-items: center; gap: 12px; margin-left: auto; }
|
||||
@media (max-width: 1200px) {
|
||||
header.top form.search input { width: 160px; }
|
||||
header.top { gap: 10px; }
|
||||
}
|
||||
Reference in new issue
Block a user