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

+35 -6
View File
@@ -11,7 +11,8 @@ function sizeHint(n, limits) {
return { text: `Up to ${fmt.format(limits.max)} allowed; ${limits.recommended} or fewer recommended.`, cls: "" };
}
const autoTitle = (n, cat) => `Top ${n} ${cat.label}`;
// Custom lists have no sensible default name; the owner names them.
const autoTitle = (n, cat) => (cat.key === "custom" ? "" : `Top ${n} ${cat.label}`);
/**
* @param {object} o
@@ -32,18 +33,29 @@ export function openListForm({ categories, limits, list, category, onSaved }) {
let titleTouched = editing && list.title !== autoTitle(list.maxItems, catByKey[list.category]);
const title = h("input", { name: "title", required: true, maxlength: 200, value: list?.title || autoTitle(state.size, catByKey[state.category]) });
title.addEventListener("input", () => { titleTouched = true; });
const syncTitle = () => { if (!titleTouched) title.value = autoTitle(state.size, catByKey[state.category]); };
title.addEventListener("input", () => { titleTouched = title.value !== ""; });
const isCustom = () => state.category === "custom";
const syncTitle = () => {
title.placeholder = isCustom() ? "e.g. Favorite Sports Teams" : "";
if (!titleTouched) title.value = autoTitle(state.size, catByKey[state.category]);
};
const lockedCat = editing && list.itemCount > 0;
const catPicker = h("div", { class: "cat-picker", role: "radiogroup", "aria-label": "Category" },
categories.map((c) => h("label", { class: "cat-option" },
categories.map((c) => h("label", { class: "cat-option" + (c.key === "custom" ? " cat-option--wide" : "") },
h("input", {
type: "radio", name: "category", value: c.key, checked: c.key === state.category,
disabled: lockedCat && c.key !== state.category,
onchange: () => { state.category = c.key; syncTitle(); },
onchange: () => {
state.category = c.key;
syncTitle();
artField.hidden = !isCustom();
if (isCustom() && !title.value) title.focus();
},
}),
h("span", {}, icon(c.icon), c.label),
c.key === "custom"
? h("span", {}, icon(c.icon), h("b", {}, c.label), h("small", { class: "muted" }, "Anything else: sports teams, drinks, restaurants…"))
: h("span", {}, icon(c.icon), c.label),
)),
);
@@ -61,6 +73,7 @@ export function openListForm({ categories, limits, list, category, onSaved }) {
};
sizeInput.addEventListener("input", () => { state.size = Number(sizeInput.value); renderSize(); syncTitle(); });
renderSize();
syncTitle();
const desc = h("textarea", { name: "description", rows: 2, maxlength: 1000, placeholder: "Optional: a line about what makes this list", value: list?.description || "" });
const slug = editing && h("input", { name: "slug", value: list.slug, maxlength: 60, pattern: "[a-z0-9-]+", spellcheck: false });
@@ -69,6 +82,14 @@ export function openListForm({ categories, limits, list, category, onSaved }) {
h("label", {}, h("input", { type: "radio", name: "layout", value: "grid", checked: layout === "grid" }), icon("grid"), "Artwork grid"),
h("label", {}, h("input", { type: "radio", name: "layout", value: "rows", checked: layout === "rows" }), icon("rows"), "Ranked list"),
);
// Custom lists choose their artwork shape; built-in categories have one.
const art = list?.art || "square";
const artField = h("div", { class: "field", hidden: !isCustom() },
h("span", { class: "field-label" }, "Artwork shape"),
h("div", { class: "radio-cards" },
h("label", {}, h("input", { type: "radio", name: "art", value: "square", checked: art === "square" }), "Square (logos, covers)"),
h("label", {}, h("input", { type: "radio", name: "art", value: "poster", checked: art === "poster" }), "Tall (posters, photos)"),
));
const visible = h("input", { type: "checkbox", name: "visible", checked: list ? list.visible : true });
const error = h("p", { class: "alert alert--error", role: "alert", hidden: true });
const save = h("button", { type: "submit", class: "btn btn--primary" }, editing ? "Save changes" : "Create list");
@@ -86,6 +107,7 @@ export function openListForm({ categories, limits, list, category, onSaved }) {
editing && h("label", { class: "field" }, h("span", { class: "field-label" }, "Address"),
h("div", { class: "input-group", style: { alignItems: "center" } }, h("span", { class: "muted" }, "/l/"), slug)),
h("div", { class: "field" }, h("span", { class: "field-label" }, "Layout"), layoutGroup),
artField,
h("label", { class: "check" }, visible, "Show this list on my public page"),
error,
),
@@ -98,6 +120,12 @@ export function openListForm({ categories, limits, list, category, onSaved }) {
form.addEventListener("submit", async (e) => {
e.preventDefault();
error.hidden = true;
if (!title.value.trim()) {
error.hidden = false;
error.textContent = "Give your list a name.";
title.focus();
return;
}
const n = Number(sizeInput.value);
if (!Number.isInteger(n) || n < 1 || n > limits.max) {
error.hidden = false;
@@ -111,6 +139,7 @@ export function openListForm({ categories, limits, list, category, onSaved }) {
description: desc.value,
layout: form.querySelector("input[name=layout]:checked").value,
visible: visible.checked,
art: isCustom() ? form.querySelector("input[name=art]:checked").value : "",
};
if (editing) body.slug = slug.value;
busy(save, true);