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) {
|
||||
|
||||
Reference in new issue
Block a user