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>
487 lines
21 KiB
JavaScript
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();
|