Add titles-only list export and Custom lists

Export
- GET /l/<list>/export?format=csv|txt downloads a list's titles in rank
  order, one per line, with no header or other fields (CSV gets a UTF-8
  BOM for Excel)
- Export menu in the list editor and, for the owner, on the public list
  page; hidden lists stay owner-only

Custom lists
- New "Custom" category for anything not built in (sports teams,
  drinks…): owner-named, slug from the title, entries added by hand or
  from an unfiltered Wikipedia search
- Per-list artwork shape (square or tall), stored in a new lists.art
  column (migration 2)
- Art can be uploaded while adding an entry, not only after

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-24 17:18:24 -07:00
1 parent e9a75a968a
commit 58a29b7339
23 files changed
+314 -37

No files matched your search

+30 -5
View File
@@ -42,6 +42,8 @@ function renderHeader() {
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()}`;
@@ -53,6 +55,8 @@ document.querySelector("[data-action=edit-list]").addEventListener("click", () =
openListForm({
categories, limits, 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);
@@ -127,7 +131,9 @@ 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.`)));
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));
}
@@ -359,8 +365,11 @@ function openItemForm({ item, result }) {
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 });
@@ -369,7 +378,11 @@ function openItemForm({ item, result }) {
const file = fileInput.files[0];
if (!file) return;
if (!editing) {
toast("Save the entry first, then upload art for it.", { error: true });
pendingFile = file;
clearImage = false;
imageUrl.value = "";
preview.replaceChildren(artBox(URL.createObjectURL(file)));
fileInput.value = "";
return;
}
const fd = new FormData();
@@ -392,12 +405,14 @@ function openItemForm({ item, result }) {
});
const removeArt = h("button", {
type: "button", class: "btn btn--sm btn--ghost",
onclick: () => { clearImage = true; imageUrl.value = ""; preview.replaceChildren(artBox("")); },
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…)."
: "Art is saved to your server, so it keeps working if the source changes.";
: 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}`);
@@ -413,7 +428,7 @@ function openItemForm({ item, result }) {
h("div", { class: "art-edit-actions" },
h("span", { class: "field-label" }, "Artwork"),
imageUrl,
h("div", { class: "row-btns" }, editing && uploadBtn, removeArt, fileInput),
h("div", { class: "row-btns" }, uploadBtn, removeArt, fileInput),
h("span", { class: "field-hint" }, artHint),
),
),
@@ -462,6 +477,16 @@ function openItemForm({ item, result }) {
provider: result?.provider || "", providerId: result?.providerId || "", details: !!result,
});
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) {