From 5fcf4082c9993f0afaa4411fc62db68789429a37 Mon Sep 17 00:00:00 2001 From: adam Date: Mon, 12 Oct 2026 20:06:18 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 17 ++++++++ internal/web/static/assets/app.js | 62 +++++++++++++++++++++------- internal/web/static/assets/style.css | 28 +++++++++++++ 3 files changed, 92 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index beaf44c..ee204b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,25 @@ All notable changes to Jarl are documented here. The format follows parser doesn't know them. Remove the ranges first if you need to downgrade. +### Changed + +- The web UI's top bar had grown too long, and the user name, Users and + Log out ended up on a second line on the left. The server's + configuration and security pages (Server options, ACLs, TSIG keys, + Exposure, Certificates, Encrypted DNS, Audit log) are now in a + **Server** menu, and Account, Users and Log out in a menu under your + name. On narrow screens the search box and your name stay together on + the right. + ### Fixed +- "update PTR" when editing a record (`jarl record update ID --ptr`) did + nothing unless the name or address changed too, and the web UI didn't + even send it alone: a reverse zone created after the record never got + its PTR. Alone, it now adds the PTR record if it is missing (or enables + it). For records of linked zones, the reverse zones of every linked view + count, not only the record's own view. + - Option values in the web UI looked different from the editor: a long line wrapped back to the left edge, losing its indentation, and the column changed width when Edit opened. Values now keep their layout diff --git a/internal/web/static/assets/app.js b/internal/web/static/assets/app.js index 9ae99b4..dcd8923 100644 --- a/internal/web/static/assets/app.js +++ b/internal/web/static/assets/app.js @@ -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() { diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index 41f92b3..dd08df8 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -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; } +}