translations and pickers

This commit is contained in:
Mateusz Gruszczyński
2026-09-05 08:56:45 +02:00
parent e68ad69bc5
commit bf34a7cab1
18 changed files with 606 additions and 63 deletions
+5 -3
View File
@@ -8,7 +8,8 @@
*/
import { logDebug, logError, logWarn } from "@rustpad/logger";
import { formatNumber, setLanguage, translateApiMessage } from "@rustpad/i18n";
import { formatNumber, restoreGuestLanguage, translateApiMessage } from "@rustpad/i18n";
import { restoreGuestTheme } from "@rustpad/theme";
const DEFAULT_ERRORS = {
400: "Invalid request.",
@@ -70,10 +71,11 @@ function clearExpiredSession() {
sessionStorage.removeItem("rustpad:auth-token");
localStorage.removeItem("rustpad:nickname");
sessionStorage.removeItem("rustpad:nickname");
localStorage.removeItem("rustpad:language");
document.cookie = "rustpad_nickname=; Path=/; SameSite=Lax; Max-Age=0";
void setLanguage("en", { persist: false });
restoreGuestTheme();
void restoreGuestLanguage();
window.dispatchEvent(new CustomEvent("rustpad:session-expired"));
window.dispatchEvent(new CustomEvent("rustpad:session-change", { detail: { session: null } }));
}
async function clearSessionIfInvalid() {
+8 -6
View File
@@ -17,7 +17,7 @@ import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
import { safeAppUrl } from "@rustpad/security";
import { toast } from "@rustpad/toast";
import { formatDateTime, populateLanguageSelect, setLanguage, t } from "@rustpad/i18n";
import { formatDateTime, getLanguage, populateLanguageSelect, setLanguage, t } from "@rustpad/i18n";
function slugify(value, fallback) {
return value.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || fallback;
@@ -423,8 +423,9 @@ function renderAccount(session) {
: "";
}
userLabelPrimary.textContent = `Signed as ${primaryIdentity}`;
userLabelPrimary.title = `Signed as ${primaryIdentity}`;
const signedAs = t("profile.signedAs", { identity: primaryIdentity }, `Signed as ${primaryIdentity}`);
userLabelPrimary.textContent = signedAs;
userLabelPrimary.title = signedAs;
userLabelSecondary.textContent = secondaryIdentity;
userLabelSecondary.title = secondaryIdentity;
userLabelSecondary.hidden = !secondaryIdentity;
@@ -461,9 +462,9 @@ if (identityDialog) {
document.querySelector("#profile-nickname").value = currentSession?.nickname || "";
const profileColor = document.querySelector("#profile-color");
profileColor.value = currentSession?.editor_color || "#7c6cff";
const selectedTheme = currentSession?.theme || getTheme();
const selectedTheme = getTheme();
profileForm.querySelectorAll(`input[name="profile-theme"]`).forEach(input => { input.checked = input.value === selectedTheme; });
populateLanguageSelect(document.querySelector("#profile-language"), currentSession?.language || "en");
populateLanguageSelect(document.querySelector("#profile-language"), getLanguage());
document.querySelector("#profile-current-email").value = currentSession?.email || "";
document.querySelector("#profile-email").value = "";
document.querySelector("#profile-new-password").value = "";
@@ -486,8 +487,9 @@ if (identityDialog) {
profileDialog.showModal();
});
document.addEventListener("rustpad:languagechange", () => {
renderAccount(currentSession);
if (!profileDialog?.open) return;
populateLanguageSelect(document.querySelector("#profile-language"), currentSession?.language || "en");
populateLanguageSelect(document.querySelector("#profile-language"), getLanguage());
});
document.querySelector("#close-profile")?.addEventListener("click", () => profileDialog.close());
profileDialog?.addEventListener("click", event => { if (event.target === profileDialog) profileDialog.close(); });
+47 -11
View File
@@ -7,6 +7,8 @@
*/
const STORAGE_KEY = "rustpad:language";
const GUEST_STORAGE_KEY = "rustpad:guest-language";
const AUTH_STATE_KEY = "rustpad:auth-state";
const FALLBACK_LANGUAGE = "en";
const TRANSLATABLE_ATTRIBUTES = ["placeholder", "title", "aria-label", "aria-description"];
const SKIP_SELECTOR = [
@@ -285,21 +287,50 @@ async function loadBundle(code) {
return bundle;
}
function hasAccountSession() {
try { return localStorage.getItem(AUTH_STATE_KEY) === "1"; } catch { return false; }
}
function preferredCode() {
// English is always the default. A cached language is only used while an
// authenticated profile is expected; the server session remains the source
// of truth and will re-apply its saved preference after validation.
let hasAccountSession = false;
let saved = "";
try {
hasAccountSession = localStorage.getItem("rustpad:auth-state") === "1";
if (hasAccountSession) saved = localStorage.getItem(STORAGE_KEY) || "";
saved = hasAccountSession()
? (localStorage.getItem(STORAGE_KEY) || "")
: (localStorage.getItem(GUEST_STORAGE_KEY) || localStorage.getItem(STORAGE_KEY) || "");
} catch { /* storage may be blocked */ }
if (!hasAccountSession) return FALLBACK_LANGUAGE;
const available = new Set(catalog.map(item => item.code));
return available.has(saved) ? saved : FALLBACK_LANGUAGE;
}
function persistLanguage(code, scope = "auto") {
try {
localStorage.setItem(STORAGE_KEY, code);
const guestScope = scope === "guest" || (scope === "auto" && !hasAccountSession());
if (guestScope) localStorage.setItem(GUEST_STORAGE_KEY, code);
} catch { /* storage may be blocked */ }
}
export function rememberGuestLanguage(code = activeCode) {
try {
const available = new Set(catalog.map(item => item.code));
const saved = localStorage.getItem(GUEST_STORAGE_KEY);
if (available.has(saved)) return;
const value = available.has(code) ? code : FALLBACK_LANGUAGE;
localStorage.setItem(GUEST_STORAGE_KEY, value);
} catch { /* storage may be blocked */ }
}
export async function restoreGuestLanguage() {
let code = FALLBACK_LANGUAGE;
try { code = localStorage.getItem(GUEST_STORAGE_KEY) || FALLBACK_LANGUAGE; } catch { /* storage may be blocked */ }
const applied = await setLanguage(code, { persist: false });
try {
localStorage.setItem(STORAGE_KEY, applied);
localStorage.setItem(GUEST_STORAGE_KEY, applied);
} catch { /* storage may be blocked */ }
return applied;
}
function applyDocumentLanguage() {
const meta = activeBundle?.meta || fallbackBundle?.meta;
if (!meta) return;
@@ -307,16 +338,14 @@ function applyDocumentLanguage() {
document.documentElement.dataset.locale = meta.locale;
}
export async function setLanguage(code, { persist = true } = {}) {
export async function setLanguage(code, { persist = true, scope = "auto" } = {}) {
await initI18n();
const available = catalog.find(item => item.code === code);
const target = available ? available.code : FALLBACK_LANGUAGE;
activeBundle = await loadBundle(target);
activeCode = activeBundle.meta.code;
rebuildSourceIndex();
if (persist) {
try { localStorage.setItem(STORAGE_KEY, activeCode); } catch { /* storage may be blocked */ }
}
if (persist) persistLanguage(activeCode, scope);
applyDocumentLanguage();
translateTree(document, true);
document.dispatchEvent(new CustomEvent("rustpad:languagechange", {
@@ -473,6 +502,13 @@ export function populateLanguageSelect(input, selectedCode = getLanguage()) {
}));
}
window.addEventListener("storage", event => {
if (event.key !== STORAGE_KEY && event.key !== GUEST_STORAGE_KEY && event.key !== AUTH_STATE_KEY) return;
if (event.key === GUEST_STORAGE_KEY && hasAccountSession()) return;
const next = preferredCode();
if (next !== activeCode) void setLanguage(next, { persist: false });
});
export function formatDateTime(value, options) {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return String(value ?? "");
+221
View File
@@ -0,0 +1,221 @@
/*
* 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 '<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(".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);
}
+4 -1
View File
@@ -26,6 +26,9 @@ const lineLinksToggle = document.querySelector("#public-line-links-toggle");
const fullWidthToggle = document.querySelector("#public-full-width-toggle");
const fullWidthStorageKey = "rustpad:public-full-width";
const passwordDialog = document.querySelector("#public-password-dialog"), passwordForm = document.querySelector("#public-password-form"), passwordInput = document.querySelector("#public-password"), passwordError = document.querySelector("#public-password-error");
const publicTitle = document.querySelector("#public-title");
publicTitle?.removeAttribute("data-i18n");
if (publicTitle) publicTitle.textContent = t("common.loading", {}, "Loading…");
let pagePassword = "";
let mermaidRenderVersion = 0;
let publicAnchorSettleCleanup = null;
@@ -179,7 +182,7 @@ async function initialize() {
const page = await api(`/api/public/${encodeURIComponent(token)}`, { headers: pageHeaders() });
if (passwordDialog.open) passwordDialog.close();
passwordError.textContent = "";
document.querySelector("#public-title").textContent = page.title;
publicTitle.textContent = page.title;
document.querySelector("#public-meta").textContent = page.allow_task_updates
? t("public.updatedTasks", { date: formatDateTime(page.updated_at) }, `Updated: ${formatDateTime(page.updated_at)} · tasks can be updated`)
: t("public.updated", { date: formatDateTime(page.updated_at) }, `Updated: ${formatDateTime(page.updated_at)}`);
+12 -5
View File
@@ -7,8 +7,8 @@
* See LICENSE file in repository root for details.
*/
import { applySessionTheme } from "@rustpad/theme";
import { setLanguage } from "@rustpad/i18n";
import { applySessionTheme, rememberGuestTheme, restoreGuestTheme } from "@rustpad/theme";
import { getLanguage, rememberGuestLanguage, restoreGuestLanguage, setLanguage } from "@rustpad/i18n";
const ACCESS_STORAGE_VERSION_KEY = "rustpad:access-storage-version";
const ACCESS_STORAGE_VERSION = "http-only-cookie-v1";
@@ -84,10 +84,16 @@ localStorage.removeItem("rustpad:auth-token");
sessionStorage.removeItem("rustpad:auth-token");
export function getAuthToken() { return localStorage.getItem(AUTH_STATE_KEY) ? "cookie" : ""; }
export async function setAuthSession(session) {
const hadAccountSession = localStorage.getItem(AUTH_STATE_KEY) === "1";
if (!hadAccountSession) {
rememberGuestTheme();
rememberGuestLanguage(getLanguage());
}
localStorage.setItem(AUTH_STATE_KEY, "1");
setNickname(session.nickname);
applySessionTheme(session);
await setLanguage(session?.language || "en");
await setLanguage(session?.language || "en", { scope: "account" });
window.dispatchEvent(new CustomEvent("rustpad:session-change", { detail: { session } }));
}
export async function clearAuthSession() {
localStorage.removeItem(AUTH_STATE_KEY);
@@ -95,7 +101,8 @@ export async function clearAuthSession() {
sessionStorage.removeItem("rustpad:auth-token");
localStorage.removeItem(NICKNAME_KEY);
sessionStorage.removeItem(NICKNAME_KEY);
localStorage.removeItem("rustpad:language");
setNicknameCookie("");
await setLanguage("en", { persist: false });
restoreGuestTheme();
await restoreGuestLanguage();
window.dispatchEvent(new CustomEvent("rustpad:session-change", { detail: { session: null } }));
}
+48 -8
View File
@@ -7,6 +7,8 @@
* See LICENSE file in repository root for details.
*/
const STORAGE_KEY = "rustpad:theme";
const GUEST_STORAGE_KEY = "rustpad:guest-theme";
const AUTH_STATE_KEY = "rustpad:auth-state";
const THEMES = new Set(["dark", "light"]);
const systemThemeQuery = window.matchMedia("(prefers-color-scheme: light)");
@@ -28,31 +30,69 @@ export function getTheme() {
return normalizeTheme(document.documentElement.dataset.theme);
}
export function applyTheme(value, { persist = true } = {}) {
function hasAccountSession() {
try { return localStorage.getItem(AUTH_STATE_KEY) === "1"; } catch { return false; }
}
function persistTheme(theme, scope = "auto") {
try {
localStorage.setItem(STORAGE_KEY, theme);
const guestScope = scope === "guest" || (scope === "auto" && !hasAccountSession());
if (guestScope) localStorage.setItem(GUEST_STORAGE_KEY, theme);
} catch { }
}
export function applyTheme(value, { persist = true, scope = "auto" } = {}) {
const theme = normalizeTheme(value);
const previousTheme = getTheme();
updateBrowserChrome(theme);
if (persist) {
try { localStorage.setItem(STORAGE_KEY, theme); } catch { }
}
if (persist) persistTheme(theme, scope);
if (theme !== previousTheme) {
window.dispatchEvent(new CustomEvent("rustpad:theme-change", { detail: { theme } }));
}
return theme;
}
export function rememberGuestTheme(value = getTheme()) {
try {
if (THEMES.has(localStorage.getItem(GUEST_STORAGE_KEY))) return;
localStorage.setItem(GUEST_STORAGE_KEY, normalizeTheme(value));
} catch { }
}
export function restoreGuestTheme() {
let saved = null;
try { saved = localStorage.getItem(GUEST_STORAGE_KEY); } catch { }
if (THEMES.has(saved)) {
try { localStorage.setItem(STORAGE_KEY, saved); } catch { }
return applyTheme(saved, { persist: false });
}
try { localStorage.removeItem(STORAGE_KEY); } catch { }
return applyTheme(systemTheme(), { persist: false });
}
export function applySessionTheme(session) {
if (session?.theme) applyTheme(session.theme);
if (session?.theme) applyTheme(session.theme, { scope: "account" });
}
window.addEventListener("storage", event => {
if (event.key !== STORAGE_KEY) return;
applyTheme(THEMES.has(event.newValue) ? event.newValue : systemTheme(), { persist: false });
if (event.key !== STORAGE_KEY && event.key !== GUEST_STORAGE_KEY && event.key !== AUTH_STATE_KEY) return;
if (event.key === GUEST_STORAGE_KEY && hasAccountSession()) return;
let saved = null;
try {
saved = hasAccountSession()
? localStorage.getItem(STORAGE_KEY)
: (localStorage.getItem(GUEST_STORAGE_KEY) || localStorage.getItem(STORAGE_KEY));
} catch { }
applyTheme(THEMES.has(saved) ? saved : systemTheme(), { persist: false });
});
systemThemeQuery.addEventListener("change", () => {
try {
if (THEMES.has(localStorage.getItem(STORAGE_KEY))) return;
const saved = hasAccountSession()
? localStorage.getItem(STORAGE_KEY)
: (localStorage.getItem(GUEST_STORAGE_KEY) || localStorage.getItem(STORAGE_KEY));
if (THEMES.has(saved)) return;
} catch { }
applyTheme(systemTheme(), { persist: false });
});