- 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>
111 lines
4.4 KiB
JavaScript
111 lines
4.4 KiB
JavaScript
// Admin dashboard: all lists, their order, create/edit/delete.
|
|
import { h, icon, api, readBoot, toast, confirmDialog, fmt } from "./common.js";
|
|
import { sortable } from "./sortable.js";
|
|
import { openListForm } from "./list-form.js";
|
|
|
|
const boot = readBoot();
|
|
const { categories, limits } = boot;
|
|
const covers = boot.covers || {};
|
|
const catByKey = Object.fromEntries(categories.map((c) => [c.key, c]));
|
|
let lists = boot.lists;
|
|
|
|
const root = document.getElementById("lists");
|
|
|
|
function newList(category) {
|
|
openListForm({
|
|
categories, limits, styles: boot.styles, category,
|
|
onSaved: (l) => { location.href = `/admin/lists/${l.id}`; },
|
|
});
|
|
}
|
|
|
|
function editList(list) {
|
|
openListForm({
|
|
categories, limits, styles: boot.styles, list,
|
|
onSaved: (saved) => {
|
|
lists = lists.map((l) => (l.id === saved.id ? saved : l));
|
|
render();
|
|
toast("List saved");
|
|
},
|
|
});
|
|
}
|
|
|
|
async function deleteList(list) {
|
|
const entries = list.itemCount ? ` Its ${fmt.format(list.itemCount)} ${list.itemCount === 1 ? "entry" : "entries"} will be removed.` : "";
|
|
const ok = await confirmDialog({
|
|
title: "Delete list?",
|
|
message: `“${list.title}” will be deleted.${entries} This can't be undone.`,
|
|
confirm: "Delete list",
|
|
danger: true,
|
|
});
|
|
if (!ok) return;
|
|
try {
|
|
await api("DELETE", `/api/lists/${list.id}`);
|
|
lists = lists.filter((l) => l.id !== list.id);
|
|
render();
|
|
toast("List deleted");
|
|
} catch (err) {
|
|
toast(err.message, { error: true });
|
|
}
|
|
}
|
|
|
|
function card(list) {
|
|
const cat = catByKey[list.category];
|
|
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" + ((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 },
|
|
h("button", { type: "button", class: "drag-handle", "aria-label": `Reorder ${list.title}. Use arrow keys to move.` }, icon("grip")),
|
|
h("a", { class: "list-card-main", href: `/admin/lists/${list.id}` },
|
|
art,
|
|
h("span", { style: { minWidth: 0 } },
|
|
h("span", { class: "list-card-title" }, list.title),
|
|
h("span", { class: "list-card-meta" },
|
|
h("span", {}, cat.label),
|
|
h("span", { "aria-hidden": "true" }, "·"),
|
|
h("span", {}, `${fmt.format(list.itemCount)} / ${fmt.format(list.maxItems)}`),
|
|
h("span", { class: "list-card-bar", "aria-hidden": "true" }, h("span", { style: { width: pct + "%" } })),
|
|
!list.visible && h("span", { class: "badge badge--muted" }, icon("eye-off"), "Hidden"),
|
|
),
|
|
),
|
|
),
|
|
h("div", { class: "list-card-actions" },
|
|
h("a", { class: "icon-btn", href: `/l/${list.slug}`, title: "View public page", "aria-label": "View public page" }, icon("external")),
|
|
h("button", { type: "button", class: "icon-btn", title: "List settings", "aria-label": "List settings", onclick: () => editList(list) }, icon("settings")),
|
|
h("button", { type: "button", class: "icon-btn icon-btn--danger", title: "Delete", "aria-label": "Delete list", onclick: () => deleteList(list) }, icon("trash")),
|
|
),
|
|
);
|
|
}
|
|
|
|
function render() {
|
|
if (!lists.length) {
|
|
root.replaceChildren(h("div", { class: "onboard" },
|
|
h("div", { class: "empty-art", style: { margin: "0 auto 16px" } }, icon("deck")),
|
|
h("h2", {}, "Start your first list"),
|
|
h("p", {}, "Pick a category. You'll choose how many spots it has next."),
|
|
h("div", { class: "quick-cats" },
|
|
categories.map((c) => h("button", { type: "button", onclick: () => newList(c.key) }, icon(c.icon), c.label))),
|
|
));
|
|
return;
|
|
}
|
|
const ul = h("ul", { class: "admin-lists", style: { listStyle: "none", margin: 0, padding: 0 } }, lists.map(card));
|
|
sortable(ul, {
|
|
onMove: async () => {
|
|
const ids = [...ul.children].map((li) => Number(li.dataset.id));
|
|
lists = ids.map((id) => lists.find((l) => l.id === id));
|
|
try {
|
|
await api("POST", "/api/lists/order", { ids });
|
|
toast("Order saved");
|
|
} catch (err) {
|
|
toast(err.message, { error: true });
|
|
}
|
|
},
|
|
});
|
|
root.replaceChildren(ul);
|
|
}
|
|
|
|
document.querySelector("[data-action=new-list]").addEventListener("click", () => newList());
|
|
render();
|