226 lines
8.1 KiB
JavaScript
226 lines
8.1 KiB
JavaScript
/*
|
|
* Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl
|
|
* Source-Available Code / Dual-Licensed.
|
|
*
|
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
|
* Commercial or production use requires a valid paid license.
|
|
* See LICENSE file in repository root for details.
|
|
*/
|
|
|
|
import { api } from "@rustpad/api";
|
|
import { getAvailableLanguages, getLanguage, setLanguage, t } from "@rustpad/i18n";
|
|
import { applyTheme, getTheme } from "@rustpad/theme";
|
|
import { toast } from "@rustpad/toast";
|
|
|
|
const AUTH_STATE_KEY = "rustpad:auth-state";
|
|
const THEMES = ["dark", "light"];
|
|
let initialized = false;
|
|
let saveChain = Promise.resolve();
|
|
|
|
function hasAccountSession() {
|
|
try { return localStorage.getItem(AUTH_STATE_KEY) === "1"; } catch { return false; }
|
|
}
|
|
|
|
function icon(name) {
|
|
if (name === "language") {
|
|
return '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle><path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"></path></svg>';
|
|
}
|
|
if (name === "light") {
|
|
return '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42M17.65 17.65l1.42 1.42M2 12h2M20 12h2M4.93 19.07l1.42-1.42M17.65 6.35l1.42-1.42"></path></svg>';
|
|
}
|
|
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20.2 15.3A8.5 8.5 0 0 1 8.7 3.8 8.5 8.5 0 1 0 20.2 15.3Z"></path></svg>';
|
|
}
|
|
|
|
function closeOtherPickers(except = null) {
|
|
document.querySelectorAll(".app-select--header[open]").forEach(details => {
|
|
if (details !== except) details.open = false;
|
|
});
|
|
}
|
|
|
|
function preferenceButton({ value, label, selected, languageCode }) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "app-select-option";
|
|
button.dataset.value = value;
|
|
button.setAttribute("role", "option");
|
|
button.setAttribute("aria-selected", selected ? "true" : "false");
|
|
if (languageCode) button.lang = languageCode;
|
|
|
|
const check = document.createElement("span");
|
|
check.className = "app-select-option__check";
|
|
check.setAttribute("aria-hidden", "true");
|
|
check.textContent = selected ? "✓" : "";
|
|
|
|
const copy = document.createElement("span");
|
|
copy.className = "app-select-option__copy";
|
|
copy.textContent = label;
|
|
|
|
button.append(check, copy);
|
|
return button;
|
|
}
|
|
|
|
function createPicker(kind) {
|
|
const details = document.createElement("details");
|
|
details.className = `app-select app-select--header app-select--${kind}`;
|
|
details.dataset.preferenceKind = kind;
|
|
|
|
const summary = document.createElement("summary");
|
|
summary.className = "app-select-trigger";
|
|
|
|
const iconWrap = document.createElement("span");
|
|
iconWrap.className = "app-select-trigger__icon";
|
|
iconWrap.innerHTML = icon(kind === "theme" ? getTheme() : "language");
|
|
|
|
const compactValue = document.createElement("span");
|
|
compactValue.className = "app-select-trigger__value";
|
|
compactValue.setAttribute("aria-hidden", "true");
|
|
|
|
const chevron = document.createElement("span");
|
|
chevron.className = "app-select-trigger__chevron";
|
|
chevron.setAttribute("aria-hidden", "true");
|
|
chevron.textContent = "▾";
|
|
|
|
summary.append(iconWrap, compactValue, chevron);
|
|
|
|
const menu = document.createElement("div");
|
|
menu.className = "app-select-menu";
|
|
menu.setAttribute("role", "listbox");
|
|
|
|
details.append(summary, menu);
|
|
details.addEventListener("toggle", () => {
|
|
if (details.open) closeOtherPickers(details);
|
|
});
|
|
return details;
|
|
}
|
|
|
|
async function persistAccountPreference(patch) {
|
|
if (!hasAccountSession()) return;
|
|
saveChain = saveChain.catch(() => undefined).then(() => api("/api/auth/profile", {
|
|
method: "POST",
|
|
body: JSON.stringify(patch),
|
|
}));
|
|
return saveChain;
|
|
}
|
|
|
|
function renderLanguagePicker(details) {
|
|
const languages = getAvailableLanguages();
|
|
const current = getLanguage();
|
|
const selected = languages.find(language => language.code === current) || languages[0];
|
|
const summary = details.querySelector(".app-select-trigger");
|
|
const value = details.querySelector(".app-select-trigger__value");
|
|
const menu = details.querySelector(".app-select-menu");
|
|
const label = t("common.language", {}, "Language");
|
|
|
|
summary.setAttribute("aria-label", label);
|
|
summary.title = label;
|
|
menu.setAttribute("aria-label", label);
|
|
value.textContent = String(selected?.code || current || "en").toUpperCase();
|
|
menu.replaceChildren(...languages.map(language => preferenceButton({
|
|
value: language.code,
|
|
label: language.native_name || language.name || language.code,
|
|
selected: language.code === current,
|
|
languageCode: language.code,
|
|
})));
|
|
}
|
|
|
|
function renderThemePicker(details) {
|
|
const current = getTheme();
|
|
const summary = details.querySelector(".app-select-trigger");
|
|
const iconWrap = details.querySelector(".app-select-trigger__icon");
|
|
const value = details.querySelector(".app-select-trigger__value");
|
|
const menu = details.querySelector(".app-select-menu");
|
|
const label = t("profile.theme", {}, "Interface theme");
|
|
|
|
summary.setAttribute("aria-label", label);
|
|
summary.title = label;
|
|
menu.setAttribute("aria-label", label);
|
|
iconWrap.innerHTML = icon(current);
|
|
value.textContent = t(`theme.${current}`, {}, current === "light" ? "Light" : "Dark");
|
|
menu.replaceChildren(...THEMES.map(theme => preferenceButton({
|
|
value: theme,
|
|
label: t(`theme.${theme}`, {}, theme === "light" ? "Light" : "Dark"),
|
|
selected: theme === current,
|
|
})));
|
|
}
|
|
|
|
function renderPicker(details) {
|
|
if (details.dataset.preferenceKind === "language") renderLanguagePicker(details);
|
|
else renderThemePicker(details);
|
|
}
|
|
|
|
function renderAll() {
|
|
document.querySelectorAll(".app-select--header").forEach(renderPicker);
|
|
}
|
|
|
|
function bindPicker(details) {
|
|
details.querySelector(".app-select-menu")?.addEventListener("click", async event => {
|
|
const button = event.target.closest(".app-select-option");
|
|
if (!(button instanceof HTMLButtonElement)) return;
|
|
const next = button.dataset.value || "";
|
|
const kind = details.dataset.preferenceKind;
|
|
details.open = false;
|
|
|
|
if (kind === "language") {
|
|
const previous = getLanguage();
|
|
if (!next || next === previous) return;
|
|
await setLanguage(next);
|
|
renderAll();
|
|
try {
|
|
await persistAccountPreference({ language: next });
|
|
} catch (error) {
|
|
if (error?.status !== 401) {
|
|
await setLanguage(previous);
|
|
renderAll();
|
|
toast.danger(error.message, { title: t("profile.language.failed", {}, "Could not change language") });
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const previous = getTheme();
|
|
if (!THEMES.includes(next) || next === previous) return;
|
|
applyTheme(next);
|
|
renderAll();
|
|
try {
|
|
await persistAccountPreference({ theme: next });
|
|
} catch (error) {
|
|
if (error?.status !== 401) {
|
|
applyTheme(previous);
|
|
renderAll();
|
|
toast.danger(error.message, { title: t("profile.updateFailed", {}, "Could not update profile") });
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function mount(container) {
|
|
if (!(container instanceof HTMLElement) || container.dataset.headerPreferencesMounted === "true") return;
|
|
container.dataset.headerPreferencesMounted = "true";
|
|
container.setAttribute("data-i18n-ignore", "");
|
|
const language = createPicker("language");
|
|
const theme = createPicker("theme");
|
|
container.replaceChildren(language, theme);
|
|
bindPicker(language);
|
|
bindPicker(theme);
|
|
renderPicker(language);
|
|
renderPicker(theme);
|
|
}
|
|
|
|
export function initHeaderPreferences() {
|
|
document.querySelectorAll("[data-header-preferences]").forEach(mount);
|
|
if (initialized) return;
|
|
initialized = true;
|
|
|
|
document.addEventListener("pointerdown", event => {
|
|
const target = event.target instanceof Element ? event.target : null;
|
|
if (!target?.closest(".app-select--header")) closeOtherPickers();
|
|
}, { passive: true });
|
|
document.addEventListener("keydown", event => {
|
|
if (event.key === "Escape") closeOtherPickers();
|
|
});
|
|
document.addEventListener("rustpad:languagechange", renderAll);
|
|
window.addEventListener("rustpad:theme-change", renderAll);
|
|
window.addEventListener("rustpad:session-change", renderAll);
|
|
window.addEventListener("rustpad:session-expired", renderAll);
|
|
}
|