Files
canon/web/static/js/common.js
T
cdnutterandClaude Opus 5.5 58a29b7339 Add titles-only list export and Custom lists
Export
- GET /l/<list>/export?format=csv|txt downloads a list's titles in rank
  order, one per line, with no header or other fields (CSV gets a UTF-8
  BOM for Excel)
- Export menu in the list editor and, for the owner, on the public list
  page; hidden lists stay owner-only

Custom lists
- New "Custom" category for anything not built in (sports teams,
  drinks…): owner-named, slug from the title, entries added by hand or
  from an unfiltered Wikipedia search
- Per-list artwork shape (square or tall), stored in a new lists.art
  column (migration 2)
- Art can be uploaded while adding an entry, not only after

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:18:24 -07:00

168 lines
5.5 KiB
JavaScript

// Shared helpers for TopDeck'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-TopDeck": "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";
});
}
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;
}
});