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>
879 lines
35 KiB
JavaScript
879 lines
35 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 { Places } from "./places.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;
|
||
let list = boot.list;
|
||
let items = [];
|
||
|
||
const square = cat.aspect === "square";
|
||
const $ = (sel) => document.querySelector(sel);
|
||
const rankList = $("[data-rank-list]");
|
||
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");
|
||
},
|
||
});
|
||
});
|
||
|
||
// ---------- ranking ----------
|
||
//
|
||
// Every entry is loaded at once. Edits change the page immediately (see
|
||
// places.js for the rules), then go to the server one at a time; once
|
||
// they're all done the server's answer is applied, in case it differs.
|
||
|
||
const byId = new Map(); // id → item
|
||
const rowEls = new Map(); // id → <li>, reused so artwork doesn't reload
|
||
let places = new Places([], list.maxItems);
|
||
let addAt = 0; // place the next added entry goes to; 0 = after the last
|
||
let rendering = false;
|
||
|
||
async function loadAll(flashId) {
|
||
try {
|
||
items = await allItems();
|
||
} catch (err) {
|
||
toast(err.message, { error: true });
|
||
return;
|
||
}
|
||
byId.clear();
|
||
for (const it of items) byId.set(it.id, it);
|
||
for (const id of [...rowEls.keys()]) if (!byId.has(id)) rowEls.delete(id);
|
||
places = new Places(items, list.maxItems);
|
||
render(flashId);
|
||
}
|
||
|
||
// Redraws one entry after its details changed; its place stays.
|
||
function replaceItem(fresh) {
|
||
const it = byId.get(fresh.id);
|
||
if (!it) return;
|
||
Object.assign(it, fresh, { rank: places.rankOf(fresh.id) || fresh.rank });
|
||
rowEls.delete(it.id);
|
||
render(it.id);
|
||
}
|
||
|
||
// ---- server queue ----
|
||
|
||
let queue = Promise.resolve();
|
||
let pending = 0;
|
||
|
||
function send(method, path, body) {
|
||
pending++;
|
||
queue = queue.then(() => api(method, path, body)).then((res) => {
|
||
if (--pending === 0 && res?.places) applyServer(res.places);
|
||
}, (err) => {
|
||
pending--;
|
||
toast(err.message, { error: true });
|
||
// Something didn't save: show what the server has.
|
||
loadAll();
|
||
});
|
||
}
|
||
|
||
function applyServer(serverPlaces) {
|
||
const fresh = new Places(serverPlaces, list.maxItems);
|
||
const same = fresh.at.size === places.at.size && [...fresh.at].every(([r, id]) => places.at.get(r) === id);
|
||
if (!same) {
|
||
places = fresh;
|
||
render();
|
||
}
|
||
}
|
||
|
||
// ---- edits ----
|
||
|
||
function syncRanks() {
|
||
for (const [r, id] of places.at) {
|
||
const it = byId.get(id);
|
||
if (it) it.rank = r;
|
||
}
|
||
}
|
||
|
||
function moveTo(it, to, flash = true) {
|
||
to = Math.round(to);
|
||
if (!Number.isFinite(to) || to < 1 || to > list.maxItems) {
|
||
toast(`Pick a place from #1 to #${fmt.format(list.maxItems)}.`, { error: true });
|
||
render();
|
||
return;
|
||
}
|
||
const from = places.rankOf(it.id);
|
||
if (to === from) { render(); return; }
|
||
places.move(from, to);
|
||
render(flash ? it.id : 0);
|
||
send("POST", `/api/items/${it.id}/move`, { rank: to });
|
||
}
|
||
|
||
function insertPlace(at) {
|
||
if (!places.makeRoom(at)) {
|
||
toast(`No room: this list ends at #${fmt.format(list.maxItems)}. Raise its size in List settings.`, { error: true });
|
||
return;
|
||
}
|
||
render();
|
||
send("POST", `/api/lists/${list.id}/places`, { action: "insert", at });
|
||
}
|
||
|
||
function removePlaces(at, count) {
|
||
places.removePlaces(at, count);
|
||
if (addAt >= at) addAt = 0;
|
||
render();
|
||
send("POST", `/api/lists/${list.id}/places`, { action: "remove", at, count });
|
||
}
|
||
|
||
function closeGaps() {
|
||
places.compact();
|
||
addAt = 0;
|
||
render();
|
||
send("POST", `/api/lists/${list.id}/places`, { action: "compact" });
|
||
toast("Gaps closed");
|
||
}
|
||
|
||
async function removeItem(it) {
|
||
const r = places.rankOf(it.id);
|
||
// Only ask when there's something right below that could move up.
|
||
let keep = false;
|
||
if (places.at.has(r + 1)) {
|
||
const choice = await chooseDialog({
|
||
title: `Remove “${it.title}”?`,
|
||
message: `It's #${r}. Should the entries below it move up, or should #${r} stay empty?`,
|
||
choices: [{ label: `Leave #${r} empty`, value: "keep" }, { label: "Move them up", value: "close", primary: true }],
|
||
});
|
||
if (!choice) return;
|
||
keep = choice === "keep";
|
||
} else if (!await confirmDialog({ title: "Remove from list?", message: `“${it.title}” will be removed from #${r}.`, confirm: "Remove", danger: true })) {
|
||
return;
|
||
}
|
||
places.remove(it.id, !keep);
|
||
byId.delete(it.id);
|
||
rowEls.delete(it.id);
|
||
items = items.filter((x) => x.id !== it.id);
|
||
list.itemCount--;
|
||
renderHeader();
|
||
render();
|
||
markResult(it, 0);
|
||
toast(`Removed “${it.title}”`);
|
||
send("DELETE", `/api/items/${it.id}${keep ? "?keep=1" : ""}`);
|
||
}
|
||
|
||
// ---- drawing ----
|
||
|
||
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 itemRow(it) {
|
||
let li = rowEls.get(it.id);
|
||
if (!li) {
|
||
li = buildRow(it);
|
||
rowEls.set(it.id, li);
|
||
}
|
||
li.dataset.place = it.rank;
|
||
const input = li.querySelector(".rank-input");
|
||
if (!input.dataset.dirty) input.value = it.rank; // don't overwrite typing
|
||
li.classList.toggle("is-top", it.rank <= 3);
|
||
return li;
|
||
}
|
||
|
||
function buildRow(it) {
|
||
const input = h("input", {
|
||
class: "rank-input", type: "text", inputmode: "numeric", autocomplete: "off", value: it.rank,
|
||
"aria-label": `Place of ${it.title}`, title: "Type a place and press Enter. ↑/↓ go to the next entry; Alt+↑/↓ moves this one.",
|
||
});
|
||
const commit = () => {
|
||
// A redraw detaching the box fires blur; that isn't the owner leaving it.
|
||
if (rendering) return;
|
||
delete input.dataset.dirty;
|
||
const n = Number(input.value.replace(/[^0-9]/g, ""));
|
||
if (!n || n === it.rank) { input.value = it.rank; return; }
|
||
moveTo(it, n);
|
||
};
|
||
input.addEventListener("input", () => { input.dataset.dirty = "1"; });
|
||
input.addEventListener("focus", () => input.select());
|
||
input.addEventListener("blur", commit);
|
||
input.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
const next = neighbourInput(input, +1);
|
||
commit();
|
||
(next || input).focus();
|
||
} else if (e.key === "Escape") {
|
||
delete input.dataset.dirty;
|
||
input.value = it.rank;
|
||
input.blur();
|
||
} else if ((e.key === "ArrowUp" || e.key === "ArrowDown") && e.altKey) {
|
||
e.preventDefault();
|
||
delete input.dataset.dirty;
|
||
moveTo(it, it.rank + (e.key === "ArrowUp" ? -1 : 1));
|
||
input.focus();
|
||
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||
e.preventDefault();
|
||
neighbourInput(input, e.key === "ArrowUp" ? -1 : 1)?.focus();
|
||
}
|
||
});
|
||
|
||
const sub = [it.subtitle, it.year].filter(Boolean).join(" · ");
|
||
const menu = h("details", { class: "menu row-menu" },
|
||
h("summary", { class: "icon-btn", "aria-label": `More for ${it.title}`, title: "More" }, icon("chevron-down")),
|
||
h("div", { class: "menu-pop" },
|
||
h("button", { type: "button", onclick: () => insertPlace(it.rank) }, icon("plus"), "Insert an empty place above"),
|
||
h("button", { type: "button", onclick: () => moveTo(it, 1) }, icon("chevron-up"), "Move to #1"),
|
||
h("button", { type: "button", onclick: () => moveTo(it, places.last()) }, icon("chevron-down"), "Move to the end"),
|
||
canRefresh(it) && h("button", { type: "button", onclick: (e) => refreshOne(it, e.currentTarget) }, icon("refresh"), `Refresh from ${sourceName(it)}`),
|
||
h("button", { type: "button", class: "is-danger", onclick: () => removeItem(it) }, icon("trash"), "Remove"),
|
||
));
|
||
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 the arrow keys` }, icon("grip")),
|
||
input,
|
||
artBox(it.image ? `/images/${it.image}` : ""),
|
||
h("button", { type: "button", class: "rank-row-body", onclick: () => openItemForm({ item: it }), title: "Edit" },
|
||
h("span", { class: "rank-row-title" }, it.title),
|
||
h("span", { 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", "aria-label": `Edit ${it.title}`, title: "Edit", onclick: () => openItemForm({ item: it }) }, icon("pencil")),
|
||
menu,
|
||
),
|
||
);
|
||
}
|
||
|
||
function neighbourInput(input, dir) {
|
||
const all = [...rankList.querySelectorAll(".rank-input")];
|
||
return all[all.indexOf(input) + dir] || null;
|
||
}
|
||
|
||
// A run of empty places shows as one row.
|
||
function emptyRow(from, to) {
|
||
const n = to - from + 1;
|
||
const label = n === 1 ? `#${fmt.format(from)}` : `#${fmt.format(from)}–${fmt.format(to)}`;
|
||
const targeted = addAt >= from && addAt <= to;
|
||
return h("li", { class: "rank-gap" + (targeted ? " is-target" : ""), "data-place": from, "data-place-end": to },
|
||
h("span", { class: "rank-gap-label" }, h("b", {}, label), n === 1 ? " Empty place" : ` ${fmt.format(n)} empty places`),
|
||
h("span", { class: "rank-gap-actions" },
|
||
h("button", { type: "button", class: "btn btn--sm btn--ghost", onclick: () => setAddAt(targeted ? 0 : from) },
|
||
icon(targeted ? "x" : "plus"), targeted ? "Cancel" : "Add here"),
|
||
h("button", { type: "button", class: "btn btn--sm btn--ghost", title: "Remove these places; everything below moves up", onclick: () => removePlaces(from, n) },
|
||
icon("trash"), n === 1 ? "Remove place" : "Remove places"),
|
||
),
|
||
);
|
||
}
|
||
|
||
function render(flashId) {
|
||
syncRanks();
|
||
const gaps = places.last() - places.at.size;
|
||
rankTools.hidden = items.length === 0;
|
||
gapInfo.textContent = gaps ? `${fmt.format(gaps)} empty ${gaps === 1 ? "place" : "places"}` : "No gaps";
|
||
closeGapsBtn.hidden = gaps === 0;
|
||
renderAddTarget();
|
||
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.`)));
|
||
return;
|
||
}
|
||
const rows = [];
|
||
const ranks = [...places.at.keys()].sort((a, b) => a - b);
|
||
let next = 1;
|
||
for (const r of ranks) {
|
||
if (r > next) rows.push(emptyRow(next, r - 1));
|
||
rows.push(itemRow(byId.get(places.at.get(r))));
|
||
next = r + 1;
|
||
}
|
||
// Re-ordering detaches the rows; keep the cursor where it was.
|
||
const focused = document.activeElement;
|
||
const sel = focused?.classList?.contains("rank-input") ? [focused.selectionStart, focused.selectionEnd] : null;
|
||
rendering = true;
|
||
try {
|
||
rankList.replaceChildren(...rows);
|
||
if (focused && focused !== document.activeElement && rankList.contains(focused)) {
|
||
focused.focus({ preventScroll: true });
|
||
if (sel) focused.setSelectionRange(...sel);
|
||
}
|
||
} finally {
|
||
rendering = false;
|
||
}
|
||
if (flashId) {
|
||
const el = rowEls.get(flashId);
|
||
if (el) {
|
||
el.classList.remove("is-flash");
|
||
void el.offsetWidth; // restart the animation
|
||
el.classList.add("is-flash");
|
||
el.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- the "add here" target ----
|
||
|
||
const addTarget = h("div", { class: "add-target", hidden: true });
|
||
document.querySelector(".editor-search .search-bar").before(addTarget);
|
||
|
||
function setAddAt(place) {
|
||
addAt = place;
|
||
render();
|
||
if (place) searchInput.focus();
|
||
}
|
||
|
||
function renderAddTarget() {
|
||
addTarget.hidden = !addAt;
|
||
if (addAt) {
|
||
addTarget.replaceChildren(icon("plus"), h("span", {}, "Adding at ", h("b", {}, `#${fmt.format(addAt)}`)),
|
||
h("button", { type: "button", class: "icon-btn", "aria-label": "Add after the last entry instead", title: "Cancel", onclick: () => setAddAt(0) }, icon("x")));
|
||
}
|
||
}
|
||
|
||
const nextPlace = () => addAt || places.appendPlace();
|
||
|
||
// After adding into a gap, aim at the next place in it, if it's empty too.
|
||
function advanceAddAt(added) {
|
||
if (!addAt) return;
|
||
addAt = places.at.has(added + 1) || added + 1 > list.maxItems ? 0 : added + 1;
|
||
}
|
||
|
||
// ---- toolbar ----
|
||
|
||
const rankTools = h("div", { class: "rank-tools", hidden: true });
|
||
const gapInfo = h("span", { class: "muted small" });
|
||
const closeGapsBtn = h("button", { type: "button", class: "btn btn--sm btn--ghost", title: "Number the entries 1, 2, 3… keeping their order", onclick: closeGaps }, "Close gaps");
|
||
const jump = h("input", { type: "text", inputmode: "numeric", placeholder: "Go to #", "aria-label": "Go to place", class: "rank-jump" });
|
||
jump.addEventListener("keydown", (e) => {
|
||
if (e.key !== "Enter") return;
|
||
e.preventDefault();
|
||
const n = Number(jump.value.replace(/[^0-9]/g, ""));
|
||
const ranks = [...places.at.keys()].sort((a, b) => a - b);
|
||
const r = ranks.find((x) => x >= n) ?? ranks[ranks.length - 1];
|
||
const el = r && rowEls.get(places.at.get(r));
|
||
if (el) {
|
||
el.scrollIntoView({ block: "center", behavior: "smooth" });
|
||
el.querySelector(".rank-input").focus();
|
||
}
|
||
jump.value = "";
|
||
});
|
||
rankTools.append(gapInfo, closeGapsBtn, h("span", { class: "spacer" }), jump);
|
||
rankList.before(rankTools);
|
||
|
||
// ---- drag and drop ----
|
||
|
||
sortable(rankList, {
|
||
onMove: (el, from, to) => {
|
||
const it = byId.get(Number(el.dataset.id));
|
||
// Where it landed decides the place: moving down, it takes the place of
|
||
// whatever is now right above it; moving up, whatever is right below.
|
||
const near = to > from ? el.previousElementSibling : el.nextElementSibling;
|
||
if (!it || !near) { render(); return; }
|
||
const target = to > from
|
||
? Number(near.dataset.placeEnd || near.dataset.place)
|
||
: Number(near.dataset.place);
|
||
moveTo(it, target);
|
||
},
|
||
});
|
||
|
||
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 loadAll(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) loadAll(); },
|
||
});
|
||
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 loadAll();
|
||
}
|
||
|
||
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: () => loadAll(),
|
||
}));
|
||
|
||
// 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);
|
||
}
|
||
|
||
// ---------- 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 at #${nextPlace()}`,
|
||
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 earlier moves reach the server first, so places line up.
|
||
await queue;
|
||
body = { ...body, rank: addAt };
|
||
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 });
|
||
}
|
||
const it = res.item;
|
||
places.makeRoom(it.rank);
|
||
places.at.set(it.rank, it.id);
|
||
items.push(it);
|
||
byId.set(it.id, it);
|
||
list.itemCount++;
|
||
advanceAddAt(it.rank);
|
||
renderHeader();
|
||
render(it.id);
|
||
toast(`Added “${it.title}” at #${it.rank}`);
|
||
if (res.warning) toast(res.warning, { error: true });
|
||
markResult(it, it.rank);
|
||
return it;
|
||
} 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}` : `#${nextPlace()}`;
|
||
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");
|
||
replaceItem(res.item);
|
||
} 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 #${nextPlace()}`);
|
||
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) {
|
||
const res = await api("PUT", `/api/items/${item.id}`, body);
|
||
dlg.close();
|
||
toast("Saved");
|
||
replaceItem(res.item);
|
||
} 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 {
|
||
const res = await api("POST", `/api/items/${created.id}/image`, fd);
|
||
replaceItem(res.item);
|
||
} 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();
|
||
loadAll();
|