Files
canon/web/static/js/admin-list.js
T
cdnutterandClaude Opus 5.5 33053d1c5f Add refresh from source for entries and whole lists
- Every provider can look an entry up again by its ID (TMDB, TheTVDB,
  IGDB, Deezer, MusicBrainz, Wikipedia); search and lookup share parsing
- POST /api/items/{id}/refresh re-applies title, subtitle, year,
  description, link, details and artwork. Kept: the "why" note, rank,
  theater production/venue/cast and a theater entry's year, the Deezer
  preview match. Empty source fields don't erase existing values
- New items.image_custom column (migration 4; existing uploads marked)
  records artwork the owner chose; refresh keeps it unless asked
- Refused for custom lists and hand-added entries; entries the source
  dropped are reported, and a refresh whose title shares no words with
  the current one is rejected as a likely bad ID
- Editor: refresh button per row (asks about the owner's own artwork),
  and "Refresh all" with a confirm step, replace-my-art checkbox,
  progress with Stop, and a summary of problems

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:41:22 -07:00

655 lines
27 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, chooseDialog, 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")),
canRefresh(it) && h("button", { type: "button", class: "icon-btn", "aria-label": `Refresh ${it.title} from ${sourceName(it)}`, title: `Refresh from ${sourceName(it)}`, onclick: (e) => refreshOne(it, e.currentTarget) }, icon("refresh")),
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")),
),
);
}
const SOURCES = { tmdb: "TMDB", tvdb: "TheTVDB", igdb: "IGDB", deezer: "Deezer", musicbrainz: "MusicBrainz", wikipedia: "Wikipedia" };
const sourceName = (it) => SOURCES[it.provider] || "its source";
// Entries added from a search can be refreshed from that service. Custom
// lists and hand-added entries have no source.
function canRefresh(it) {
return cat.key !== "custom" && !!it.provider;
}
// ---------- refresh from source ----------
async function refreshOne(it, button) {
let replaceCustomArt = false;
if (it.customArt) {
const choice = await chooseDialog({
title: "Replace your artwork?",
message: `“${it.title}” uses artwork you chose yourself. Refreshing updates its details from ${sourceName(it)}. Should it also switch to ${sourceName(it)}'s artwork?`,
choices: [{ label: "Keep my artwork", value: "keep" }, { label: `Use ${sourceName(it)}'s`, value: "replace", primary: true }],
});
if (!choice) return;
replaceCustomArt = choice === "replace";
}
busy(button, true);
try {
const res = await api("POST", `/api/items/${it.id}/refresh`, { replaceCustomArt });
toast(res.changed.length ? `Updated ${res.changed.join(", ")} for “${res.item.title}”` : `“${res.item.title}” is already up to date`);
if (res.warning) toast(res.warning, { error: true });
await load(page, it.id);
} catch (err) {
toast(err.message, { error: true });
busy(button, false);
}
}
async function allItems() {
const out = [];
for (let offset = 0; ; offset += 500) {
const batch = await api("GET", `/api/lists/${list.id}/items?offset=${offset}&limit=500`);
out.push(...batch);
if (batch.length < 500) return out;
}
}
async function refreshAll() {
let all;
try {
all = await allItems();
} catch (err) {
toast(err.message, { error: true });
return;
}
const targets = all.filter(canRefresh);
if (!targets.length) {
toast("Nothing to refresh: every entry here was added by hand.", { error: true });
return;
}
const custom = targets.filter((it) => it.customArt).length;
const manual = all.length - targets.length;
const sources = new Intl.ListFormat("en", { type: "conjunction" }).format([...new Set(targets.map(sourceName))]);
// Step 1: confirm, and ask about the owner's own artwork.
const replaceBox = h("input", { type: "checkbox" });
let start = false;
let dlg = openDialog(h("div", {},
dialogHead("Refresh all from source", () => dlg),
h("div", { class: "dialog-body stack" },
h("p", {}, `Re-pull ${fmt.format(targets.length)} ${targets.length === 1 ? "entry" : "entries"} from ${sources}. Titles, years, descriptions, links, details and artwork are updated wherever the source changed.`),
h("p", { class: "muted" }, "Your “why” notes, the ranking, and theater productions stay exactly as they are." +
(manual ? ` ${fmt.format(manual)} added by hand ${manual === 1 ? "is" : "are"} skipped.` : "")),
custom > 0 && h("label", { class: "check refresh-art-choice" }, replaceBox,
h("span", {}, h("b", {}, "Also replace artwork you chose yourself"),
h("br"), h("span", { class: "muted small" }, `${fmt.format(custom)} ${custom === 1 ? "entry uses" : "entries use"} an image you uploaded, pasted or removed. Leave this off to keep ${custom === 1 ? "it" : "them"}.`))),
),
h("div", { class: "dialog-foot" },
h("button", { type: "button", class: "btn", onclick: () => dlg.close() }, "Cancel"),
h("button", { type: "button", class: "btn btn--primary", onclick: () => { start = true; dlg.close(); } }, icon("refresh"), "Refresh all"),
),
), { onClose: () => start && runRefreshAll(targets, replaceBox.checked) });
dlg.style.maxWidth = "520px";
}
async function runRefreshAll(targets, replaceCustomArt) {
let stopped = false;
let finished = false;
const bar = h("span", { style: { width: "0%" } });
const status = h("p", { class: "muted small", "aria-live": "polite" }, "Starting…");
const body = h("div", { class: "dialog-body stack" }, h("div", { class: "meter", style: { margin: 0 } }, bar), status);
const stop = h("button", { type: "button", class: "btn", onclick: () => { stopped = true; stop.disabled = true; stop.textContent = "Stopping…"; } }, "Stop");
const foot = h("div", { class: "dialog-foot" }, stop);
const dlg = openDialog(h("div", {}, h("div", { class: "dialog-head" }, h("h2", {}, "Refreshing…")), body, foot), {
onClose: () => { stopped = true; if (finished) load(page); },
});
dlg.style.maxWidth = "520px";
let updated = 0, current = 0, processed = 0;
const failed = [];
for (let i = 0; i < targets.length && !stopped; i++) {
const it = targets[i];
status.textContent = `${fmt.format(i + 1)} of ${fmt.format(targets.length)} · ${it.title}`;
processed++;
try {
const res = await api("POST", `/api/items/${it.id}/refresh`, { replaceCustomArt });
if (res.changed.length) updated++; else current++;
if (res.warning) failed.push({ title: it.title, error: res.warning });
} catch (err) {
failed.push({ title: it.title, error: err.message });
// Missing keys affect every entry from that service: stop early.
if (err.status === 424) break;
}
bar.style.width = `${Math.round((processed / targets.length) * 100)}%`;
}
finished = true;
dlg.querySelector(".dialog-head h2").textContent = processed < targets.length ? "Refresh stopped" : "Refresh finished";
body.replaceChildren(...[
h("p", {}, [
`${fmt.format(updated)} updated`,
`${fmt.format(current)} already up to date`,
failed.length && `${fmt.format(failed.length)} with problems`,
processed < targets.length && `${fmt.format(targets.length - processed)} not reached`,
].filter(Boolean).join(" · ")),
failed.length > 0 && h("ul", { class: "refresh-failures" },
failed.slice(0, 12).map((f) => h("li", {}, h("b", {}, f.title), " — ", f.error)),
failed.length > 12 && h("li", { class: "muted" }, `…and ${failed.length - 12} more`)),
].filter(Boolean));
foot.replaceChildren(h("button", { type: "button", class: "btn btn--primary", onclick: () => dlg.close() }, "Done"));
await load(page);
}
document.querySelector("[data-action=refresh-all]")?.addEventListener("click", refreshAll);
// 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,
customArt: !!result && body.imageUrl !== "" && body.imageUrl !== (result.imageUrl || ""),
});
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);