Files
canon/web/static/js/admin-list.js
T
cdnutterandClaude Opus 5.5 ddf2157e63 Add list styles: Podium, Spotlight, Grid, List
- Each list picks a style in List settings (new lists default to Podium);
  existing lists keep Grid or List
- Podium: top 3 on medal-colored steps (#1 center and raised), the rest
  behind a native <details> "Show N more"; stacks #1 over #2/#3 on phones
- Spotlight: #1 as a feature card (art, why-note or description, play
  button), the rest as a numbered list
- Visitors can switch styles on a list page (?style=), preserved across
  pages; later pages fall back to a plain list since they don't start at #1
- Entries' detail data now lives on a wrapper per list, so featured and
  collapsed entries all open the detail view

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

522 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// List editor: search providers, add entries, rank and edit them.
import { h, icon, api, readBoot, toast, confirmDialog, openDialog, dialogHead, debounce, busy, fmt } from "./common.js";
import { sortable } from "./sortable.js";
import { openListForm } from "./list-form.js";
import { previewButton } from "./player.js";
const boot = readBoot();
const { categories, limits, productions } = boot;
const cat = boot.category;
const pageSize = boot.pageSize;
let list = boot.list;
let items = [];
let page = 1;
const square = cat.aspect === "square";
const $ = (sel) => document.querySelector(sel);
const rankList = $("[data-rank-list]");
const pager = $("[data-pager]");
const results = $("[data-results]");
const searchForm = $("[data-search]");
const searchInput = searchForm.querySelector("input");
const spinner = searchForm.querySelector(".spinner");
if (square) {
rankList.classList.add("is-square");
results.classList.add("is-square");
}
// Category-specific extra fields, stored in item.extra.
const EXTRA_FIELDS = {
movie: [["genres", "Genres"], ["runtime", "Runtime"]],
tv: [["genres", "Genres"]],
album: [["type", "Type (EP, Live…)"], ["label", "Label"], ["genres", "Genres"]],
song: [["album", "Album"], ["length", "Length"]],
game: [["platforms", "Platforms"], ["genres", "Genres"]],
theater: [["venue", "Venue / Theatre"], ["cast", "Notable cast"]],
};
// ---------- header ----------
function renderHeader() {
document.querySelector("[data-bind=title]").textContent = list.title;
document.querySelector("[data-bind=count]").textContent = fmt.format(list.itemCount);
document.querySelector("[data-bind=max]").textContent = fmt.format(list.maxItems);
document.querySelector("[data-bind=hidden]").textContent = list.visible ? "" : " · Hidden";
document.querySelector("[data-bind=public]").href = `/l/${list.slug}`;
document.querySelector("[data-bind=export-csv]").href = `/l/${list.slug}/export?format=csv`;
document.querySelector("[data-bind=export-txt]").href = `/l/${list.slug}/export?format=txt`;
document.querySelector("[data-bind=meter]").style.width = Math.min(100, (list.itemCount / list.maxItems) * 100) + "%";
document.querySelector("[data-bind=provider]").textContent = boot.provider ? `via ${boot.provider}` : "";
document.title = `${list.title} · ${document.title.split(" · ").pop()}`;
}
const isFull = () => list.itemCount >= list.maxItems;
document.querySelector("[data-action=edit-list]").addEventListener("click", () => {
openListForm({
categories, limits, styles: boot.styles, list,
onSaved: (saved) => {
// The artwork shape affects the whole editor, so redraw from scratch.
if (saved.art !== list.art) { location.reload(); return; }
list = saved;
renderHeader();
renderResults(lastResults);
toast("List saved");
},
});
});
// ---------- ranked items ----------
const pages = () => Math.max(1, Math.ceil(list.itemCount / pageSize));
async function load(p = page, flashId) {
page = Math.min(Math.max(1, p), pages());
try {
items = await api("GET", `/api/lists/${list.id}/items?offset=${(page - 1) * pageSize}&limit=${pageSize}`);
} catch (err) {
toast(err.message, { error: true });
return;
}
renderItems(flashId);
}
function artBox(src, alt = "") {
const box = h("span", { class: "result-art" });
if (src) {
const img = h("img", { src, alt, loading: "lazy", referrerpolicy: "no-referrer" });
img.addEventListener("error", () => img.replaceWith(icon(cat.icon)), { once: true });
box.append(img);
} else {
box.append(icon(cat.icon));
}
return box;
}
function row(it) {
const rankInput = h("input", {
class: "rank-input", type: "number", min: 1, max: list.itemCount, value: it.rank,
"aria-label": `Rank of ${it.title}`, inputmode: "numeric",
});
const commit = () => {
const n = Number(rankInput.value);
if (!Number.isInteger(n) || n === it.rank) { rankInput.value = it.rank; return; }
moveTo(it, Math.min(Math.max(1, n), list.itemCount));
};
rankInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); rankInput.blur(); } });
rankInput.addEventListener("change", commit);
const sub = [it.subtitle, it.year].filter(Boolean).join(" · ");
return h("li", { class: "rank-row", "data-id": it.id },
h("button", { type: "button", class: "drag-handle", "aria-label": `Drag to reorder ${it.title}, or use arrow keys` }, icon("grip")),
rankInput,
artBox(it.image ? `/images/${it.image}` : ""),
h("div", { class: "rank-row-body" },
h("div", { class: "rank-row-title" }, it.title),
h("div", { class: "rank-row-sub" },
it.extra?.production && h("span", { class: "badge" }, it.extra.production),
sub && h("span", {}, sub),
it.note && h("span", { title: "Has a note" }, icon("pencil")),
),
),
h("div", { class: "rank-row-actions" },
canPreview(it) && previewButton(`/preview/${it.id}`, `Play a preview of ${it.title}`, "play-btn play-btn--inline hide-sm"),
h("button", { type: "button", class: "icon-btn hide-sm", "aria-label": "Move up", title: "Move up", disabled: it.rank === 1, onclick: () => moveTo(it, it.rank - 1) }, icon("chevron-up")),
h("button", { type: "button", class: "icon-btn hide-sm", "aria-label": "Move down", title: "Move down", disabled: it.rank === list.itemCount, onclick: () => moveTo(it, it.rank + 1) }, icon("chevron-down")),
h("button", { type: "button", class: "icon-btn", "aria-label": `Edit ${it.title}`, title: "Edit", onclick: () => openItemForm({ item: it }) }, icon("pencil")),
h("button", { type: "button", class: "icon-btn icon-btn--danger", "aria-label": `Remove ${it.title}`, title: "Remove", onclick: () => removeItem(it) }, icon("trash")),
),
);
}
// Songs and albums from Deezer, or matched to Deezer when saved, can play.
function canPreview(it) {
return (cat.key === "song" || cat.key === "album") && (it.provider === "deezer" || !!it.extra?.deezer);
}
function renderItems(flashId) {
if (!items.length) {
rankList.replaceChildren(h("li", { class: "rank-empty" },
h("div", { class: "empty-art", style: { margin: "0 auto 12px" } }, icon(cat.icon)),
h("p", {}, cat.key === "custom"
? "Nothing here yet. Add your first favorite by hand, or search Wikipedia for one with a picture."
: `No ${cat.label.toLowerCase()} yet. Search on the left to add your first.`)));
} else {
rankList.replaceChildren(...items.map(row));
}
if (flashId) {
const el = rankList.querySelector(`[data-id="${flashId}"]`);
if (el) {
el.classList.add("is-flash");
el.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}
renderPager();
}
function renderPager() {
const total = pages();
if (total <= 1) { pager.replaceChildren(); return; }
const btn = (p, label = String(p), aria) => h("button", {
type: "button", class: "pager-btn" + (p === page ? " is-current" : ""), "aria-label": aria,
"aria-current": p === page ? "page" : null, onclick: () => load(p),
}, label);
const nums = [];
let last = 0;
for (let i = 1; i <= total; i++) {
if (i === 1 || i === total || Math.abs(i - page) <= 2) {
if (last && i - last > 1) nums.push(h("span", { class: "pager-gap" }, "…"));
nums.push(btn(i, `${fmt.format((i - 1) * pageSize + 1)}–${fmt.format(Math.min(i * pageSize, list.itemCount))}`));
last = i;
}
}
pager.replaceChildren(...nums);
}
async function moveTo(it, rank) {
try {
await api("POST", `/api/items/${it.id}/move`, { rank });
} catch (err) {
toast(err.message, { error: true });
}
const targetPage = Math.ceil(rank / pageSize);
await load(targetPage, it.id);
}
sortable(rankList, {
onMove: async (el, from, to) => {
const id = Number(el.dataset.id);
const it = items.find((x) => x.id === id);
const rank = (page - 1) * pageSize + to + 1;
// Renumber right away so the UI feels instant; the reload confirms.
[...rankList.children].forEach((li, i) => {
const input = li.querySelector(".rank-input");
if (input) input.value = (page - 1) * pageSize + i + 1;
});
try {
await api("POST", `/api/items/${id}/move`, { rank });
} catch (err) {
toast(err.message, { error: true });
}
await load(page, it?.id);
},
});
async function removeItem(it) {
const ok = await confirmDialog({
title: "Remove from list?",
message: `“${it.title}” will be removed from #${it.rank}, and everything below moves up one spot.`,
confirm: "Remove",
danger: true,
});
if (!ok) return;
try {
await api("DELETE", `/api/items/${it.id}`);
list.itemCount--;
renderHeader();
toast(`Removed “${it.title}”`);
await load();
markResult(it, 0);
} catch (err) {
toast(err.message, { error: true });
}
}
// ---------- search ----------
let lastResults = null;
let searchSeq = 0;
async function search(q) {
q = q.trim();
if (!q) { renderResults(null); return; }
const seq = ++searchSeq;
spinner.hidden = false;
try {
const data = await api("GET", `/api/search?category=${cat.key}&list=${list.id}&q=${encodeURIComponent(q)}`);
if (seq !== searchSeq) return;
renderResults(data.results);
} catch (err) {
if (seq !== searchSeq) return;
lastResults = null;
const needsSetup = err.status === 424;
results.replaceChildren(h("div", { class: "alert " + (needsSetup ? "alert--warn" : "alert--error") },
icon(needsSetup ? "key" : "alert"),
h("div", {}, err.message, needsSetup && h("div", {}, h("a", { href: "/admin/settings#integrations" }, "Open integrations →")))));
} finally {
if (seq === searchSeq) spinner.hidden = true;
}
}
function resultKey(r) { return `${r.provider}:${r.providerId}`; }
function markResult(it, rank) {
if (!lastResults || !it.provider) return;
const r = lastResults.find((x) => x.provider === it.provider && x.providerId === it.providerId);
if (r) { r.existingRank = rank; renderResults(lastResults); }
}
function renderResults(res) {
lastResults = res;
if (res == null) { results.replaceChildren(); return; }
if (!res.length) {
results.replaceChildren(h("p", { class: "search-msg" }, "No matches. Try another spelling, or add it manually below."));
return;
}
const full = isFull();
const nodes = res.map((r) => {
const sub = [r.subtitle, r.year, r.extra?.type].filter(Boolean).join(" · ");
const source = { deezer: "Deezer", musicbrainz: "MusicBrainz" }[r.provider];
const action = r.existingRank
? h("span", { class: "result-added" }, icon("check"), `#${r.existingRank}`)
: h("button", {
type: "button", class: "btn btn--sm btn--primary", disabled: full,
title: full ? "This list is full" : `Add to #${list.itemCount + 1}`,
onclick: (e) => addResult(r, e.currentTarget),
}, icon("plus"), "Add");
const art = artBox(r.imageUrl, "");
if (r.previewRef) art.append(previewButton(`/admin/preview?ref=${encodeURIComponent(r.previewRef)}`, `Play a preview of ${r.title}`, "play-btn play-btn--mini"));
return h("div", { class: "result", "data-key": resultKey(r) },
art,
h("div", { style: { minWidth: 0 } },
h("div", { class: "result-title" }, r.title),
sub && h("div", { class: "result-sub" }, sub),
source && h("span", { class: "result-source" }, source),
(r.overview || r.hint) && h("div", { class: "result-overview" }, r.overview || r.hint),
),
action,
);
});
if (full) nodes.unshift(h("p", { class: "alert alert--warn" }, icon("info"), `This list is full (${fmt.format(list.maxItems)}). Raise its size in List settings to add more.`));
results.replaceChildren(...nodes);
}
async function addResult(r, button) {
// Theater entries need the owner to say which production they mean.
if (cat.key === "theater") {
openItemForm({ result: r });
return;
}
busy(button, true);
await createItem({ ...fromResult(r), details: true }, () => busy(button, false));
}
function fromResult(r) {
return {
title: r.title, subtitle: r.subtitle, year: r.year, externalUrl: r.externalUrl,
imageUrl: r.imageUrl, provider: r.provider, providerId: r.providerId, extra: { ...(r.extra || {}) },
description: r.overview || "",
};
}
async function createItem(body, done) {
try {
let res;
try {
res = await api("POST", `/api/lists/${list.id}/items`, body);
} catch (err) {
if (err.status !== 409) throw err;
const again = await confirmDialog({ title: "Already ranked", message: `${err.message} Add it again anyway?`, confirm: "Add again" });
if (!again) return null;
res = await api("POST", `/api/lists/${list.id}/items`, { ...body, force: true });
}
list.itemCount++;
renderHeader();
toast(`Added “${res.item.title}” at #${res.item.rank}`);
if (res.warning) toast(res.warning, { error: true });
markResult(res.item, res.item.rank);
await load(Math.ceil(res.item.rank / pageSize), res.item.id);
return res.item;
} catch (err) {
toast(err.message, { error: true });
return null;
} finally {
done?.();
}
}
const runSearch = debounce((q) => search(q), 450);
searchInput.addEventListener("input", () => runSearch(searchInput.value));
searchForm.addEventListener("submit", (e) => { e.preventDefault(); search(searchInput.value); });
document.querySelector("[data-action=manual]").addEventListener("click", () => openItemForm({}));
// ---------- item form (add manually / edit / theater add) ----------
function openItemForm({ item, result }) {
const editing = !!item;
const src = item || (result ? fromResult(result) : { extra: {} });
const extra = { ...(src.extra || {}) };
let clearImage = false;
let dlg;
const field = (label, input, hint) => h("label", { class: "field" },
h("span", { class: "field-label" }, label), input, hint && h("span", { class: "field-hint" }, hint));
const title = h("input", { required: true, maxlength: 500, value: src.title || "" });
const subtitle = h("input", { maxlength: 500, value: src.subtitle || "" });
const year = h("input", { maxlength: 20, value: src.year || "", inputmode: "numeric", style: { maxWidth: "140px" } });
const description = h("textarea", { rows: 4, maxlength: 10000, value: src.description || "", placeholder: "What it is, in a few sentences." });
const note = h("textarea", { rows: 3, maxlength: 10000, value: item?.note || "", placeholder: "What earns it this spot? Shown to visitors." });
const source = { tmdb: "TMDB", tvdb: "TheTVDB", igdb: "IGDB", wikipedia: "Wikipedia" }[src.provider];
const rankLabel = editing ? `#${item.rank}` : `#${list.itemCount + 1}`;
const link = h("input", { type: "url", maxlength: 2000, value: src.externalUrl || "", placeholder: "https://" });
// Theater production gets top billing with suggestions.
let production, productionList;
if (cat.key === "theater") {
productionList = h("datalist", { id: "productions" }, productions.map((p) => h("option", { value: p })));
production = h("input", { list: "productions", maxlength: 200, value: extra.production || "", placeholder: "e.g. Broadway, North American Tour" });
}
const extraInputs = (EXTRA_FIELDS[cat.key] || []).map(([key, label]) => {
const input = h("input", { maxlength: 500, value: extra[key] || "" });
return { key, label, input };
});
// Artwork
const currentArt = item?.image ? `/images/${item.image}` : (src.imageUrl || "");
const preview = h("div", {}, artBox(currentArt));
const imageUrl = h("input", {
type: "url", placeholder: "Paste an image URL",
value: editing ? (item.imageSource && item.imageSource !== "upload" ? item.imageSource : "") : (src.imageUrl || ""),
});
// In add mode a chosen file waits here and is uploaded once the entry exists.
let pendingFile = null;
imageUrl.addEventListener("change", () => {
clearImage = false;
pendingFile = null;
preview.replaceChildren(artBox(imageUrl.value.trim()));
});
const fileInput = h("input", { type: "file", accept: "image/jpeg,image/png,image/webp,image/gif,image/avif", hidden: true });
const uploadBtn = h("button", { type: "button", class: "btn btn--sm", onclick: () => fileInput.click() }, icon("upload"), "Upload");
fileInput.addEventListener("change", async () => {
const file = fileInput.files[0];
if (!file) return;
if (!editing) {
pendingFile = file;
clearImage = false;
imageUrl.value = "";
preview.replaceChildren(artBox(URL.createObjectURL(file)));
fileInput.value = "";
return;
}
const fd = new FormData();
fd.append("file", file);
busy(uploadBtn, true);
try {
const res = await api("POST", `/api/items/${item.id}/image`, fd);
Object.assign(item, res.item);
imageUrl.value = "";
clearImage = false;
preview.replaceChildren(artBox(`/images/${res.item.image}`));
toast("Artwork updated");
await load(page);
} catch (err) {
toast(err.message, { error: true });
} finally {
busy(uploadBtn, false);
fileInput.value = "";
}
});
const removeArt = h("button", {
type: "button", class: "btn btn--sm btn--ghost",
onclick: () => { clearImage = true; pendingFile = null; imageUrl.value = ""; preview.replaceChildren(artBox("")); },
}, icon("x"), "Remove");
const artHint = cat.key === "theater"
? "Tip: use a poster from the specific production (a tour's key art, a revival's Playbill…)."
: cat.key === "custom"
? "Paste an image URL or upload a photo or logo."
: "Art is saved to your server, so it keeps working if the source changes.";
const error = h("p", { class: "alert alert--error", role: "alert", hidden: true });
const save = h("button", { type: "submit", class: "btn btn--primary" }, editing ? "Save" : `Add at #${list.itemCount + 1}`);
const heading = editing ? `Edit #${item.rank}` : result ? `Add “${result.title}”` : `Add a ${cat.singular.toLowerCase()}`;
const form = h("form", { novalidate: true },
dialogHead(heading, () => dlg),
h("div", { class: "dialog-body stack" },
production && field("Production", production, "Which staging this is about. Pick one or type your own."),
productionList,
h("div", { class: "art-edit" + (square ? " is-square" : "") },
preview,
h("div", { class: "art-edit-actions" },
h("span", { class: "field-label" }, "Artwork"),
imageUrl,
h("div", { class: "row-btns" }, uploadBtn, removeArt, fileInput),
h("span", { class: "field-hint" }, artHint),
),
),
field("Title", title),
h("div", { class: "row" },
field(cat.subtitleHint, subtitle),
field(cat.key === "theater" ? "Year (of this production)" : "Year", year),
),
extraInputs.length > 0 && h("div", { class: "row" }, extraInputs.map((x) => field(x.label, x.input))),
field("Description", description, source ? `From ${source}. Edit it if you like.` : "Optional. Shown to visitors."),
field(`Why it's ${rankLabel}`, note, "Your take on why it earns its place in the ranking."),
field("Link", link, "Where the title links to on your page."),
error,
),
h("div", { class: "dialog-foot" },
h("button", { type: "button", class: "btn", onclick: () => dlg.close() }, "Cancel"),
save,
),
);
form.addEventListener("submit", async (e) => {
e.preventDefault();
error.hidden = true;
if (!title.value.trim()) {
error.hidden = false;
error.textContent = "Give it a title.";
title.focus();
return;
}
const newExtra = { ...extra };
if (production) newExtra.production = production.value.trim();
for (const x of extraInputs) newExtra[x.key] = x.input.value.trim();
const body = {
title: title.value, subtitle: subtitle.value, year: year.value, note: note.value, description: description.value,
externalUrl: link.value.trim(), extra: newExtra,
imageUrl: clearImage ? "" : imageUrl.value.trim(), clearImage,
};
busy(save, true);
try {
if (editing) {
await api("PUT", `/api/items/${item.id}`, body);
dlg.close();
toast("Saved");
await load(page, item.id);
} else {
Object.assign(body, {
provider: result?.provider || "", providerId: result?.providerId || "", details: !!result,
});
const created = await createItem(body);
if (created && pendingFile) {
const fd = new FormData();
fd.append("file", pendingFile);
try {
await api("POST", `/api/items/${created.id}/image`, fd);
await load(page, created.id);
} catch (err) {
toast(`Added, but the artwork didn't upload: ${err.message}`, { error: true });
}
}
if (created) dlg.close();
}
} catch (err) {
error.hidden = false;
error.textContent = err.message;
} finally {
busy(save, false);
}
});
dlg = openDialog(form);
(production || title).focus();
}
renderHeader();
load(1);