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

+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;
}