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:
1 parent
e9a75a968a
commit
58a29b7339
23 files changed
+314
-37
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -53,7 +53,7 @@ function card(list) {
|
||||
const pct = Math.min(100, Math.round((list.itemCount / list.maxItems) * 100));
|
||||
const imgs = covers[list.id] || [];
|
||||
const art = imgs.length
|
||||
? h("span", { class: "list-card-covers" + (cat.aspect === "square" ? " is-square" : "") },
|
||||
? h("span", { class: "list-card-covers" + ((list.art || cat.aspect) === "square" ? " is-square" : "") },
|
||||
imgs.map((src) => h("span", { style: { backgroundImage: `url("${src}")` } })))
|
||||
: h("span", { class: "shelf-icon" }, icon(cat.icon));
|
||||
return h("li", { class: "list-card", "data-id": list.id },
|
||||
|
||||
@@ -306,8 +306,8 @@ function integrationsSection() {
|
||||
ready: () => true,
|
||||
}),
|
||||
integration({
|
||||
title: "Theater · Wikipedia", iconName: "masks", category: "theater",
|
||||
desc: "No key needed. Finds musicals, plays and operas on Wikipedia. You choose the production (Broadway, a tour…) and can swap in its own poster.",
|
||||
title: "Theater & Custom · Wikipedia", iconName: "masks", category: "theater",
|
||||
desc: "No key needed. Theater lists search musicals, plays and operas; you choose the production (Broadway, a tour…) and can swap in its own poster. Custom lists can search all of Wikipedia for a name and picture, or skip search and add entries by hand.",
|
||||
ready: () => true,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -158,3 +158,10 @@ export function storageGet(key) {
|
||||
export function storageSet(key, val) {
|
||||
try { localStorage.setItem(key, val); } catch { /* private mode */ }
|
||||
}
|
||||
|
||||
// Close <details class="menu"> dropdowns when clicking elsewhere or picking an item.
|
||||
document.addEventListener("click", (e) => {
|
||||
for (const d of document.querySelectorAll("details.menu[open]")) {
|
||||
if (!d.contains(e.target) || e.target.closest(".menu-pop a")) d.open = false;
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user