Replace breadcrumbs with back links; keep the zone header on tab switches

The zone page's breadcrumb (Zones › view) linked the view to a filtered
zone list while the view link below it opened the view. It is now a
single "← Zones" link back to the list with its filters, and a changeset
has "← History". View links carry where they came from, so the views page
offers "← example.org (external)" or "← Zones" (only the UI's own #/
addresses are accepted).

Switching a zone's tabs no longer blanks the page: the header and tabs
show at once and the previous tab stays, faded, until the next has
loaded. The router shows "Loading…" only for pages that take a while.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-09 15:07:55 +00:00
1 parent db9ebb14dd
commit 4b79abf296
8 files changed
+86 -40

No files matched your search

+24 -11
View File
@@ -1,10 +1,12 @@
import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView, crumbs, viewLink, lastList } from "../ui.js";
import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView, backLink, viewLink, lastList } from "../ui.js";
import { api, get, change, describeResult, rrtypes, state, draftQuery } from "../api.js";
import { rerender } from "../app.js";
import { optionsEditor } from "./options.js";
import { emailOf, pendingBadge } from "./zones.js";
import { isReverseZone, reverseInfo, ptrOwner, addressOf } from "../names.js";
let renderSeq = 0;
export async function render(el, params, query) {
const id = params.id;
const [zone, views] = await Promise.all([get("/zones/" + id, draftQuery()), get("/views")]);
@@ -18,27 +20,39 @@ export async function render(el, params, query) {
["primary", "secondary", "mirror"].includes(zone.type) ? ["servers", "Name servers"] : null,
].filter(Boolean);
const body = h("div");
mount(el,
h("div", { class: "page-head" },
const head = h("div", { class: "page-head" },
h("div", { class: "grow" },
crumbs(["Zones", lastList.zones || "#/zones"],
...(zone.view ? [[h("span", { class: view && view.color ? "vc-" + view.color : null }, zone.view), "#/zones?view=" + encodeURIComponent(zone.view)]] : [])),
backLink("Zones", lastList.zones || "#/zones"),
h("h1", { class: "mono" }, zone.display_name),
h("div", { class: "muted" },
badge(zone.type, "accent"), " ",
zone.pending ? [pendingBadge(zone.pending), " "] : null,
zone.view ? ["view ", viewLink(view, zone.view), " · "] : null,
zone.view ? ["view ", viewLink(view, zone.view, "#/zones/" + id + (params.tab ? "/" + params.tab : "")), " · "] : null,
zone.type === "primary" ? "serial " + zone.soa.serial + " · " : null,
zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)),
h("div", { class: "actions" },
h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone"))),
h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label))),
body);
h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone")));
const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label)));
// The header and tabs show at once. Below them, switching tabs keeps the
// previous tab's content (faded) until the new one is loaded, rather
// than blanking the page; opening the zone shows "Loading…" there.
const seq = ++renderSeq;
const prev = el.querySelector(`:scope > .zone-body[data-zone="${id}"]`);
const holder = prev || h("div", { class: "zone-body" }, h("div", { class: "muted" }, "Loading…"));
holder.classList.add("stale");
body.className = "zone-body";
body.dataset.zone = id;
mount(el, head, tabBar, holder);
if (tab === "records") await recordsTab(body, zone, query);
else if (tab === "settings") await settingsTab(body, zone);
else if (tab === "options") await optionsEditor(body, { scope: "zone", scope_id: zone.id, catalogScope: "zone:" + zone.type, title: "Zone options" });
else if (tab === "file") await fileTab(body, zone);
else if (tab === "servers") await serversTab(body, zone);
// A later click (another tab) has taken over the page.
if (seq !== renderSeq || !holder.isConnected) return;
holder.replaceWith(body);
const first = body.querySelector("[data-autofocus]");
if (first) first.focus();
}
// ---- name servers: serials on the other servers, and the delegation
@@ -162,7 +176,7 @@ async function recordsTab(el, zone, query) {
const reverse = isReverseZone(zone.name);
const rinfo = reverse ? reverseInfo(zone.name) : null;
// Add form. In reverse zones records are named by address.
const fName = input({ placeholder: reverse && rinfo ? rinfo.example : "@ or www", style: { width: reverse ? "220px" : "180px" }, class: "mono", autocomplete: "off", spellcheck: "false" });
const fName = input({ placeholder: reverse && rinfo ? rinfo.example : "@ or www", style: { width: reverse ? "220px" : "180px" }, class: "mono", autocomplete: "off", spellcheck: "false", "data-autofocus": true });
const fType = select(types.map((t) => [t.type, t.type + (t.common ? "" : "")]), reverse ? "PTR" : "A", { style: { width: "110px" } });
const fTTL = input({ type: "number", placeholder: "default", min: 0, style: { width: "100px" } });
const fData = input({ class: "mono", placeholder: typeInfo.A.example });
@@ -384,7 +398,6 @@ async function recordsTab(el, zone, query) {
(reverse ? "Names are the last part of the address within " + zone.display_name + "." : "Names are relative to " + zone.display_name + "; end a name with '.' to make it absolute."))),
list));
draw();
fName.focus();
}
// ---- settings