// 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 →
  • , 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 number 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(`There's no room left. This list stops at #${fmt.format(list.maxItems)}; you can make it bigger 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 at #${r}. Want everything below it to move up, or keep #${r} open?`, choices: [{ label: `Keep #${r} open`, 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": `Spot for ${it.title}`, title: "Type a number and hit Enter. Alt + arrow keys nudge it up or down.", }); 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"), "Open up a spot 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 ? " Open spot" : ` ${fmt.format(n)} open spots`), 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: "Close this up; everything below moves up", onclick: () => removePlaces(from, n) }, icon("trash"), "Close up"), ), ); } function render(flashId) { syncRanks(); const gaps = places.last() - places.at.size; rankTools.hidden = items.length === 0; gapInfo.textContent = gaps ? `${fmt.format(gaps)} open ${gaps === 1 ? "spot" : "spots"}` : "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: "Renumber everything 1, 2, 3… in the same 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 at ${fmt.format(list.maxItems)}. Make it bigger 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();