Files
canon/web/static/js/admin-settings.js
T
cdnutterandClaude Opus 5.5 f3185ba7bd Add scheduled and manual backups
Backups are .tar.gz archives of a consistent database snapshot (VACUUM
INTO) plus the images folder. They run on a cron schedule, daily at 3:00
by default, and the newest N scheduled ones are kept (7 by default).
Manual backups are never pruned.

Settings → Backups offers schedule presets or a custom cron expression,
retention, "Back up now", and download/delete for each backup. A small
built-in parser handles standard 5-field cron syntax with names, ranges,
steps and @daily-style shorthands.

Backups go to CANON_BACKUP_DIR (default data/backups). Compose mounts a
separate canon-backups volume at /backups, overridable with
CANON_BACKUP_PATH.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:30:40 -07:00

487 lines
21 KiB
JavaScript

// Settings: profile, appearance, integrations, backups, account.
import { h, icon, api, toast, busy, confirmDialog } 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" });
// Mirrors DefaultTitle in render.go.
const defaultTitle = (name) => (name.trim() ? `${name.trim()}'s Canon` : "Canon");
const siteTitle = h("input", { maxlength: 100, value: settings.siteTitle, placeholder: defaultTitle(settings.ownerName) });
ownerName.addEventListener("input", () => { siteTitle.placeholder = defaultTitle(ownerName.value); });
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 previews = h("input", { type: "checkbox", checked: settings.showPreviews });
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. Leave blank to use “Your name's Canon”.")),
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 why each entry is ranked where it is"),
h("label", { class: "check" }, previews, "Play 30-second previews for songs and albums (from Deezer)"),
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, showPreviews: previews.checked,
}, save, "Profile saved");
if (ok) {
renderAvatar();
const brand = document.querySelector(".brand-name");
if (brand) brand.textContent = settings.siteTitleShown;
}
});
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 ${"CANON_" + 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. Searching both finds the most: Deezer has the best art and preview clips, and MusicBrainz covers albums Deezer's search misses plus game and film soundtracks.",
extra: [providerChoice("musicProvider", [["both", "Deezer + MusicBrainz (recommended)"], ["deezer", "Deezer only"], ["musicbrainz", "MusicBrainz only"]])],
ready: () => true,
}),
integration({
title: "Theater & Custom · Wikipedia", iconName: "masks", category: "theater",
desc: "No key needed. Theater lists search musicals, plays and operas; you choose the production (Broadway, a tour…) and can swap in its own poster. Custom lists can search all of Wikipedia for a name and picture, or skip search and add entries by hand.",
ready: () => true,
}),
);
}
// ---------- backups ----------
const SCHEDULES = [
["off", "Off"],
["0 * * * *", "Every hour"],
["0 */6 * * *", "Every 6 hours"],
["0 3 * * *", "Every day at 3:00"],
["0 3 * * 0", "Every Sunday at 3:00"],
["0 3 1 * *", "The 1st of each month at 3:00"],
];
const when = (iso) => new Date(iso).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
function formatSize(bytes) {
const units = ["B", "KB", "MB", "GB"];
let i = 0;
while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; }
return `${bytes.toFixed(i && bytes < 10 ? 1 : 0)} ${units[i]}`;
}
function backupsSection() {
let data;
const preset = h("select", {}, SCHEDULES.map(([v, label]) => h("option", { value: v }, label)), h("option", { value: "custom" }, "Custom (cron)…"));
const cron = h("input", { spellcheck: false, placeholder: "30 2 * * mon-fri", style: { fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" } });
const cronField = field("Cron expression", cron);
const keep = h("input", { type: "number", min: 1, max: 365, style: { maxWidth: "120px" } });
const status = h("p", { class: "backup-status", "aria-live": "polite" });
const list = h("ul", { class: "backup-list" });
const empty = h("p", { class: "muted" }, "No backups yet.");
const save = h("button", { type: "submit", class: "btn btn--primary" }, "Save schedule");
const now = h("button", { type: "button", class: "btn" }, icon("archive"), "Back up now");
const showCustom = () => { cronField.hidden = preset.value !== "custom"; };
preset.addEventListener("change", () => {
if (preset.value === "custom" && !cron.value) cron.value = data.cron === "off" ? "0 3 * * *" : data.cron;
showCustom();
});
const render = (d) => {
data = d;
const known = SCHEDULES.some(([v]) => v === d.cron);
preset.value = known ? d.cron : "custom";
cron.value = known ? "" : d.cron;
keep.value = d.keep;
showCustom();
const parts = [d.next ? `Next backup: ${when(d.next)}.` : "Scheduled backups are off."];
if (d.status.lastRun) parts.push(d.status.lastError ? `The last backup failed: ${d.status.lastError}` : `Last backup: ${when(d.status.lastRun)}.`);
status.textContent = parts.join(" ");
status.className = "backup-status" + (d.status.lastError ? " alert alert--error" : "");
list.replaceChildren(...d.backups.map(backupRow));
empty.hidden = d.backups.length > 0;
};
const reload = async () => render(await api("GET", "/api/backups"));
const backupRow = (b) => h("li", {},
h("span", { class: "backup-when" }, when(b.created), " ", b.manual ? h("span", { class: "badge badge--muted" }, "Manual") : null,
h("span", { class: "backup-meta" }, `${formatSize(b.size)} · ${b.name}`)),
h("span", { class: "backup-actions" },
h("a", { class: "icon-btn", href: `/admin/backups/${encodeURIComponent(b.name)}`, download: b.name, title: "Download", "aria-label": `Download ${b.name}` }, icon("download")),
h("button", {
type: "button", class: "icon-btn icon-btn--danger", title: "Delete", "aria-label": `Delete ${b.name}`,
onclick: async () => {
if (!await confirmDialog({ title: "Delete this backup?", message: `${b.name} will be removed from the server.`, confirm: "Delete", danger: true })) return;
try {
await api("DELETE", `/api/backups/${encodeURIComponent(b.name)}`);
toast("Backup deleted");
await reload();
} catch (err) { toast(err.message, { error: true }); }
},
}, icon("trash")),
),
);
now.addEventListener("click", async () => {
busy(now, true);
try {
await api("POST", "/api/backups");
toast("Backup created");
} catch (err) {
toast(err.message, { error: true });
} finally {
busy(now, false);
reload().catch(() => {});
}
});
const form = h("form", { class: "stack" },
h("div", { class: "row" },
field("Schedule", preset),
field("Scheduled backups to keep", keep, "Older ones are deleted. Manual backups are kept until you delete them."),
),
cronField,
status,
h("div", { class: "settings-actions" }, now, save),
);
form.addEventListener("submit", async (e) => {
e.preventDefault();
busy(save, true);
try {
render(await api("PUT", "/api/backups/schedule", {
cron: preset.value === "custom" ? cron.value : preset.value,
keep: Number(keep.value),
}));
toast("Backup schedule saved");
} catch (err) {
toast(err.message, { error: true });
} finally {
busy(save, false);
}
});
const hint = h("span", { class: "field-hint" });
const el = section("backups", "Backups",
"Each backup is a .tar.gz holding your database and every image, so it's all you need to restore.",
form, h("div", { class: "field" }, h("span", { class: "field-label" }, "Saved backups"), list, empty, hint));
reload().then(() => {
hint.textContent = `Stored in ${data.dir} on the server. Schedules use the server's time zone, ${data.timezone}.`;
}).catch((err) => status.replaceChildren(err.message));
return el;
}
// ---------- 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(), backupsSection(), accountSection());
root.removeAttribute("aria-busy");
if (location.hash) document.querySelector(location.hash)?.scrollIntoView();
}
init();