// 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; }