Files
canon/web/static/js/common.js
T
cdnutterandClaude Opus 5.5 88373176f2 Allow empty places in rankings; rebuild the list editor
Entries now sit at places 1…max with gaps allowed (#28, nothing at #29,
#30). Moving onto an empty place just puts the entry there; onto a taken
place, the entries in between shift one step, stopping at the first
empty place, so lists without gaps reorder exactly as before. Inserts
push down only to the next gap; deleting can close up or leave the place
empty. New endpoint POST /api/lists/{id}/places inserts or removes empty
places or closes every gap; move and delete reply with every entry's
place. The podium shows an empty step for an empty #2 or #3, and a list
can't shrink below its last used place.

The editor loads every entry (no paging) and applies edits instantly
with the same rules (places.js mirrors places.go), queueing saves in the
background and reconciling with the server's reply. Type a place and
press Enter (↑/↓ move between boxes, Alt+↑/↓ moves the entry), drag, or
use the row menu. Empty runs show as rows with Add here and Remove, and
a toolbar shows the gap count, Close gaps and a Go to # box. Clicking an
entry opens it for editing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:24:00 -07:00

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, .menu-pop button")) 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();
}
});