The list editor gets an Import button. A .csv/.tsv/.txt file or pasted text is parsed on the server: header columns (Title, Year, Artist or Director, Note, Rank), numbered lines and trailing "(1999)" years are understood, and Canon's own export round-trips. Each row is then searched with the list's provider and scored. A row is matched automatically only when one result fits exactly and nothing else fits as well; a year or artist in the file breaks ties, an exact title beats a bracketed version like "(Live)", and "Artist - Title" lines are tried both ways. Custom lists need a year or subtitle to be certain. Everything else is shown for review with a recommended pick, the other results, "Add as written", Skip, and a search-again box. Entries are added in file order after the existing ones, with any notes from the file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
662 lines
28 KiB
JavaScript
662 lines
28 KiB
JavaScript
// 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";
|
||
import { openImport } from "./import.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);
|
||
|
||
document.querySelector("[data-action=import]").addEventListener("click", () => openImport({
|
||
list, cat,
|
||
onListChange: (l) => { list = l; renderHeader(); },
|
||
onDone: () => load(pages()),
|
||
}));
|
||
|
||
// 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);
|