Files
canon/web/static/js/admin-lists.js
T
cdnutterandClaude Opus 5.5 ddf2157e63 Add list styles: Podium, Spotlight, Grid, List
- 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>
2026-09-25 11:18:44 -07:00

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();