Visitors see the home page with no top bar at all. Signed in, the bar sits behind a small tab at the top center that drops it down; Escape or a click outside closes it. Every other page keeps the regular bar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
205 lines
6.8 KiB
JavaScript
205 lines
6.8 KiB
JavaScript
// Shared helpers for Canon's pages. No build step, no dependencies.
|
|
|
|
/** Create an element: h("button", { class: "btn", onclick }, "Save") */
|
|
export function h(tag, attrs = {}, ...children) {
|
|
const el = document.createElement(tag);
|
|
let value;
|
|
for (const [k, v] of Object.entries(attrs || {})) {
|
|
if (v === false || v == null) continue;
|
|
if (k === "value") value = v;
|
|
else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v);
|
|
else if (k === "class") el.className = v;
|
|
else if (k === "style" && typeof v === "object") Object.assign(el.style, v);
|
|
else if (k in el && typeof v !== "string") el[k] = v;
|
|
else el.setAttribute(k, v === true ? "" : v);
|
|
}
|
|
append(el, children);
|
|
// Set after children so <select> options exist.
|
|
if (value !== undefined) el.value = value;
|
|
return el;
|
|
}
|
|
|
|
function append(el, children) {
|
|
for (const c of children.flat(Infinity)) {
|
|
if (c == null || c === false) continue;
|
|
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
|
|
}
|
|
}
|
|
|
|
const SVG = "http://www.w3.org/2000/svg";
|
|
|
|
export function icon(name) {
|
|
const svg = document.createElementNS(SVG, "svg");
|
|
svg.setAttribute("class", "icon");
|
|
svg.setAttribute("aria-hidden", "true");
|
|
const use = document.createElementNS(SVG, "use");
|
|
use.setAttribute("href", `/static/img/icons.svg#${name}`);
|
|
svg.append(use);
|
|
return svg;
|
|
}
|
|
|
|
export function readBoot() {
|
|
const el = document.getElementById("boot");
|
|
return el ? JSON.parse(el.textContent) : {};
|
|
}
|
|
|
|
export class APIError extends Error {
|
|
constructor(message, status, body) {
|
|
super(message);
|
|
this.status = status;
|
|
this.body = body;
|
|
}
|
|
}
|
|
|
|
/** Call the admin JSON API. Throws APIError with a human message. */
|
|
export async function api(method, path, body) {
|
|
const opts = { method, headers: { "X-Canon": "1" }, credentials: "same-origin" };
|
|
if (body instanceof FormData) {
|
|
opts.body = body;
|
|
} else if (body !== undefined) {
|
|
opts.headers["Content-Type"] = "application/json";
|
|
opts.body = JSON.stringify(body);
|
|
}
|
|
let res;
|
|
try {
|
|
res = await fetch(path, opts);
|
|
} catch {
|
|
throw new APIError("Can't reach the server. Check your connection.", 0);
|
|
}
|
|
if (res.status === 204) return null;
|
|
const data = await res.json().catch(() => ({}));
|
|
if (res.status === 401) {
|
|
location.href = "/login?next=" + encodeURIComponent(location.pathname);
|
|
}
|
|
if (!res.ok) throw new APIError(data.error || `Request failed (${res.status})`, res.status, data);
|
|
return data;
|
|
}
|
|
|
|
let toastBox;
|
|
export function toast(message, { error = false, timeout = 3200 } = {}) {
|
|
if (!toastBox) {
|
|
toastBox = h("div", { class: "toasts", role: "status", "aria-live": "polite" });
|
|
document.body.append(toastBox);
|
|
}
|
|
const t = h("div", { class: "toast" + (error ? " toast--error" : "") }, icon(error ? "alert" : "check"), message);
|
|
toastBox.append(t);
|
|
setTimeout(() => {
|
|
t.classList.add("is-leaving");
|
|
setTimeout(() => t.remove(), 250);
|
|
}, error ? Math.max(timeout, 5000) : timeout);
|
|
}
|
|
|
|
/** Open a modal dialog. Returns the <dialog>; it removes itself on close. */
|
|
export function openDialog(content, { className = "", onClose } = {}) {
|
|
const dlg = h("dialog", { class: className }, content);
|
|
dlg.addEventListener("click", (e) => {
|
|
// Clicking the backdrop (outside the box) closes.
|
|
if (e.target !== dlg) return;
|
|
const r = dlg.getBoundingClientRect();
|
|
const inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
|
|
if (!inside) dlg.close();
|
|
});
|
|
dlg.addEventListener("close", () => {
|
|
onClose?.();
|
|
dlg.remove();
|
|
});
|
|
document.body.append(dlg);
|
|
dlg.showModal();
|
|
return dlg;
|
|
}
|
|
|
|
export function dialogHead(title, dlg) {
|
|
return h("div", { class: "dialog-head" },
|
|
h("h2", {}, title),
|
|
h("button", { type: "button", class: "icon-btn", "aria-label": "Close", onclick: () => dlg().close() }, icon("x")),
|
|
);
|
|
}
|
|
|
|
/** Ask a yes/no question. Resolves true when confirmed. */
|
|
export function confirmDialog({ title, message, confirm = "Confirm", danger = false }) {
|
|
return new Promise((resolve) => {
|
|
let ok = false;
|
|
let dlg;
|
|
const content = h("form", { method: "dialog" },
|
|
dialogHead(title, () => dlg),
|
|
h("div", { class: "dialog-body" }, h("p", { class: "muted" }, message)),
|
|
h("div", { class: "dialog-foot" },
|
|
h("button", { type: "button", class: "btn", onclick: () => dlg.close() }, "Cancel"),
|
|
h("button", {
|
|
type: "submit",
|
|
class: "btn " + (danger ? "btn--danger btn--solid" : "btn--primary"),
|
|
onclick: () => { ok = true; },
|
|
}, confirm),
|
|
),
|
|
);
|
|
dlg = openDialog(content, { onClose: () => resolve(ok) });
|
|
dlg.style.maxWidth = "440px";
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Ask a question with several answers. Resolves to the chosen value, or
|
|
* null if dismissed. choices: [{ label, value, primary }]
|
|
*/
|
|
export function chooseDialog({ title, message, choices }) {
|
|
return new Promise((resolve) => {
|
|
let picked = null;
|
|
let dlg;
|
|
const content = h("div", {},
|
|
dialogHead(title, () => dlg),
|
|
h("div", { class: "dialog-body" }, h("p", { class: "muted" }, message)),
|
|
h("div", { class: "dialog-foot" },
|
|
h("button", { type: "button", class: "btn btn--ghost", onclick: () => dlg.close() }, "Cancel"),
|
|
choices.map((c) => h("button", {
|
|
type: "button", class: "btn" + (c.primary ? " btn--primary" : ""),
|
|
onclick: () => { picked = c.value; dlg.close(); },
|
|
}, c.label)),
|
|
),
|
|
);
|
|
dlg = openDialog(content, { onClose: () => resolve(picked) });
|
|
dlg.style.maxWidth = "480px";
|
|
});
|
|
}
|
|
|
|
export function debounce(fn, ms) {
|
|
let t;
|
|
return (...args) => {
|
|
clearTimeout(t);
|
|
t = setTimeout(() => fn(...args), ms);
|
|
};
|
|
}
|
|
|
|
export function busy(button, on) {
|
|
button.disabled = on;
|
|
button.setAttribute("aria-busy", on ? "true" : "false");
|
|
}
|
|
|
|
export const fmt = new Intl.NumberFormat();
|
|
|
|
export function storageGet(key) {
|
|
try { return localStorage.getItem(key); } catch { return null; }
|
|
}
|
|
export function storageSet(key, val) {
|
|
try { localStorage.setItem(key, val); } catch { /* private mode */ }
|
|
}
|
|
|
|
// Close <details class="menu"> dropdowns when clicking elsewhere or picking an item.
|
|
document.addEventListener("click", (e) => {
|
|
for (const d of document.querySelectorAll("details.menu[open]")) {
|
|
if (!d.contains(e.target) || e.target.closest(".menu-pop a")) d.open = false;
|
|
}
|
|
});
|
|
|
|
// The home page's drop-down top bar closes on Escape or a click outside it.
|
|
document.addEventListener("click", (e) => {
|
|
const d = document.querySelector("details.topbar-drawer[open]");
|
|
if (d && !d.contains(e.target)) d.open = false;
|
|
});
|
|
document.addEventListener("keydown", (e) => {
|
|
const d = document.querySelector("details.topbar-drawer[open]");
|
|
if (e.key === "Escape" && d) {
|
|
d.open = false;
|
|
d.querySelector("summary").focus();
|
|
}
|
|
});
|