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,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;
|
||||
}
|
||||
Reference in new issue
Block a user