/* * Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl * Source-Available Code / Dual-Licensed. */ 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 ''; } if (name === "light") { return ''; } return ''; } function closeOtherPickers(except = null) { document.querySelectorAll(".header-preference-picker[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 = "header-preference-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 = "header-preference-option__check"; check.setAttribute("aria-hidden", "true"); check.textContent = selected ? "✓" : ""; const copy = document.createElement("span"); copy.className = "header-preference-option__copy"; copy.textContent = label; button.append(check, copy); return button; } function createPicker(kind) { const details = document.createElement("details"); details.className = `header-preference-picker header-preference-picker--${kind}`; details.dataset.preferenceKind = kind; const summary = document.createElement("summary"); summary.className = "header-preference-trigger"; const iconWrap = document.createElement("span"); iconWrap.className = "header-preference-trigger__icon"; iconWrap.innerHTML = icon(kind === "theme" ? getTheme() : "language"); const compactValue = document.createElement("span"); compactValue.className = "header-preference-trigger__value"; compactValue.setAttribute("aria-hidden", "true"); const chevron = document.createElement("span"); chevron.className = "header-preference-trigger__chevron"; chevron.setAttribute("aria-hidden", "true"); chevron.textContent = "▾"; summary.append(iconWrap, compactValue, chevron); const menu = document.createElement("div"); menu.className = "header-preference-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(".header-preference-trigger"); const value = details.querySelector(".header-preference-trigger__value"); const menu = details.querySelector(".header-preference-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(".header-preference-trigger"); const iconWrap = details.querySelector(".header-preference-trigger__icon"); const value = details.querySelector(".header-preference-trigger__value"); const menu = details.querySelector(".header-preference-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(".header-preference-picker").forEach(renderPicker); } function bindPicker(details) { details.querySelector(".header-preference-menu")?.addEventListener("click", async event => { const button = event.target.closest(".header-preference-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(".header-preference-picker")) 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); }