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

+480
View File
@@ -0,0 +1,480 @@
// List editor: search providers, add entries, rank and edit them.
import { h, icon, api, readBoot, toast, confirmDialog, openDialog, dialogHead, debounce, busy, fmt } from "./common.js";
import { sortable } from "./sortable.js";
import { openListForm } from "./list-form.js";
const boot = readBoot();
const { categories, limits, productions } = boot;
const cat = boot.category;
const pageSize = boot.pageSize;
let list = boot.list;
let items = [];
let page = 1;
const square = cat.aspect === "square";
const $ = (sel) => document.querySelector(sel);
const rankList = $("[data-rank-list]");
const pager = $("[data-pager]");
const results = $("[data-results]");
const searchForm = $("[data-search]");
const searchInput = searchForm.querySelector("input");
const spinner = searchForm.querySelector(".spinner");
if (square) {
rankList.classList.add("is-square");
results.classList.add("is-square");
}
// Category-specific extra fields, stored in item.extra.
const EXTRA_FIELDS = {
movie: [["genres", "Genres"], ["runtime", "Runtime"]],
tv: [["genres", "Genres"]],
album: [["type", "Type (EP, Live…)"], ["label", "Label"], ["genres", "Genres"]],
song: [["album", "Album"], ["length", "Length"]],
game: [["platforms", "Platforms"], ["genres", "Genres"]],
theater: [["venue", "Venue / Theatre"], ["cast", "Notable cast"]],
};
// ---------- header ----------
function renderHeader() {
document.querySelector("[data-bind=title]").textContent = list.title;
document.querySelector("[data-bind=count]").textContent = fmt.format(list.itemCount);
document.querySelector("[data-bind=max]").textContent = fmt.format(list.maxItems);
document.querySelector("[data-bind=hidden]").textContent = list.visible ? "" : " · Hidden";
document.querySelector("[data-bind=public]").href = `/l/${list.slug}`;
document.querySelector("[data-bind=meter]").style.width = Math.min(100, (list.itemCount / list.maxItems) * 100) + "%";
document.querySelector("[data-bind=provider]").textContent = boot.provider ? `via ${boot.provider}` : "";
document.title = `${list.title} · ${document.title.split(" · ").pop()}`;
}
const isFull = () => list.itemCount >= list.maxItems;
document.querySelector("[data-action=edit-list]").addEventListener("click", () => {
openListForm({
categories, limits, list,
onSaved: (saved) => {
list = saved;
renderHeader();
renderResults(lastResults);
toast("List saved");
},
});
});
// ---------- ranked items ----------
const pages = () => Math.max(1, Math.ceil(list.itemCount / pageSize));
async function load(p = page, flashId) {
page = Math.min(Math.max(1, p), pages());
try {
items = await api("GET", `/api/lists/${list.id}/items?offset=${(page - 1) * pageSize}&limit=${pageSize}`);
} catch (err) {
toast(err.message, { error: true });
return;
}
renderItems(flashId);
}
function artBox(src, alt = "") {
const box = h("span", { class: "result-art" });
if (src) {
const img = h("img", { src, alt, loading: "lazy", referrerpolicy: "no-referrer" });
img.addEventListener("error", () => img.replaceWith(icon(cat.icon)), { once: true });
box.append(img);
} else {
box.append(icon(cat.icon));
}
return box;
}
function row(it) {
const rankInput = h("input", {
class: "rank-input", type: "number", min: 1, max: list.itemCount, value: it.rank,
"aria-label": `Rank of ${it.title}`, inputmode: "numeric",
});
const commit = () => {
const n = Number(rankInput.value);
if (!Number.isInteger(n) || n === it.rank) { rankInput.value = it.rank; return; }
moveTo(it, Math.min(Math.max(1, n), list.itemCount));
};
rankInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); rankInput.blur(); } });
rankInput.addEventListener("change", commit);
const sub = [it.subtitle, it.year].filter(Boolean).join(" · ");
return h("li", { class: "rank-row", "data-id": it.id },
h("button", { type: "button", class: "drag-handle", "aria-label": `Drag to reorder ${it.title}, or use arrow keys` }, icon("grip")),
rankInput,
artBox(it.image ? `/images/${it.image}` : ""),
h("div", { class: "rank-row-body" },
h("div", { class: "rank-row-title" }, it.title),
h("div", { class: "rank-row-sub" },
it.extra?.production && h("span", { class: "badge" }, it.extra.production),
sub && h("span", {}, sub),
it.note && h("span", { title: "Has a note" }, icon("pencil")),
),
),
h("div", { class: "rank-row-actions" },
h("button", { type: "button", class: "icon-btn hide-sm", "aria-label": "Move up", title: "Move up", disabled: it.rank === 1, onclick: () => moveTo(it, it.rank - 1) }, icon("chevron-up")),
h("button", { type: "button", class: "icon-btn hide-sm", "aria-label": "Move down", title: "Move down", disabled: it.rank === list.itemCount, onclick: () => moveTo(it, it.rank + 1) }, icon("chevron-down")),
h("button", { type: "button", class: "icon-btn", "aria-label": `Edit ${it.title}`, title: "Edit", onclick: () => openItemForm({ item: it }) }, icon("pencil")),
h("button", { type: "button", class: "icon-btn icon-btn--danger", "aria-label": `Remove ${it.title}`, title: "Remove", onclick: () => removeItem(it) }, icon("trash")),
),
);
}
function renderItems(flashId) {
if (!items.length) {
rankList.replaceChildren(h("li", { class: "rank-empty" },
h("div", { class: "empty-art", style: { margin: "0 auto 12px" } }, icon(cat.icon)),
h("p", {}, `No ${cat.label.toLowerCase()} yet. Search on the left to add your first.`)));
} else {
rankList.replaceChildren(...items.map(row));
}
if (flashId) {
const el = rankList.querySelector(`[data-id="${flashId}"]`);
if (el) {
el.classList.add("is-flash");
el.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}
renderPager();
}
function renderPager() {
const total = pages();
if (total <= 1) { pager.replaceChildren(); return; }
const btn = (p, label = String(p), aria) => h("button", {
type: "button", class: "pager-btn" + (p === page ? " is-current" : ""), "aria-label": aria,
"aria-current": p === page ? "page" : null, onclick: () => load(p),
}, label);
const nums = [];
let last = 0;
for (let i = 1; i <= total; i++) {
if (i === 1 || i === total || Math.abs(i - page) <= 2) {
if (last && i - last > 1) nums.push(h("span", { class: "pager-gap" }, "…"));
nums.push(btn(i, `${fmt.format((i - 1) * pageSize + 1)}–${fmt.format(Math.min(i * pageSize, list.itemCount))}`));
last = i;
}
}
pager.replaceChildren(...nums);
}
async function moveTo(it, rank) {
try {
await api("POST", `/api/items/${it.id}/move`, { rank });
} catch (err) {
toast(err.message, { error: true });
}
const targetPage = Math.ceil(rank / pageSize);
await load(targetPage, it.id);
}
sortable(rankList, {
onMove: async (el, from, to) => {
const id = Number(el.dataset.id);
const it = items.find((x) => x.id === id);
const rank = (page - 1) * pageSize + to + 1;
// Renumber right away so the UI feels instant; the reload confirms.
[...rankList.children].forEach((li, i) => {
const input = li.querySelector(".rank-input");
if (input) input.value = (page - 1) * pageSize + i + 1;
});
try {
await api("POST", `/api/items/${id}/move`, { rank });
} catch (err) {
toast(err.message, { error: true });
}
await load(page, it?.id);
},
});
async function removeItem(it) {
const ok = await confirmDialog({
title: "Remove from list?",
message: `“${it.title}” will be removed from #${it.rank}, and everything below moves up one spot.`,
confirm: "Remove",
danger: true,
});
if (!ok) return;
try {
await api("DELETE", `/api/items/${it.id}`);
list.itemCount--;
renderHeader();
toast(`Removed “${it.title}”`);
await load();
markResult(it, 0);
} catch (err) {
toast(err.message, { error: true });
}
}
// ---------- search ----------
let lastResults = null;
let searchSeq = 0;
async function search(q) {
q = q.trim();
if (!q) { renderResults(null); return; }
const seq = ++searchSeq;
spinner.hidden = false;
try {
const data = await api("GET", `/api/search?category=${cat.key}&list=${list.id}&q=${encodeURIComponent(q)}`);
if (seq !== searchSeq) return;
renderResults(data.results);
} catch (err) {
if (seq !== searchSeq) return;
lastResults = null;
const needsSetup = err.status === 424;
results.replaceChildren(h("div", { class: "alert " + (needsSetup ? "alert--warn" : "alert--error") },
icon(needsSetup ? "key" : "alert"),
h("div", {}, err.message, needsSetup && h("div", {}, h("a", { href: "/admin/settings#integrations" }, "Open integrations →")))));
} finally {
if (seq === searchSeq) spinner.hidden = true;
}
}
function resultKey(r) { return `${r.provider}:${r.providerId}`; }
function markResult(it, rank) {
if (!lastResults || !it.provider) return;
const r = lastResults.find((x) => x.provider === it.provider && x.providerId === it.providerId);
if (r) { r.existingRank = rank; renderResults(lastResults); }
}
function renderResults(res) {
lastResults = res;
if (res == null) { results.replaceChildren(); return; }
if (!res.length) {
results.replaceChildren(h("p", { class: "search-msg" }, "No matches. Try another spelling, or add it manually below."));
return;
}
const full = isFull();
const nodes = res.map((r) => {
const sub = [r.subtitle, r.year, r.extra?.type].filter(Boolean).join(" · ");
const action = r.existingRank
? h("span", { class: "result-added" }, icon("check"), `#${r.existingRank}`)
: h("button", {
type: "button", class: "btn btn--sm btn--primary", disabled: full,
title: full ? "This list is full" : `Add to #${list.itemCount + 1}`,
onclick: (e) => addResult(r, e.currentTarget),
}, icon("plus"), "Add");
return h("div", { class: "result", "data-key": resultKey(r) },
artBox(r.imageUrl, ""),
h("div", { style: { minWidth: 0 } },
h("div", { class: "result-title" }, r.title),
sub && h("div", { class: "result-sub" }, sub),
r.overview && h("div", { class: "result-overview" }, r.overview),
),
action,
);
});
if (full) nodes.unshift(h("p", { class: "alert alert--warn" }, icon("info"), `This list is full (${fmt.format(list.maxItems)}). Raise its size in List settings to add more.`));
results.replaceChildren(...nodes);
}
async function addResult(r, button) {
// Theater entries need the owner to say which production they mean.
if (cat.key === "theater") {
openItemForm({ result: r });
return;
}
busy(button, true);
await createItem({ ...fromResult(r), details: true }, () => busy(button, false));
}
function fromResult(r) {
return {
title: r.title, subtitle: r.subtitle, year: r.year, externalUrl: r.externalUrl,
imageUrl: r.imageUrl, provider: r.provider, providerId: r.providerId, extra: { ...(r.extra || {}) },
};
}
async function createItem(body, done) {
try {
let res;
try {
res = await api("POST", `/api/lists/${list.id}/items`, body);
} catch (err) {
if (err.status !== 409) throw err;
const again = await confirmDialog({ title: "Already ranked", message: `${err.message} Add it again anyway?`, confirm: "Add again" });
if (!again) return null;
res = await api("POST", `/api/lists/${list.id}/items`, { ...body, force: true });
}
list.itemCount++;
renderHeader();
toast(`Added “${res.item.title}” at #${res.item.rank}`);
if (res.warning) toast(res.warning, { error: true });
markResult(res.item, res.item.rank);
await load(Math.ceil(res.item.rank / pageSize), res.item.id);
return res.item;
} catch (err) {
toast(err.message, { error: true });
return null;
} finally {
done?.();
}
}
const runSearch = debounce((q) => search(q), 450);
searchInput.addEventListener("input", () => runSearch(searchInput.value));
searchForm.addEventListener("submit", (e) => { e.preventDefault(); search(searchInput.value); });
document.querySelector("[data-action=manual]").addEventListener("click", () => openItemForm({}));
// ---------- item form (add manually / edit / theater add) ----------
function openItemForm({ item, result }) {
const editing = !!item;
const src = item || (result ? fromResult(result) : { extra: {} });
const extra = { ...(src.extra || {}) };
let clearImage = false;
let dlg;
const field = (label, input, hint) => h("label", { class: "field" },
h("span", { class: "field-label" }, label), input, hint && h("span", { class: "field-hint" }, hint));
const title = h("input", { required: true, maxlength: 500, value: src.title || "" });
const subtitle = h("input", { maxlength: 500, value: src.subtitle || "" });
const year = h("input", { maxlength: 20, value: src.year || "", inputmode: "numeric", style: { maxWidth: "140px" } });
const note = h("textarea", { rows: 3, maxlength: 10000, value: item?.note || "", placeholder: "Why it's here. Shown to visitors." });
const link = h("input", { type: "url", maxlength: 2000, value: src.externalUrl || "", placeholder: "https://" });
// Theater production gets top billing with suggestions.
let production, productionList;
if (cat.key === "theater") {
productionList = h("datalist", { id: "productions" }, productions.map((p) => h("option", { value: p })));
production = h("input", { list: "productions", maxlength: 200, value: extra.production || "", placeholder: "e.g. Broadway, North American Tour" });
}
const extraInputs = (EXTRA_FIELDS[cat.key] || []).map(([key, label]) => {
const input = h("input", { maxlength: 500, value: extra[key] || "" });
return { key, label, input };
});
// Artwork
const currentArt = item?.image ? `/images/${item.image}` : (src.imageUrl || "");
const preview = h("div", {}, artBox(currentArt));
const imageUrl = h("input", {
type: "url", placeholder: "Paste an image URL",
value: editing ? (item.imageSource && item.imageSource !== "upload" ? item.imageSource : "") : (src.imageUrl || ""),
});
imageUrl.addEventListener("change", () => {
clearImage = false;
preview.replaceChildren(artBox(imageUrl.value.trim()));
});
const fileInput = h("input", { type: "file", accept: "image/jpeg,image/png,image/webp,image/gif,image/avif", hidden: true });
const uploadBtn = h("button", { type: "button", class: "btn btn--sm", onclick: () => fileInput.click() }, icon("upload"), "Upload");
fileInput.addEventListener("change", async () => {
const file = fileInput.files[0];
if (!file) return;
if (!editing) {
toast("Save the entry first, then upload art for it.", { error: true });
return;
}
const fd = new FormData();
fd.append("file", file);
busy(uploadBtn, true);
try {
const res = await api("POST", `/api/items/${item.id}/image`, fd);
Object.assign(item, res.item);
imageUrl.value = "";
clearImage = false;
preview.replaceChildren(artBox(`/images/${res.item.image}`));
toast("Artwork updated");
await load(page);
} catch (err) {
toast(err.message, { error: true });
} finally {
busy(uploadBtn, false);
fileInput.value = "";
}
});
const removeArt = h("button", {
type: "button", class: "btn btn--sm btn--ghost",
onclick: () => { clearImage = true; imageUrl.value = ""; preview.replaceChildren(artBox("")); },
}, icon("x"), "Remove");
const artHint = cat.key === "theater"
? "Tip: use a poster from the specific production (a tour's key art, a revival's Playbill…)."
: "Art is saved to your server, so it keeps working if the source changes.";
const error = h("p", { class: "alert alert--error", role: "alert", hidden: true });
const save = h("button", { type: "submit", class: "btn btn--primary" }, editing ? "Save" : `Add at #${list.itemCount + 1}`);
const heading = editing ? `Edit #${item.rank}` : result ? `Add “${result.title}”` : `Add a ${cat.singular.toLowerCase()}`;
const form = h("form", { novalidate: true },
dialogHead(heading, () => dlg),
h("div", { class: "dialog-body stack" },
production && field("Production", production, "Which staging this is about. Pick one or type your own."),
productionList,
h("div", { class: "art-edit" + (square ? " is-square" : "") },
preview,
h("div", { class: "art-edit-actions" },
h("span", { class: "field-label" }, "Artwork"),
imageUrl,
h("div", { class: "row-btns" }, editing && uploadBtn, removeArt, fileInput),
h("span", { class: "field-hint" }, artHint),
),
),
field("Title", title),
h("div", { class: "row" },
field(cat.subtitleHint, subtitle),
field(cat.key === "theater" ? "Year (of this production)" : "Year", year),
),
extraInputs.length > 0 && h("div", { class: "row" }, extraInputs.map((x) => field(x.label, x.input))),
field("Your note", note),
field("Link", link, "Where the title links to on your page."),
error,
),
h("div", { class: "dialog-foot" },
h("button", { type: "button", class: "btn", onclick: () => dlg.close() }, "Cancel"),
save,
),
);
form.addEventListener("submit", async (e) => {
e.preventDefault();
error.hidden = true;
if (!title.value.trim()) {
error.hidden = false;
error.textContent = "Give it a title.";
title.focus();
return;
}
const newExtra = { ...extra };
if (production) newExtra.production = production.value.trim();
for (const x of extraInputs) newExtra[x.key] = x.input.value.trim();
const body = {
title: title.value, subtitle: subtitle.value, year: year.value, note: note.value,
externalUrl: link.value.trim(), extra: newExtra,
imageUrl: clearImage ? "" : imageUrl.value.trim(), clearImage,
};
busy(save, true);
try {
if (editing) {
await api("PUT", `/api/items/${item.id}`, body);
dlg.close();
toast("Saved");
await load(page, item.id);
} else {
Object.assign(body, {
provider: result?.provider || "", providerId: result?.providerId || "", details: !!result,
});
const created = await createItem(body);
if (created) dlg.close();
}
} catch (err) {
error.hidden = false;
error.textContent = err.message;
} finally {
busy(save, false);
}
});
dlg = openDialog(form);
(production || title).focus();
}
renderHeader();
load(1);
+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();
+360
View File
@@ -0,0 +1,360 @@
// Settings: profile, appearance, integrations, account.
import { h, icon, api, toast, busy } from "./common.js";
const root = document.getElementById("settings");
let settings;
const field = (label, input, hint) => h("label", { class: "field" },
h("span", { class: "field-label" }, label), input, hint && h("span", { class: "field-hint" }, hint));
function section(id, title, desc, ...body) {
return h("section", { class: "settings-section", id },
h("h2", {}, title),
desc && h("p", { class: "section-desc" }, desc),
h("div", { class: "panel" }, ...body));
}
async function saveSettings(patch, button, message = "Saved") {
if (button) busy(button, true);
try {
settings = await api("PUT", "/api/settings", patch);
toast(message);
return true;
} catch (err) {
toast(err.message, { error: true });
return false;
} finally {
if (button) busy(button, false);
}
}
// ---------- profile ----------
function profileSection() {
const avatarBox = h("div", { class: "avatar" });
const renderAvatar = () => {
avatarBox.replaceChildren(settings.avatar
? h("img", { src: settings.avatar, alt: "" })
: h("span", {}, (settings.ownerName || settings.siteTitle || "T").trim().charAt(0).toUpperCase()));
removeBtn.hidden = !settings.avatar;
};
const file = h("input", { type: "file", accept: "image/jpeg,image/png,image/webp,image/gif,image/avif", hidden: true });
const uploadBtn = h("button", { type: "button", class: "btn btn--sm", onclick: () => file.click() }, icon("upload"), "Upload picture");
const removeBtn = h("button", {
type: "button", class: "btn btn--sm btn--ghost",
onclick: async () => {
try {
await api("DELETE", "/api/settings/avatar");
settings.avatar = "";
renderAvatar();
toast("Picture removed");
} catch (err) { toast(err.message, { error: true }); }
},
}, "Remove");
file.addEventListener("change", async () => {
if (!file.files[0]) return;
const fd = new FormData();
fd.append("file", file.files[0]);
busy(uploadBtn, true);
try {
const res = await api("POST", "/api/settings/avatar", fd);
settings.avatar = res.avatar;
renderAvatar();
toast("Picture updated");
} catch (err) {
toast(err.message, { error: true });
} finally {
busy(uploadBtn, false);
file.value = "";
}
});
const ownerName = h("input", { maxlength: 100, value: settings.ownerName, placeholder: "Your name" });
const siteTitle = h("input", { maxlength: 100, value: settings.siteTitle, placeholder: "TopDeck" });
const tagline = h("input", { maxlength: 300, value: settings.tagline, placeholder: "A line about you or your taste" });
const preview = h("input", { type: "number", min: 0, max: 50, value: settings.previewCount, style: { maxWidth: "120px" } });
const notes = h("input", { type: "checkbox", checked: settings.showNotes });
const save = h("button", { type: "submit", class: "btn btn--primary" }, "Save profile");
const form = h("form", { class: "stack" },
h("div", { class: "avatar-edit" }, avatarBox, h("div", { class: "row-btns", style: { display: "flex", gap: "6px", flexWrap: "wrap" } }, uploadBtn, removeBtn, file)),
h("div", { class: "row" }, field("Your name", ownerName, "Shown as the page heading."), field("Site title", siteTitle, "Shown in the header and browser tab.")),
field("Tagline", tagline),
field("Entries per list on the home page", preview, "Visitors see the full list on each list's page. 0 shows only the heading."),
h("label", { class: "check" }, notes, "Show my notes to visitors"),
h("div", { class: "settings-actions" }, save),
);
form.addEventListener("submit", async (e) => {
e.preventDefault();
const ok = await saveSettings({
ownerName: ownerName.value, siteTitle: siteTitle.value, tagline: tagline.value,
previewCount: Number(preview.value), showNotes: notes.checked,
}, save, "Profile saved");
if (ok) {
renderAvatar();
const brand = document.querySelector(".brand-name");
if (brand) brand.textContent = settings.siteTitle || "TopDeck";
}
});
renderAvatar();
return section("profile", "Profile", "Who's behind the lists.", form);
}
// ---------- appearance ----------
const CSS_EXAMPLE = `/* Override any theme variable. */
:root, [data-theme] {
--accent: #ff5a7a;
--radius-art: 14px;
}
/* Or restyle anything, e.g. bigger posters: */
.items--grid { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); }
`;
function appearanceSection() {
const html = document.documentElement;
const cards = h("div", { class: "themes", role: "radiogroup", "aria-label": "Theme" },
settings.themes.map((t) => h("label", { class: "theme-card" },
h("input", {
type: "radio", name: "theme", value: t.key, checked: t.key === settings.theme,
onchange: async () => {
html.dataset.theme = t.key;
await saveSettings({ theme: t.key }, null, `Theme set to ${t.label}`);
},
}),
h("span", { class: "theme-swatch", "data-theme": t.key },
h("span", { class: "theme-preview" }, h("i"), h("i"), h("i"), h("i")),
h("span", { class: "theme-name" }, t.label, h("small", { class: "theme-kind" }, t.dark ? "Dark" : "Light")),
),
)),
);
const css = h("textarea", { class: "code", spellcheck: false, value: settings.customCss, placeholder: CSS_EXAMPLE, "aria-label": "Custom CSS" });
css.addEventListener("keydown", (e) => {
if (e.key === "Tab" && !e.shiftKey) {
e.preventDefault();
css.setRangeText(" ", css.selectionStart, css.selectionEnd, "end");
}
});
let live = document.querySelector("link[href^='/custom.css']");
const previewStyle = h("style");
const previewBtn = h("button", {
type: "button", class: "btn",
onclick: () => {
live?.remove();
live = null;
previewStyle.textContent = css.value;
document.head.append(previewStyle);
toast("Previewing on this page. Save to publish.");
},
}, icon("eye"), "Preview");
const exampleBtn = h("button", { type: "button", class: "btn btn--ghost", onclick: () => { css.value = CSS_EXAMPLE; css.focus(); } }, "Insert example");
const save = h("button", { type: "button", class: "btn btn--primary" }, "Save CSS");
save.addEventListener("click", async () => {
if (await saveSettings({ customCss: css.value }, save, "Custom CSS saved")) {
live?.remove();
live = null;
previewStyle.textContent = css.value;
document.head.append(previewStyle);
}
});
return section("appearance", "Appearance", "Pick a preset, then fine-tune it with your own CSS if you like.",
h("div", { class: "field" }, h("span", { class: "field-label" }, "Theme"), cards),
h("div", { class: "field" },
h("span", { class: "field-label" }, "Custom CSS"),
css,
h("span", { class: "field-hint" }, "Loaded after the theme on every page. Every color is a CSS variable (see themes.css), so a few lines can make a whole new theme."),
),
h("div", { class: "settings-actions" }, exampleBtn, previewBtn, save),
);
}
// ---------- integrations ----------
function secretInput(key, label, hintHTML) {
const st = settings.secrets[key];
const input = h("input", {
type: "password", autocomplete: "off", spellcheck: false,
placeholder: st.set ? `Saved (${st.hint}). Type to replace.` : "Not set",
disabled: st.locked,
});
const hint = h("span", { class: "field-hint" });
if (st.locked) hint.textContent = `Set by the ${"TOPDECK_" + key.toUpperCase()} environment variable.`;
else if (hintHTML) hint.append(...hintHTML);
const wrap = field(label, input);
wrap.append(hint);
return { key, input, wrap, st };
}
function testButton(category, out) {
const btn = h("button", { type: "button", class: "btn btn--sm" }, icon("refresh"), "Test");
btn.addEventListener("click", async () => {
busy(btn, true);
out.textContent = "Testing…";
out.className = "test-result";
try {
const res = await api("POST", "/api/integrations/test", { category });
out.textContent = res.message;
out.className = "test-result " + (res.ok ? "is-ok" : "is-error");
} catch (err) {
out.textContent = err.message;
out.className = "test-result is-error";
} finally {
busy(btn, false);
}
});
return btn;
}
const extLink = (href, text) => h("a", { href, target: "_blank", rel: "noopener noreferrer" }, text);
function integration({ title, iconName, desc, category, secrets = [], extra = [], ready }) {
const out = h("span", { class: "test-result", "aria-live": "polite" });
const status = h("span", { class: "status" });
const setStatus = () => {
const ok = ready();
status.textContent = ok ? "Ready" : "Needs a key";
status.className = "status " + (ok ? "is-ok" : "is-off");
};
const editable = secrets.filter((s) => !s.st.locked);
const save = editable.length > 0 && h("button", { type: "button", class: "btn btn--sm btn--primary" }, "Save");
const clear = editable.some((s) => s.st.set) && h("button", { type: "button", class: "btn btn--sm btn--ghost" }, "Clear");
const collect = (clearAll) => {
const patch = {};
for (const s of editable) {
if (clearAll) patch[s.key] = "";
else if (s.input.value.trim()) patch[s.key] = s.input.value.trim();
}
return patch;
};
const refresh = () => {
for (const s of secrets) {
s.st = settings.secrets[s.key];
s.input.value = "";
s.input.placeholder = s.st.set ? `Saved (${s.st.hint}). Type to replace.` : "Not set";
}
setStatus();
};
save && save.addEventListener("click", async () => {
const patch = collect(false);
if (!Object.keys(patch).length) { toast("Type a key first", { error: true }); return; }
if (await saveSettings(patch, save, `${title} saved`)) refresh();
});
clear && clear.addEventListener("click", async () => {
if (await saveSettings(collect(true), clear, `${title} keys cleared`)) refresh();
});
setStatus();
return h("div", { class: "integration" },
h("div", { class: "integration-head" },
h("div", {}, h("h3", {}, icon(iconName), title), h("p", {}, desc)),
status,
),
...extra,
...secrets.map((s) => s.wrap),
h("div", { style: { display: "flex", gap: "8px", alignItems: "center", flexWrap: "wrap" } },
testButton(category, out), save, clear, out),
);
}
function providerChoice(settingKey, options) {
const name = "choice-" + settingKey;
return h("div", { class: "radio-cards", role: "radiogroup" },
options.map(([value, label]) => h("label", {},
h("input", {
type: "radio", name, value, checked: settings[settingKey] === value,
onchange: () => saveSettings({ [settingKey]: value }, null, `Now using ${label.split(" (")[0]}`),
}),
label)));
}
function integrationsSection() {
const tmdb = secretInput("tmdb_api_key", "API key or Read Access Token", [
"Free with a TMDB account: ", extLink("https://www.themoviedb.org/settings/api", "themoviedb.org/settings/api"), ". Either the v3 key or the v4 token works."]);
const tvdb = secretInput("tvdb_api_key", "API key", [
"Get one at ", extLink("https://thetvdb.com/api-information", "thetvdb.com/api-information"), "."]);
const tvdbPin = secretInput("tvdb_pin", "Subscriber PIN (optional)", ["Only needed for user-supported keys."]);
const igdbId = secretInput("igdb_client_id", "Twitch Client ID", [
"IGDB uses Twitch logins. Register an app at ", extLink("https://dev.twitch.tv/console/apps", "dev.twitch.tv/console"), " (see ",
extLink("https://api-docs.igdb.com/#account-creation", "IGDB's guide"), ")."]);
const igdbSecret = secretInput("igdb_client_secret", "Twitch Client Secret");
const has = (k) => settings.secrets[k].set;
return section("integrations", "Integrations", "Where search results and artwork come from. Keys are stored on your server and never shown on your public page.",
integration({
title: "Movies · TMDB", iconName: "film", category: "movie",
desc: "The Movie Database: posters, directors, genres.",
secrets: [tmdb], ready: () => has("tmdb_api_key"),
}),
integration({
title: "TV Shows", iconName: "tv", category: "tv",
desc: "TheTVDB by default. TMDB also works if you'd rather use one key for both.",
extra: [providerChoice("tvProvider", [["tvdb", "TheTVDB"], ["tmdb", "TMDB (uses the key above)"]])],
secrets: [tvdb, tvdbPin],
ready: () => settings.tvProvider === "tmdb" ? has("tmdb_api_key") : has("tvdb_api_key"),
}),
integration({
title: "Video Games · IGDB", iconName: "gamepad", category: "game",
desc: "The Internet Game Database: covers, developers, platforms.",
secrets: [igdbId, igdbSecret], ready: () => has("igdb_client_id") && has("igdb_client_secret"),
}),
integration({
title: "Music · Songs & Albums", iconName: "disc", category: "album",
desc: "No key needed. Deezer has the best cover art and fast search; MusicBrainz is fully open data but slower (1 request/second).",
extra: [providerChoice("musicProvider", [["deezer", "Deezer (recommended)"], ["musicbrainz", "MusicBrainz + Cover Art Archive"]])],
ready: () => true,
}),
integration({
title: "Theater · Wikipedia", iconName: "masks", category: "theater",
desc: "No key needed. Finds musicals, plays and operas on Wikipedia. You choose the production (Broadway, a tour…) and can swap in its own poster.",
ready: () => true,
}),
);
}
// ---------- account ----------
function accountSection() {
const username = h("input", { autocomplete: "username", maxlength: 64, value: settings.username, required: true });
const current = h("input", { type: "password", autocomplete: "current-password", required: true });
const next = h("input", { type: "password", autocomplete: "new-password", minlength: 8, placeholder: "Leave blank to keep" });
const confirm = h("input", { type: "password", autocomplete: "new-password" });
const save = h("button", { type: "submit", class: "btn btn--primary" }, "Update account");
const form = h("form", { class: "stack" },
field("Username", username),
h("div", { class: "row" }, field("New password", next), field("Confirm new password", confirm)),
field("Current password", current, "Required to make changes. Changing your password signs out other browsers."),
h("div", { class: "settings-actions" }, save),
);
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (next.value && next.value !== confirm.value) { toast("The new passwords don't match", { error: true }); return; }
if (next.value && next.value.length < 8) { toast("Use at least 8 characters", { error: true }); return; }
busy(save, true);
try {
const res = await api("PUT", "/api/account", { username: username.value, currentPassword: current.value, newPassword: next.value });
settings.username = res.username;
current.value = next.value = confirm.value = "";
toast("Account updated");
} catch (err) {
toast(err.message, { error: true });
} finally {
busy(save, false);
}
});
return section("account", "Account", "", form);
}
async function init() {
try {
settings = await api("GET", "/api/settings");
} catch (err) {
root.replaceChildren(h("p", { class: "alert alert--error" }, icon("alert"), err.message));
return;
}
root.replaceChildren(profileSection(), appearanceSection(), integrationsSection(), accountSection());
root.removeAttribute("aria-busy");
if (location.hash) document.querySelector(location.hash)?.scrollIntoView();
}
init();
+160
View File
@@ -0,0 +1,160 @@
// Shared helpers for TopDeck's pages. No build step, no dependencies.
/** Create an element: h("button", { class: "btn", onclick }, "Save") */
export function h(tag, attrs = {}, ...children) {
const el = document.createElement(tag);
let value;
for (const [k, v] of Object.entries(attrs || {})) {
if (v === false || v == null) continue;
if (k === "value") value = v;
else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v);
else if (k === "class") el.className = v;
else if (k === "style" && typeof v === "object") Object.assign(el.style, v);
else if (k in el && typeof v !== "string") el[k] = v;
else el.setAttribute(k, v === true ? "" : v);
}
append(el, children);
// Set after children so <select> options exist.
if (value !== undefined) el.value = value;
return el;
}
function append(el, children) {
for (const c of children.flat(Infinity)) {
if (c == null || c === false) continue;
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
}
}
const SVG = "http://www.w3.org/2000/svg";
export function icon(name) {
const svg = document.createElementNS(SVG, "svg");
svg.setAttribute("class", "icon");
svg.setAttribute("aria-hidden", "true");
const use = document.createElementNS(SVG, "use");
use.setAttribute("href", `/static/img/icons.svg#${name}`);
svg.append(use);
return svg;
}
export function readBoot() {
const el = document.getElementById("boot");
return el ? JSON.parse(el.textContent) : {};
}
export class APIError extends Error {
constructor(message, status, body) {
super(message);
this.status = status;
this.body = body;
}
}
/** Call the admin JSON API. Throws APIError with a human message. */
export async function api(method, path, body) {
const opts = { method, headers: { "X-TopDeck": "1" }, credentials: "same-origin" };
if (body instanceof FormData) {
opts.body = body;
} else if (body !== undefined) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
}
let res;
try {
res = await fetch(path, opts);
} catch {
throw new APIError("Can't reach the server. Check your connection.", 0);
}
if (res.status === 204) return null;
const data = await res.json().catch(() => ({}));
if (res.status === 401) {
location.href = "/login?next=" + encodeURIComponent(location.pathname);
}
if (!res.ok) throw new APIError(data.error || `Request failed (${res.status})`, res.status, data);
return data;
}
let toastBox;
export function toast(message, { error = false, timeout = 3200 } = {}) {
if (!toastBox) {
toastBox = h("div", { class: "toasts", role: "status", "aria-live": "polite" });
document.body.append(toastBox);
}
const t = h("div", { class: "toast" + (error ? " toast--error" : "") }, icon(error ? "alert" : "check"), message);
toastBox.append(t);
setTimeout(() => {
t.classList.add("is-leaving");
setTimeout(() => t.remove(), 250);
}, error ? Math.max(timeout, 5000) : timeout);
}
/** Open a modal dialog. Returns the <dialog>; it removes itself on close. */
export function openDialog(content, { className = "", onClose } = {}) {
const dlg = h("dialog", { class: className }, content);
dlg.addEventListener("click", (e) => {
// Clicking the backdrop (outside the box) closes.
if (e.target !== dlg) return;
const r = dlg.getBoundingClientRect();
const inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
if (!inside) dlg.close();
});
dlg.addEventListener("close", () => {
onClose?.();
dlg.remove();
});
document.body.append(dlg);
dlg.showModal();
return dlg;
}
export function dialogHead(title, dlg) {
return h("div", { class: "dialog-head" },
h("h2", {}, title),
h("button", { type: "button", class: "icon-btn", "aria-label": "Close", onclick: () => dlg().close() }, icon("x")),
);
}
/** Ask a yes/no question. Resolves true when confirmed. */
export function confirmDialog({ title, message, confirm = "Confirm", danger = false }) {
return new Promise((resolve) => {
let ok = false;
let dlg;
const content = h("form", { method: "dialog" },
dialogHead(title, () => dlg),
h("div", { class: "dialog-body" }, h("p", { class: "muted" }, message)),
h("div", { class: "dialog-foot" },
h("button", { type: "button", class: "btn", onclick: () => dlg.close() }, "Cancel"),
h("button", {
type: "submit",
class: "btn " + (danger ? "btn--danger btn--solid" : "btn--primary"),
onclick: () => { ok = true; },
}, confirm),
),
);
dlg = openDialog(content, { onClose: () => resolve(ok) });
dlg.style.maxWidth = "440px";
});
}
export function debounce(fn, ms) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), ms);
};
}
export function busy(button, on) {
button.disabled = on;
button.setAttribute("aria-busy", on ? "true" : "false");
}
export const fmt = new Intl.NumberFormat();
export function storageGet(key) {
try { return localStorage.getItem(key); } catch { return null; }
}
export function storageSet(key, val) {
try { localStorage.setItem(key, val); } catch { /* private mode */ }
}
+134
View File
@@ -0,0 +1,134 @@
// Create / edit list dialog, shared by the dashboard and the list editor.
import { h, icon, api, openDialog, dialogHead, busy, fmt } from "./common.js";
const SIZE_PRESETS = [5, 10, 25, 50, 100];
function sizeHint(n, limits) {
if (!Number.isInteger(n) || n < 1) return { text: "Pick a size of at least 1.", cls: "is-error" };
if (n > limits.max) return { text: `The most a list can hold is ${fmt.format(limits.max)}.`, cls: "is-error" };
if (n > limits.warn) return { text: `That's a lot. Lists this long work, but they're hard to browse and rank. ${limits.recommended} or fewer is recommended.`, cls: "is-warn" };
if (n > limits.recommended) return { text: `Heads up: ${limits.recommended} or fewer is recommended so visitors can take it all in.`, cls: "is-warn" };
return { text: `Up to ${fmt.format(limits.max)} allowed; ${limits.recommended} or fewer recommended.`, cls: "" };
}
const autoTitle = (n, cat) => `Top ${n} ${cat.label}`;
/**
* @param {object} o
* @param {object[]} o.categories
* @param {object} o.limits
* @param {object} [o.list] existing list to edit
* @param {string} [o.category] preselected category for a new list
* @param {(list: object) => void} o.onSaved
*/
export function openListForm({ categories, limits, list, category, onSaved }) {
const editing = !!list;
const catByKey = Object.fromEntries(categories.map((c) => [c.key, c]));
const state = {
category: list?.category || category || categories[0].key,
size: list?.maxItems || 10,
};
// Keep the title in sync with size/category until the owner types their own.
let titleTouched = editing && list.title !== autoTitle(list.maxItems, catByKey[list.category]);
const title = h("input", { name: "title", required: true, maxlength: 200, value: list?.title || autoTitle(state.size, catByKey[state.category]) });
title.addEventListener("input", () => { titleTouched = true; });
const syncTitle = () => { if (!titleTouched) title.value = autoTitle(state.size, catByKey[state.category]); };
const lockedCat = editing && list.itemCount > 0;
const catPicker = h("div", { class: "cat-picker", role: "radiogroup", "aria-label": "Category" },
categories.map((c) => h("label", { class: "cat-option" },
h("input", {
type: "radio", name: "category", value: c.key, checked: c.key === state.category,
disabled: lockedCat && c.key !== state.category,
onchange: () => { state.category = c.key; syncTitle(); },
}),
h("span", {}, icon(c.icon), c.label),
)),
);
const sizeInput = h("input", { type: "number", min: 1, max: limits.max, step: 1, value: state.size, inputmode: "numeric", style: { maxWidth: "140px" } });
const hint = h("span", { class: "field-hint" });
const chips = h("div", { class: "size-chips" });
const renderSize = () => {
const { text, cls } = sizeHint(state.size, limits);
hint.textContent = text;
hint.className = "field-hint " + cls;
chips.replaceChildren(...SIZE_PRESETS.map((n) => h("button", {
type: "button", "aria-pressed": String(n === state.size),
onclick: () => { state.size = n; sizeInput.value = n; renderSize(); syncTitle(); },
}, `Top ${n}`)));
};
sizeInput.addEventListener("input", () => { state.size = Number(sizeInput.value); renderSize(); syncTitle(); });
renderSize();
const desc = h("textarea", { name: "description", rows: 2, maxlength: 1000, placeholder: "Optional: a line about what makes this list", value: list?.description || "" });
const slug = editing && h("input", { name: "slug", value: list.slug, maxlength: 60, pattern: "[a-z0-9-]+", spellcheck: false });
const layout = list?.layout || "grid";
const layoutGroup = h("div", { class: "radio-cards" },
h("label", {}, h("input", { type: "radio", name: "layout", value: "grid", checked: layout === "grid" }), icon("grid"), "Artwork grid"),
h("label", {}, h("input", { type: "radio", name: "layout", value: "rows", checked: layout === "rows" }), icon("rows"), "Ranked list"),
);
const visible = h("input", { type: "checkbox", name: "visible", checked: list ? list.visible : true });
const error = h("p", { class: "alert alert--error", role: "alert", hidden: true });
const save = h("button", { type: "submit", class: "btn btn--primary" }, editing ? "Save changes" : "Create list");
let dlg;
const form = h("form", { novalidate: true },
dialogHead(editing ? "List settings" : "New list", () => dlg),
h("div", { class: "dialog-body stack" },
h("div", { class: "field" }, h("span", { class: "field-label" }, "Category"), catPicker,
lockedCat && h("span", { class: "field-hint" }, "The category can't change once a list has entries.")),
h("div", { class: "field" }, h("span", { class: "field-label" }, "How many?"),
h("div", { class: "input-group", style: { alignItems: "center", flexWrap: "wrap" } }, chips, sizeInput), hint),
h("label", { class: "field" }, h("span", { class: "field-label" }, "Title"), title),
h("label", { class: "field" }, h("span", { class: "field-label" }, "Description"), desc),
editing && h("label", { class: "field" }, h("span", { class: "field-label" }, "Address"),
h("div", { class: "input-group", style: { alignItems: "center" } }, h("span", { class: "muted" }, "/l/"), slug)),
h("div", { class: "field" }, h("span", { class: "field-label" }, "Layout"), layoutGroup),
h("label", { class: "check" }, visible, "Show this list on my public page"),
error,
),
h("div", { class: "dialog-foot" },
h("button", { type: "button", class: "btn", onclick: () => dlg.close() }, "Cancel"),
save,
),
);
form.addEventListener("submit", async (e) => {
e.preventDefault();
error.hidden = true;
const n = Number(sizeInput.value);
if (!Number.isInteger(n) || n < 1 || n > limits.max) {
error.hidden = false;
error.textContent = sizeHint(n, limits).text;
return;
}
const body = {
title: title.value,
category: state.category,
maxItems: n,
description: desc.value,
layout: form.querySelector("input[name=layout]:checked").value,
visible: visible.checked,
};
if (editing) body.slug = slug.value;
busy(save, true);
try {
const saved = editing
? await api("PUT", `/api/lists/${list.id}`, body)
: await api("POST", "/api/lists", body);
dlg.close();
onSaved(saved);
} catch (err) {
error.hidden = false;
error.textContent = err.message;
} finally {
busy(save, false);
}
});
dlg = openDialog(form);
(editing ? title : catPicker.querySelector("input:checked"))?.focus();
return dlg;
}
+93
View File
@@ -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);
});
}
+104
View File
@@ -0,0 +1,104 @@
// Minimal drag-to-reorder for vertical lists. Works with mouse, touch and pen
// via pointer events; the handle also supports ArrowUp/ArrowDown.
/**
* @param {HTMLElement} container element whose children are sortable
* @param {object} opts
* @param {string} opts.handle selector for the drag handle inside a child
* @param {string} opts.placeholderClass classes for the gap left while dragging
* @param {(el: HTMLElement, from: number, to: number) => void} opts.onMove
*/
export function sortable(container, { handle = ".drag-handle", placeholderClass = "is-placeholder", onMove }) {
const children = () => [...container.children];
container.addEventListener("pointerdown", (e) => {
const hdl = e.target.closest(handle);
if (!hdl || !container.contains(hdl) || (e.pointerType === "mouse" && e.button !== 0)) return;
const el = [...container.children].find((c) => c.contains(hdl));
if (!el) return;
e.preventDefault();
const from = children().indexOf(el);
const rect = el.getBoundingClientRect();
const grabOffset = e.clientY - rect.top;
let pointerY = e.clientY;
const ph = el.cloneNode(true);
ph.className = el.className + " " + placeholderClass;
ph.removeAttribute("id");
ph.setAttribute("aria-hidden", "true");
ph.style.height = rect.height + "px";
el.after(ph);
Object.assign(el.style, {
position: "fixed",
left: rect.left + "px",
top: rect.top + "px",
width: rect.width + "px",
zIndex: "50",
pointerEvents: "none",
});
el.classList.add("is-dragging");
document.documentElement.classList.add("dragging-active");
hdl.setPointerCapture(e.pointerId);
const place = () => {
el.style.top = pointerY - grabOffset + "px";
const others = children().filter((c) => c !== el && c !== ph);
let before = null;
for (const c of others) {
const r = c.getBoundingClientRect();
if (pointerY < r.top + r.height / 2) { before = c; break; }
}
if (before) {
if (ph.nextElementSibling !== before) container.insertBefore(ph, before);
} else if (container.lastElementChild !== ph) {
container.append(ph);
}
};
// Scroll the page when dragging near the top or bottom edge.
let raf = 0;
const autoscroll = () => {
const edge = 70;
let dy = 0;
if (pointerY < edge + 60) dy = -Math.ceil((edge + 60 - pointerY) / 6);
else if (pointerY > innerHeight - edge) dy = Math.ceil((pointerY - (innerHeight - edge)) / 6);
if (dy) { scrollBy(0, dy); place(); }
raf = requestAnimationFrame(autoscroll);
};
raf = requestAnimationFrame(autoscroll);
const move = (ev) => { pointerY = ev.clientY; place(); };
const end = () => {
cancelAnimationFrame(raf);
hdl.removeEventListener("pointermove", move);
hdl.removeEventListener("pointerup", end);
hdl.removeEventListener("pointercancel", end);
ph.replaceWith(el);
el.removeAttribute("style");
el.classList.remove("is-dragging");
document.documentElement.classList.remove("dragging-active");
const to = children().indexOf(el);
if (to !== from) onMove(el, from, to);
};
hdl.addEventListener("pointermove", move);
hdl.addEventListener("pointerup", end);
hdl.addEventListener("pointercancel", end);
});
container.addEventListener("keydown", (e) => {
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
const hdl = e.target.closest(handle);
if (!hdl) return;
const el = children().find((c) => c.contains(hdl));
const from = children().indexOf(el);
const to = e.key === "ArrowUp" ? from - 1 : from + 1;
if (to < 0 || to >= children().length) return;
e.preventDefault();
if (e.key === "ArrowUp") el.previousElementSibling.before(el);
else el.nextElementSibling.after(el);
hdl.focus();
onMove(el, from, to);
});
}