// 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: "" }; } const autoTitle = (n, cat) => `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, 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 = true; }); const syncTitle = () => { 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" }, 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(); }, }), 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(); 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 || "grid"; const layoutGroup = h("div", { class: "radio-cards" }, 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"), ); 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" }, "Layout"), layoutGroup), 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; 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, }; 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; }