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,93 @@
|
||||
// Public pages: item detail dialog and grid/list toggle.
|
||||
import { h, icon, openDialog, storageGet, storageSet } from "./common.js";
|
||||
|
||||
// Each ranked list on the page ships its items as JSON next to the markup.
|
||||
const lists = [...document.querySelectorAll("[data-items]")].map((ol) => {
|
||||
const data = JSON.parse(ol.nextElementSibling?.textContent || "[]");
|
||||
return { ol, items: data, title: ol.dataset.listTitle, square: ol.classList.contains("aspect-square") };
|
||||
});
|
||||
|
||||
function findItem(id) {
|
||||
for (const list of lists) {
|
||||
const idx = list.items.findIndex((it) => it.id === id);
|
||||
if (idx >= 0) return { list, idx };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let current = null;
|
||||
|
||||
function renderDialog(list, idx) {
|
||||
const it = list.items[idx];
|
||||
const art = it.image
|
||||
? h("img", { src: it.image, alt: `Artwork for ${it.title}` })
|
||||
: h("span", { class: "art-fallback" }, icon("image"));
|
||||
const sub = [it.subtitle, it.year].filter(Boolean).join(" · ");
|
||||
const topClass = it.rank <= 3 ? ` top${it.rank}` : "";
|
||||
const meta = it.meta.filter((m) => m.label !== "Production");
|
||||
|
||||
const prev = h("button", { type: "button", class: "icon-btn", "aria-label": "Previous", disabled: idx === 0, onclick: () => show(list, idx - 1) }, icon("chevron-left"));
|
||||
const next = h("button", { type: "button", class: "icon-btn", "aria-label": "Next", disabled: idx === list.items.length - 1, onclick: () => show(list, idx + 1) }, icon("chevron-right"));
|
||||
|
||||
return h("div", { class: "item-dialog-inner" },
|
||||
h("div", { class: "dlg-art" }, art),
|
||||
h("div", { class: "dlg-body" },
|
||||
h("button", { type: "button", class: "icon-btn dlg-close", "aria-label": "Close", onclick: () => current?.close() }, icon("x")),
|
||||
h("p", { class: "dlg-rank" + topClass }, h("strong", {}, `#${it.rank}`), h("span", {}, list.title)),
|
||||
h("h2", { class: "dlg-title" }, it.title),
|
||||
sub && h("p", { class: "dlg-sub" }, sub),
|
||||
it.badge && h("span", { class: "badge" }, it.badge),
|
||||
meta.length > 0 && h("dl", { class: "dlg-meta" }, meta.map((m) => [h("dt", {}, m.label), h("dd", {}, m.value)])),
|
||||
it.note && h("blockquote", { class: "dlg-note" }, it.note),
|
||||
h("div", { class: "dlg-actions" },
|
||||
it.link && h("a", { class: "btn btn--sm", href: it.link, target: "_blank", rel: "noopener noreferrer" },
|
||||
icon("external"), it.source ? `View on ${it.source}` : "Learn more"),
|
||||
h("div", { class: "dlg-nav" }, prev, next),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function show(list, idx) {
|
||||
const content = renderDialog(list, idx);
|
||||
if (current) {
|
||||
current.replaceChildren(content);
|
||||
} else {
|
||||
current = openDialog(content, {
|
||||
className: "item-dialog" + (list.square ? " is-square" : ""),
|
||||
onClose: () => { current = null; },
|
||||
});
|
||||
current.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowLeft") current.querySelector(".dlg-nav button:first-child")?.click();
|
||||
if (e.key === "ArrowRight") current.querySelector(".dlg-nav button:last-child")?.click();
|
||||
});
|
||||
}
|
||||
current.setAttribute("aria-label", `#${list.items[idx].rank} ${list.items[idx].title}`);
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-item]");
|
||||
if (!btn) return;
|
||||
const found = findItem(Number(btn.dataset.item));
|
||||
if (found) show(found.list, found.idx);
|
||||
});
|
||||
|
||||
// Grid / list toggle on list pages, remembered per list in this browser.
|
||||
const toggle = document.querySelector("[data-layout-toggle]");
|
||||
if (toggle) {
|
||||
const key = "topdeck:layout:" + toggle.dataset.key;
|
||||
const apply = (layout) => {
|
||||
for (const { ol } of lists) {
|
||||
ol.classList.toggle("items--grid", layout === "grid");
|
||||
ol.classList.toggle("items--rows", layout === "rows");
|
||||
}
|
||||
for (const b of toggle.querySelectorAll("button")) b.setAttribute("aria-pressed", String(b.dataset.layout === layout));
|
||||
};
|
||||
apply(storageGet(key) || toggle.dataset.default);
|
||||
toggle.addEventListener("click", (e) => {
|
||||
const b = e.target.closest("button[data-layout]");
|
||||
if (!b) return;
|
||||
apply(b.dataset.layout);
|
||||
storageSet(key, b.dataset.layout);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user