Initial TopDeck: self-hosted ranked favorites
A single Go binary with SQLite that serves a public page of the owner's ranked lists (movies, TV, albums, songs, video games, theater) and a login-gated editor. - Lists of 1–65,535 entries (100 recommended), several per category - Search-to-add via TMDB, TheTVDB, IGDB, Deezer/MusicBrainz, Wikipedia; manual entry as a fallback - Theater entries carry a production (Broadway, tour, …), venue and cast - Artwork cached locally; URL override and uploads - Drag, arrow and type-a-rank reordering; paginated for long lists - 12 theme presets plus custom CSS - First-run setup code or env-provided admin; bcrypt, rate-limited login, CSRF-guarded JSON API - Dockerfile and docker-compose.yml Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e9a75a968a
53 files changed
+7078
No files matched your search
@@ -0,0 +1,110 @@
|
||||
// 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, category,
|
||||
onSaved: (l) => { location.href = `/admin/lists/${l.id}`; },
|
||||
});
|
||||
}
|
||||
|
||||
function editList(list) {
|
||||
openListForm({
|
||||
categories, limits, 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" + (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();
|
||||
Reference in new issue
Block a user