- Each list picks a style in List settings (new lists default to Podium); existing lists keep Grid or List - Podium: top 3 on medal-colored steps (#1 center and raised), the rest behind a native <details> "Show N more"; stacks #1 over #2/#3 on phones - Spotlight: #1 as a feature card (art, why-note or description, play button), the rest as a numbered list - Visitors can switch styles on a list page (?style=), preserved across pages; later pages fall back to a plain list since they don't start at #1 - Entries' detail data now lives on a wrapper per list, so featured and collapsed entries all open the detail view Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
166 lines
8.1 KiB
JavaScript
166 lines
8.1 KiB
JavaScript
// Create / edit list dialog, shared by the dashboard and the list editor.
|
|
import { h, icon, api, openDialog, dialogHead, busy, fmt } from "./common.js";
|
|
|
|
const SIZE_PRESETS = [5, 10, 25, 50, 100];
|
|
|
|
function sizeHint(n, limits) {
|
|
if (!Number.isInteger(n) || n < 1) return { text: "Pick a size of at least 1.", cls: "is-error" };
|
|
if (n > limits.max) return { text: `The most a list can hold is ${fmt.format(limits.max)}.`, cls: "is-error" };
|
|
if (n > limits.warn) return { text: `That's a lot. Lists this long work, but they're hard to browse and rank. ${limits.recommended} or fewer is recommended.`, cls: "is-warn" };
|
|
if (n > limits.recommended) return { text: `Heads up: ${limits.recommended} or fewer is recommended so visitors can take it all in.`, cls: "is-warn" };
|
|
return { text: `Up to ${fmt.format(limits.max)} allowed; ${limits.recommended} or fewer recommended.`, cls: "" };
|
|
}
|
|
|
|
// 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
|
|
* @param {object[]} o.categories
|
|
* @param {object} o.limits
|
|
* @param {object} [o.list] existing list to edit
|
|
* @param {string} [o.category] preselected category for a new list
|
|
* @param {(list: object) => void} o.onSaved
|
|
*/
|
|
export function openListForm({ categories, limits, styles, list, category, onSaved }) {
|
|
const editing = !!list;
|
|
const catByKey = Object.fromEntries(categories.map((c) => [c.key, c]));
|
|
const state = {
|
|
category: list?.category || category || categories[0].key,
|
|
size: list?.maxItems || 10,
|
|
};
|
|
// Keep the title in sync with size/category until the owner types their own.
|
|
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 = 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" + (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();
|
|
artField.hidden = !isCustom();
|
|
if (isCustom() && !title.value) title.focus();
|
|
},
|
|
}),
|
|
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),
|
|
)),
|
|
);
|
|
|
|
const sizeInput = h("input", { type: "number", min: 1, max: limits.max, step: 1, value: state.size, inputmode: "numeric", style: { maxWidth: "140px" } });
|
|
const hint = h("span", { class: "field-hint" });
|
|
const chips = h("div", { class: "size-chips" });
|
|
const renderSize = () => {
|
|
const { text, cls } = sizeHint(state.size, limits);
|
|
hint.textContent = text;
|
|
hint.className = "field-hint " + cls;
|
|
chips.replaceChildren(...SIZE_PRESETS.map((n) => h("button", {
|
|
type: "button", "aria-pressed": String(n === state.size),
|
|
onclick: () => { state.size = n; sizeInput.value = n; renderSize(); syncTitle(); },
|
|
}, `Top ${n}`)));
|
|
};
|
|
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 });
|
|
const layout = list?.layout || styles[0].key;
|
|
const layoutGroup = h("div", { class: "style-picker", role: "radiogroup", "aria-label": "Style" },
|
|
styles.map((st) => h("label", { class: "style-option" },
|
|
h("input", { type: "radio", name: "layout", value: st.key, checked: layout === st.key }),
|
|
h("span", {}, icon(st.icon), h("b", {}, st.label), h("small", {}, st.description)),
|
|
)));
|
|
// 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");
|
|
|
|
let dlg;
|
|
const form = h("form", { novalidate: true },
|
|
dialogHead(editing ? "List settings" : "New list", () => dlg),
|
|
h("div", { class: "dialog-body stack" },
|
|
h("div", { class: "field" }, h("span", { class: "field-label" }, "Category"), catPicker,
|
|
lockedCat && h("span", { class: "field-hint" }, "The category can't change once a list has entries.")),
|
|
h("div", { class: "field" }, h("span", { class: "field-label" }, "How many?"),
|
|
h("div", { class: "input-group", style: { alignItems: "center", flexWrap: "wrap" } }, chips, sizeInput), hint),
|
|
h("label", { class: "field" }, h("span", { class: "field-label" }, "Title"), title),
|
|
h("label", { class: "field" }, h("span", { class: "field-label" }, "Description"), desc),
|
|
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" }, "Style"), layoutGroup,
|
|
h("span", { class: "field-hint" }, "How the list looks on your page. Visitors can switch styles on the list's own page.")),
|
|
artField,
|
|
h("label", { class: "check" }, visible, "Show this list on my public page"),
|
|
error,
|
|
),
|
|
h("div", { class: "dialog-foot" },
|
|
h("button", { type: "button", class: "btn", onclick: () => dlg.close() }, "Cancel"),
|
|
save,
|
|
),
|
|
);
|
|
|
|
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;
|
|
error.textContent = sizeHint(n, limits).text;
|
|
return;
|
|
}
|
|
const body = {
|
|
title: title.value,
|
|
category: state.category,
|
|
maxItems: n,
|
|
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);
|
|
try {
|
|
const saved = editing
|
|
? await api("PUT", `/api/lists/${list.id}`, body)
|
|
: await api("POST", "/api/lists", body);
|
|
dlg.close();
|
|
onSaved(saved);
|
|
} catch (err) {
|
|
error.hidden = false;
|
|
error.textContent = err.message;
|
|
} finally {
|
|
busy(save, false);
|
|
}
|
|
});
|
|
|
|
dlg = openDialog(form);
|
|
(editing ? title : catPicker.querySelector("input:checked"))?.focus();
|
|
return dlg;
|
|
}
|