The view shown in the zone list and on the zone page now links to that
view. The zone page and changeset page get breadcrumbs, and the zone list
keeps its filters in the address so going back restores them. Views can
be given a colour (Views > Edit, `jarl view color`, PUT /views/{id}/color)
so zones of the same view are easy to spot; the colour is UI-only, kept
outside changesets, and needs no deploy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
290 lines
11 KiB
JavaScript
290 lines
11 KiB
JavaScript
// Small DOM helpers and widgets used by every page.
|
||
|
||
// h creates an element. attrs may contain class, style, on<event>
|
||
// handlers, dataset, value/checked/disabled and any other attribute.
|
||
export function h(tag, attrs, ...children) {
|
||
const el = document.createElement(tag);
|
||
if (attrs) {
|
||
for (const [k, v] of Object.entries(attrs)) {
|
||
if (v === undefined || v === null || v === false) continue;
|
||
if (k === "class") el.className = v;
|
||
else if (k === "style" && typeof v === "object") Object.assign(el.style, v);
|
||
else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v);
|
||
else if (k === "dataset") Object.assign(el.dataset, v);
|
||
else if (k === "value") el.value = v;
|
||
else if (k === "checked" || k === "disabled" || k === "selected" || k === "multiple" || k === "required" || k === "readOnly") el[k] = !!v;
|
||
else el.setAttribute(k, v === true ? "" : v);
|
||
}
|
||
}
|
||
append(el, children);
|
||
return el;
|
||
}
|
||
|
||
function append(el, children) {
|
||
for (const c of children.flat(Infinity)) {
|
||
if (c === null || c === undefined || c === false) continue;
|
||
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
|
||
}
|
||
}
|
||
|
||
export function clear(el) {
|
||
while (el.firstChild) el.removeChild(el.firstChild);
|
||
return el;
|
||
}
|
||
|
||
export function mount(el, ...children) {
|
||
clear(el);
|
||
append(el, children);
|
||
return el;
|
||
}
|
||
|
||
// ---- formatting
|
||
|
||
export function fmtTime(iso) {
|
||
if (!iso) return "";
|
||
const d = new Date(iso);
|
||
if (isNaN(d)) return iso;
|
||
return d.toLocaleString(undefined, { year: "numeric", month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||
}
|
||
|
||
export function relTime(iso) {
|
||
if (!iso) return "";
|
||
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
|
||
const s = Math.abs(diff);
|
||
if (s < 60) return "just now";
|
||
let n;
|
||
if (s < 3600) n = Math.floor(s / 60) + " min";
|
||
else if (s < 86400) n = Math.floor(s / 3600) + " h";
|
||
else if (s < 86400 * 30) n = plural(Math.floor(s / 86400), "day");
|
||
else return fmtTime(iso);
|
||
return diff > 0 ? n + " ago" : "in " + n; // a future time (an expiry)
|
||
}
|
||
|
||
export function time(iso) {
|
||
return h("span", { title: fmtTime(iso), class: "nowrap" }, relTime(iso));
|
||
}
|
||
|
||
export function display(name) {
|
||
if (!name || name === ".") return ".";
|
||
return name.endsWith(".") ? name.slice(0, -1) : name;
|
||
}
|
||
|
||
export function plural(n, word, words) {
|
||
return n + " " + (n === 1 ? word : (words || word + "s"));
|
||
}
|
||
|
||
export function badge(text, kind) {
|
||
return h("span", { class: "badge" + (kind ? " " + kind : "") }, text);
|
||
}
|
||
|
||
// ---- toasts
|
||
|
||
export function toast(message, kind = "ok", ms = 3500) {
|
||
const box = document.getElementById("toasts");
|
||
const t = h("div", { class: "toast " + kind, onclick: () => t.remove() }, message);
|
||
// A repeated message replaces the earlier one instead of stacking.
|
||
for (const old of [...box.children]) if (old.textContent === t.textContent) old.remove();
|
||
box.append(t);
|
||
while (box.children.length > 3) box.firstChild.remove();
|
||
setTimeout(() => t.remove(), ms);
|
||
}
|
||
|
||
// ---- modal dialogs
|
||
|
||
// modal shows a dialog. buttons: [{label, kind, value, onclick}].
|
||
// onclick may return false to keep the dialog open, or a promise.
|
||
export function modal(title, body, buttons = [{ label: "Close", value: null }], opts = {}) {
|
||
return new Promise((resolve) => {
|
||
const back = h("div", { class: "modal-back" });
|
||
const close = (v) => { back.remove(); document.removeEventListener("keydown", onKey); resolve(v); };
|
||
const onKey = (e) => { if (e.key === "Escape") close(null); };
|
||
document.addEventListener("keydown", onKey);
|
||
const btns = buttons.map((b) =>
|
||
h("button", {
|
||
class: b.kind || "", type: "button",
|
||
onclick: async (e) => {
|
||
if (b.onclick) {
|
||
e.target.disabled = true;
|
||
try {
|
||
const r = await b.onclick();
|
||
if (r === false) return;
|
||
} finally { e.target.disabled = false; }
|
||
}
|
||
close(b.value === undefined ? b.label : b.value);
|
||
},
|
||
}, b.label));
|
||
const dialog = h("div", { class: "modal" + (opts.wide ? " wide" : ""), role: "dialog" }, h("h2", null, title), body, h("div", { class: "buttons" }, btns));
|
||
back.append(dialog);
|
||
back.addEventListener("mousedown", (e) => { if (e.target === back && !opts.sticky) close(null); });
|
||
document.body.append(back);
|
||
const first = dialog.querySelector("input, select, textarea");
|
||
if (first) first.focus();
|
||
});
|
||
}
|
||
|
||
export async function confirmDialog(title, message, okLabel = "OK", kind = "primary") {
|
||
const r = await modal(title, h("p", null, message), [
|
||
{ label: "Cancel", value: false },
|
||
{ label: okLabel, kind, value: true },
|
||
]);
|
||
return r === true;
|
||
}
|
||
|
||
// ---- forms
|
||
|
||
// field returns a label + control pair for .form-grid layouts.
|
||
export function field(label, control, help) {
|
||
// The label points at the control itself, also when it is wrapped.
|
||
const target = control.matches && !control.matches("input, select, textarea") ? control.querySelector("input, select, textarea") || control : control;
|
||
const id = target.id || ("f" + Math.random().toString(36).slice(2, 9));
|
||
target.id = id;
|
||
return [h("label", { for: id }, label), control, help ? h("div", { class: "help" }, help) : null];
|
||
}
|
||
|
||
export function input(attrs = {}) {
|
||
return h("input", { type: "text", autocomplete: "off", spellcheck: "false", ...attrs });
|
||
}
|
||
|
||
export function select(options, value, attrs = {}) {
|
||
const s = h("select", attrs);
|
||
for (const o of options) {
|
||
const [v, label] = Array.isArray(o) ? o : [o, o];
|
||
s.append(h("option", { value: v, selected: String(v) === String(value) }, label));
|
||
}
|
||
return s;
|
||
}
|
||
|
||
export function textarea(value, attrs = {}) {
|
||
return h("textarea", { spellcheck: "false", ...attrs, value: value || "" });
|
||
}
|
||
|
||
export function checkbox(label, checked, attrs = {}) {
|
||
const c = h("input", { type: "checkbox", checked, ...attrs });
|
||
const l = h("label", { class: "check" }, c, label);
|
||
l.input = c;
|
||
return l;
|
||
}
|
||
|
||
// lines splits a textarea's value into non-empty trimmed lines.
|
||
export function lines(text) {
|
||
return text.split(/\r?\n/).map((s) => s.trim()).filter((s) => s !== "");
|
||
}
|
||
|
||
// ---- tables
|
||
|
||
// table renders rows with columns [{label, render(row) => node|string, class}].
|
||
export function table(columns, rows, opts = {}) {
|
||
if (!rows.length) return h("div", { class: "empty" }, opts.empty || "Nothing here yet.");
|
||
const thead = h("thead", null, h("tr", null, columns.map((c) => h("th", { class: c.class || "" }, c.label))));
|
||
const tbody = h("tbody");
|
||
rows.forEach((row, i) => {
|
||
const tr = h("tr", { class: opts.rowClass ? opts.rowClass(row, i) : "" });
|
||
for (const c of columns) {
|
||
const v = c.render(row, i);
|
||
tr.append(h("td", { class: c.tdClass ? (typeof c.tdClass === "function" ? c.tdClass(row, i) : c.tdClass) : "" }, v));
|
||
}
|
||
tbody.append(tr);
|
||
});
|
||
return h("div", { class: "table-wrap" }, h("table", null, thead, tbody));
|
||
}
|
||
|
||
// ---- problems, diffs, errors
|
||
|
||
export function problemsList(problems) {
|
||
if (!problems || !problems.length) return null;
|
||
return h("ul", { class: "problems" }, problems.map((p) => {
|
||
const where = [p.view && "view " + p.view, p.zone && "zone " + display(p.zone)].filter(Boolean).join(", ");
|
||
return h("li", { class: p.severity }, h("strong", null, p.severity + ": "), p.message, where ? h("span", { class: "where" }, where) : null);
|
||
}));
|
||
}
|
||
|
||
// checkList shows named-checkconf's errors and warnings (not its routine
|
||
// "loaded serial" lines).
|
||
export function checkList(msgs) {
|
||
const shown = (msgs || []).filter((m) => m.error || !(m.text.startsWith("loaded serial") || m.text === "OK"));
|
||
if (!shown.length) return null;
|
||
return h("ul", { class: "problems" }, shown.map((m) =>
|
||
h("li", { class: m.error ? "error" : "warning" }, h("strong", null, m.error ? "named-checkconf: " : "named-checkconf warning: "), m.text, m.object ? h("span", { class: "where" }, m.object) : null)));
|
||
}
|
||
|
||
export function diffView(text) {
|
||
if (!text) return h("div", { class: "muted" }, "No file changes.");
|
||
const box = h("div", { class: "diff" });
|
||
for (const line of text.replace(/\n$/, "").split("\n")) {
|
||
let cls = "";
|
||
if (line.startsWith("+++") || line.startsWith("---")) cls = "file";
|
||
else if (line.startsWith("+")) cls = "add";
|
||
else if (line.startsWith("-")) cls = "del";
|
||
else if (line.startsWith("@@")) cls = "hunk";
|
||
box.append(h("div", { class: cls }, line || " "));
|
||
}
|
||
return box;
|
||
}
|
||
|
||
// errorBox shows an API error with its problems and named-checkconf output.
|
||
export function errorBox(err) {
|
||
if (!err) return null;
|
||
const d = err.details || {};
|
||
let problems = d.problems || [];
|
||
// Don't repeat a single problem that is already the headline.
|
||
if (problems.length === 1 && err.message.includes(problems[0].message)) problems = [];
|
||
return h("div", { class: "msg err" },
|
||
h("div", null, err.message),
|
||
problemsList(problems),
|
||
checkList(d.check));
|
||
}
|
||
|
||
export function copyBox(value) {
|
||
const inp = input({ value, readOnly: true });
|
||
const btn = h("button", { type: "button", class: "small", onclick: async () => {
|
||
try { await navigator.clipboard.writeText(value); toast("Copied"); } catch { inp.select(); document.execCommand("copy"); toast("Copied"); }
|
||
} }, "Copy");
|
||
return h("div", { class: "copy-box" }, inp, btn);
|
||
}
|
||
|
||
// debounce delays fn until calls stop for ms.
|
||
export function debounce(fn, ms = 250) {
|
||
let t;
|
||
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
||
}
|
||
|
||
// Where each list page was last left (with its filters), so a breadcrumb
|
||
// back to it restores them. The router and list pages fill it in.
|
||
export const lastList = {};
|
||
|
||
// crumbs is a breadcrumb trail above a detail page's heading: the pages
|
||
// it belongs to, as [label, href] pairs (the heading names the page).
|
||
export function crumbs(...items) {
|
||
const out = [];
|
||
items.forEach(([label, href], i) => {
|
||
if (i > 0) out.push(h("span", { class: "sep", "aria-hidden": "true" }, "›"));
|
||
out.push(h("a", { href }, label));
|
||
});
|
||
return h("nav", { class: "crumbs", "aria-label": "Breadcrumb" }, out);
|
||
}
|
||
|
||
// The colours a view can be shown in (besides the default, the link
|
||
// colour). Keep in step with model.ViewColors.
|
||
export const viewColors = ["green", "teal", "purple", "pink", "orange", "yellow", "red", "grey"];
|
||
|
||
// viewLink links to a view's page, in the view's colour. v is a view
|
||
// from /views; name is shown when the view is unknown.
|
||
export function viewLink(v, name) {
|
||
if (!v) return name ? h("span", null, name) : h("span", { class: "muted" }, "–");
|
||
return h("a", { href: "#/views?view=" + v.id, class: v.color ? "vc-" + v.color : null, title: "View " + v.name }, v.name);
|
||
}
|
||
|
||
// colorPicker picks a view colour; its value is "" for the default.
|
||
export function colorPicker(value) {
|
||
const el = h("div", { class: "swatches", role: "radiogroup" });
|
||
const draw = () => mount(el, ["", ...viewColors].map((c) => h("button", {
|
||
type: "button", role: "radio", "aria-checked": String(c === el.value),
|
||
class: "swatch" + (c ? " vc-" + c : " vc-default") + (c === el.value ? " selected" : ""),
|
||
title: c || "default (link colour)", "aria-label": c || "default",
|
||
onclick: () => { el.value = c; draw(); },
|
||
})));
|
||
el.value = value || "";
|
||
draw();
|
||
return el;
|
||
}
|