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
@@ -277,6 +277,17 @@ code { font: 0.9em ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; bac
|
||||
.pager-btn.is-current { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
||||
.pager-gap { color: var(--faint); padding: 0 4px; }
|
||||
|
||||
/* ---------- dropdown menu ---------- */
|
||||
.menu { position: relative; }
|
||||
.menu > summary { list-style: none; }
|
||||
.menu > summary::-webkit-details-marker { display: none; }
|
||||
.menu-pop {
|
||||
position: absolute; right: 0; top: calc(100% + 6px); z-index: 30; min-width: 200px; padding: 6px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: calc(var(--radius) + 2px); box-shadow: var(--shadow);
|
||||
}
|
||||
.menu-pop a { display: flex; justify-content: space-between; gap: 12px; padding: 8px 10px; border-radius: 8px; text-decoration: none; font-size: 14px; }
|
||||
.menu-pop a:hover { background: var(--surface-2); }
|
||||
|
||||
/* ---------- empty / error ---------- */
|
||||
.empty { text-align: center; padding-top: 80px; padding-bottom: 40px; display: flex; flex-direction: column; align-items: center; gap: 10px; }
|
||||
.empty-art { display: grid; place-items: center; width: 72px; height: 72px; border-radius: 20px; background: var(--accent-soft); color: var(--accent); margin-bottom: 10px; }
|
||||
@@ -445,6 +456,9 @@ input[type="checkbox"] { accent-color: var(--accent); width: 18px; height: 18px;
|
||||
}
|
||||
.cat-option .icon, .quick-cats .icon { width: 22px; height: 22px; color: var(--accent); }
|
||||
.cat-option span:hover, .quick-cats button:hover { border-color: var(--accent); }
|
||||
.cat-option--wide { grid-column: 1 / -1; }
|
||||
.cat-option--wide span { flex-direction: row; justify-content: center; flex-wrap: wrap; gap: 4px 8px; }
|
||||
.cat-option--wide small { flex-basis: 100%; font-weight: 400; }
|
||||
.cat-option input:checked + span { border-color: var(--accent); background: var(--accent-soft); box-shadow: 0 0 0 1px var(--accent) inset; }
|
||||
.cat-option input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.cat-option input:disabled + span { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<symbol id="music" viewBox="0 0 24 24"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></symbol>
|
||||
<symbol id="gamepad" viewBox="0 0 24 24"><path d="M6 12h4M8 10v4M15 13h.01M18 11h.01"/><rect width="20" height="12" x="2" y="6" rx="4"/></symbol>
|
||||
<symbol id="masks" viewBox="0 0 24 24"><path d="M2 4.5c0-.8.6-1.4 1.4-1.3C5 3.4 7 3.4 8.6 3.1c.8-.1 1.4.5 1.4 1.3V9c0 3.3-1.8 6-4 6s-4-2.7-4-6z"/><path d="M4.5 7.5h.01M7.5 7.5h.01M4.8 11.2c.7-.5 1.7-.5 2.4 0"/><path d="M14 9.6c0-.8.6-1.4 1.4-1.3 1.6.3 3.6.3 5.2 0 .8-.1 1.4.5 1.4 1.3V14c0 3.3-1.8 6-4 6s-4-2.7-4-6z"/><path d="M16.5 12.5h.01M19.5 12.5h.01M16.8 15.6c.7.5 1.7.5 2.4 0"/><path d="M10 12.5c.9.7 2.3.8 4 .3"/></symbol>
|
||||
<symbol id="star" viewBox="0 0 24 24"><path d="M11.5 2.9a.5.5 0 0 1 1 0l2.3 4.7a2 2 0 0 0 1.5 1.1l5.2.8a.5.5 0 0 1 .3.9l-3.8 3.6a2 2 0 0 0-.6 1.8l.9 5.2a.5.5 0 0 1-.8.5l-4.6-2.4a2 2 0 0 0-1.9 0l-4.6 2.4a.5.5 0 0 1-.8-.5l.9-5.2a2 2 0 0 0-.6-1.8L2.3 10.4a.5.5 0 0 1 .3-.9l5.2-.8a2 2 0 0 0 1.5-1.1z"/></symbol>
|
||||
<symbol id="grip" viewBox="0 0 24 24"><circle cx="9" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="18" r="1"/></symbol>
|
||||
<symbol id="pencil" viewBox="0 0 24 24"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></symbol>
|
||||
<symbol id="trash" viewBox="0 0 24 24"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6"/></symbol>
|
||||
@@ -21,6 +22,7 @@
|
||||
<symbol id="rows" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="6" rx="1.5"/><rect x="3" y="14" width="18" height="6" rx="1.5"/></symbol>
|
||||
<symbol id="grid" viewBox="0 0 24 24"><rect width="7" height="7" x="3" y="3" rx="1.5"/><rect width="7" height="7" x="14" y="3" rx="1.5"/><rect width="7" height="7" x="14" y="14" rx="1.5"/><rect width="7" height="7" x="3" y="14" rx="1.5"/></symbol>
|
||||
<symbol id="logout" viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></symbol>
|
||||
<symbol id="download" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></symbol>
|
||||
<symbol id="upload" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/></symbol>
|
||||
<symbol id="image" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/></symbol>
|
||||
<symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7M19 12H5"/></symbol>
|
||||
|
||||
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 6.1 KiB |
@@ -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