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>
361 lines
15 KiB
JavaScript
361 lines
15 KiB
JavaScript
// 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();
|