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:
cdnutterandClaude Opus 5.5 committed 2026-09-24 16:54:19 -07:00
commit e9a75a968a
53 files changed
+7078

No files matched your search

+110
View File
@@ -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();