// List editor: search providers, add entries, rank and edit them. import { h, icon, api, readBoot, toast, confirmDialog, openDialog, dialogHead, debounce, busy, fmt } from "./common.js"; import { sortable } from "./sortable.js"; import { openListForm } from "./list-form.js"; 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=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, list, onSaved: (saved) => { 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" }, h("button", { type: "button", class: "icon-btn hide-sm", "aria-label": "Move up", title: "Move up", disabled: it.rank === 1, onclick: () => moveTo(it, it.rank - 1) }, icon("chevron-up")), h("button", { type: "button", class: "icon-btn hide-sm", "aria-label": "Move down", title: "Move down", disabled: it.rank === list.itemCount, onclick: () => moveTo(it, it.rank + 1) }, icon("chevron-down")), h("button", { type: "button", class: "icon-btn", "aria-label": `Edit ${it.title}`, title: "Edit", onclick: () => openItemForm({ item: it }) }, icon("pencil")), h("button", { type: "button", class: "icon-btn icon-btn--danger", "aria-label": `Remove ${it.title}`, title: "Remove", onclick: () => removeItem(it) }, icon("trash")), ), ); } 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", {}, `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 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"); return h("div", { class: "result", "data-key": resultKey(r) }, artBox(r.imageUrl, ""), h("div", { style: { minWidth: 0 } }, h("div", { class: "result-title" }, r.title), sub && h("div", { class: "result-sub" }, sub), r.overview && h("div", { class: "result-overview" }, r.overview), ), 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 || {}) }, }; } 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 note = h("textarea", { rows: 3, maxlength: 10000, value: item?.note || "", placeholder: "Why it's here. Shown to visitors." }); 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 || ""), }); imageUrl.addEventListener("change", () => { clearImage = false; 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) { toast("Save the entry first, then upload art for it.", { error: true }); 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; 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…)." : "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" }, editing && 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("Your note", note), 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, externalUrl: link.value.trim(), extra: newExtra, imageUrl: clearImage ? "" : imageUrl.value.trim(), clearImage, }; busy(save, true); try { if (editing) { await api("PUT", `/api/items/${item.id}`, body); dlg.close(); toast("Saved"); await load(page, item.id); } else { Object.assign(body, { provider: result?.provider || "", providerId: result?.providerId || "", details: !!result, }); const created = await createItem(body); if (created) dlg.close(); } } catch (err) { error.hidden = false; error.textContent = err.message; } finally { busy(save, false); } }); dlg = openDialog(form); (production || title).focus(); } renderHeader(); load(1);