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
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]]
name = "rustpad"
version = "0.2.64"
version = "0.2.65"
dependencies = [
"argon2",
"aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rustpad"
version = "0.2.64"
version = "0.2.65"
edition = "2024"
rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
+12 -7
View File
@@ -1,11 +1,16 @@
Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License.
This software is available under the GNU General Public License version 3
(GPLv3). GPLv3 permits commercial use, modification, and redistribution,
provided that its terms are followed.
COMMERCIAL LICENSE NOTICE:
Commercial use in proprietary applications (including by linuxiarz.pl Mateusz Gruszczyński)
is not permitted under GPLv3 without an explicit Commercial License Agreement.
Contact: linuxiarz<at>>linuxiarz.pl
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation, version 3 of the License.
COMMERCIAL LICENSING NOTICE:
If you want to distribute this software or a derivative work as part of a
proprietary (closed-source) product without the obligations imposed by GPLv3,
contact the copyright holder about a separate commercial license.
Contact: linuxiarz<at>linuxiarz.pl
+4 -4
View File
@@ -7,10 +7,10 @@
},
"translations": {
"about.author": "Author",
"about.commercial": "Commercial use in proprietary applications (including by linuxiarz.pl Mateusz Gruszczyński) is not permitted under GPLv3 without an explicit Commercial License Agreement.",
"about.commercialTitle": "COMMERCIAL LICENSE NOTICE:",
"about.commercial": "If you want to distribute this software or a derivative work as part of a proprietary (closed-source) product without the obligations imposed by GPLv3, contact us about a separate commercial license.",
"about.commercialTitle": "COMMERCIAL LICENSING NOTICE:",
"about.copy": "RustPad project information and licensing.",
"about.gpl": "This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License.",
"about.gpl": "This software is available under GNU GPLv3, which also permits commercial use provided that its terms are followed.",
"about.license": "Licence",
"about.repository": "Git repository",
"api.accountAlreadyConfirmed": "This account is already confirmed.",
@@ -921,4 +921,4 @@
"error.document.methodNotAllowed": "Method not allowed · RustPad",
"error.document.serverError": "Server error · RustPad"
}
}
}
+5 -5
View File
@@ -7,10 +7,10 @@
},
"translations": {
"about.author": "Autor",
"about.commercial": "Komercyjne użycie w aplikacjach własnościowych (w tym przez linuxiarz.pl Mateusz Gruszczyński) nie jest dozwolone na mocy GPLv3 bez wyraźnej umowy licencji komercyjnej.",
"about.commercialTitle": "INFORMACJA O LICENCJI KOMERCYJNEJ:",
"about.copy": "Informacje o projekcie RustPad i licencji.",
"about.gpl": "Ten program jest wolnym oprogramowaniem: możesz go rozpowszechniać i/lub modyfikować na warunkach GNU General Public License opublikowanej przez Free Software Foundation, w wersji 3 licencji.",
"about.commercial": "Jeżeli chcesz rozpowszechniać to oprogramowanie lub utwór pochodny jako część zamkniętego (własnościowego) produktu bez obowiązków wynikających z GPLv3, skontaktuj się w sprawie odrębnej licencji komercyjnej.",
"about.commercialTitle": "INFORMACJA O LICENCJONOWANIU KOMERCYJNYM:",
"about.copy": "Informacje o projekcie RustPad oraz zasadach licencjonowania.",
"about.gpl": "To oprogramowanie jest dostępne na warunkach GNU GPLv3, która zezwala również na użycie komercyjne pod warunkiem przestrzegania jej postanowień.",
"about.license": "Licencja",
"about.repository": "Repozytorium Git",
"api.accountAlreadyConfirmed": "To konto jest już potwierdzone.",
@@ -921,4 +921,4 @@
"error.document.methodNotAllowed": "Metoda niedozwolona · RustPad",
"error.document.serverError": "Błąd serwera · RustPad"
}
}
}
+4 -3
View File
@@ -33,6 +33,7 @@ const MODULES: &[&str] = &[
"note-editor",
"note-files",
"preview-edit",
"preferences",
"render-queue",
"session",
"socket",
@@ -93,11 +94,11 @@ pub fn render_html(
}
pub fn theme_bootstrap() -> &'static str {
r#"<script>(()=>{const key="rustpad:theme";let theme=matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";try{const saved=localStorage.getItem(key);if(saved==="light"||saved==="dark")theme=saved}catch{}const root=document.documentElement;root.dataset.theme=theme;root.style.colorScheme=theme;const meta=document.querySelector('meta[name="color-scheme"]');if(meta)meta.content=theme})();</script>"#
r#"<script>(()=>{const key="rustpad:theme",guestKey="rustpad:guest-theme",authKey="rustpad:auth-state";let theme=matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";try{const signedIn=localStorage.getItem(authKey)==="1";const saved=signedIn?localStorage.getItem(key):(localStorage.getItem(guestKey)||localStorage.getItem(key));if(saved==="light"||saved==="dark")theme=saved}catch{}const root=document.documentElement;root.dataset.theme=theme;root.style.colorScheme=theme;const meta=document.querySelector('meta[name="color-scheme"]');if(meta)meta.content=theme})();</script>"#
}
pub fn language_bootstrap() -> &'static str {
r#"<script>(()=>{const key="rustpad:language";let lang="en";try{if(localStorage.getItem("rustpad:auth-state")==="1"){const saved=localStorage.getItem(key);if(saved)lang=saved}}catch{}const root=document.documentElement;root.lang=lang;if(lang!=="en"){root.dataset.i18nPending="true";root.style.visibility="hidden"}})();</script>"#
r#"<script>(()=>{const key="rustpad:language",guestKey="rustpad:guest-language",authKey="rustpad:auth-state";let lang="en";try{const signedIn=localStorage.getItem(authKey)==="1";const saved=signedIn?localStorage.getItem(key):(localStorage.getItem(guestKey)||localStorage.getItem(key));if(saved)lang=saved}catch{}const root=document.documentElement;root.lang=lang;if(lang!=="en"){root.dataset.i18nPending="true";root.style.visibility="hidden"}})();</script>"#
}
pub fn stylesheet_tag(asset_version: &str, name: &str) -> String {
@@ -151,7 +152,7 @@ impl<'a> AssetUrls<'a> {
fn entrypoint(&self, name: &str) -> String {
let entrypoint = self.url(&format!("js/{name}.js"));
format!(
r#"<script type="module">import {{ initI18n }} from "@rustpad/i18n";await initI18n();await import("{}");</script>"#,
r#"<script type="module">import {{ initI18n }} from "@rustpad/i18n";import {{ initHeaderPreferences }} from "@rustpad/preferences";await initI18n();initHeaderPreferences();await import("{}");</script>"#,
escape_js_string(&entrypoint)
)
}
+224
View File
@@ -8102,3 +8102,227 @@ dialog::backdrop {
transform: none;
}
}
/* Compact interface preferences in application headers. */
.header-preferences {
display: flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
}
.header-preference-picker {
position: relative;
flex: 0 0 auto;
}
.header-preference-picker > summary {
list-style: none;
}
.header-preference-picker > summary::-webkit-details-marker {
display: none;
}
.header-preference-trigger {
display: inline-flex;
min-width: 38px;
height: 36px;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 9px;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in srgb, var(--surface-2) 78%, transparent);
color: var(--muted);
cursor: pointer;
user-select: none;
transition: background-color .14s ease, border-color .14s ease, color .14s ease, box-shadow .14s ease;
}
.header-preference-trigger:hover,
.header-preference-picker[open] > .header-preference-trigger {
border-color: var(--border-strong);
background: var(--surface-3);
color: var(--text);
}
.header-preference-trigger:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.header-preference-trigger__icon {
display: grid;
width: 17px;
height: 17px;
flex: 0 0 17px;
place-items: center;
}
.header-preference-trigger__icon svg {
width: 17px;
height: 17px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.7;
}
.header-preference-trigger__value {
max-width: 72px;
overflow: hidden;
color: var(--text);
font-size: .72rem;
font-weight: 750;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-preference-picker--language .header-preference-trigger__value {
min-width: 18px;
text-align: center;
letter-spacing: .04em;
}
.header-preference-trigger__chevron {
color: var(--muted-2);
font-size: .68rem;
line-height: 1;
transition: transform .14s ease;
}
.header-preference-picker[open] .header-preference-trigger__chevron {
transform: rotate(180deg);
}
.header-preference-menu {
position: absolute;
top: calc(100% + 7px);
right: 0;
z-index: 80;
display: grid;
width: max-content;
min-width: 164px;
max-width: min(260px, calc(100vw - 24px));
gap: 2px;
padding: 5px;
border: 1px solid var(--border-strong);
border-radius: 10px;
background: var(--surface-floating);
box-shadow: 0 14px 34px var(--shadow-38), inset 0 1px 0 var(--wash-soft);
}
.header-preference-picker--language .header-preference-menu {
min-width: 190px;
}
.header-preference-option {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
gap: 7px;
width: 100%;
min-height: 36px;
padding: 6px 9px;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
color: var(--text);
text-align: left;
}
.header-preference-option:hover,
.header-preference-option:focus-visible {
border-color: var(--border-strong);
outline: 0;
background: var(--surface-hover);
}
.header-preference-option[aria-selected="true"] {
border-color: var(--accent-a35);
background: var(--accent-soft);
}
.header-preference-option__check {
display: grid;
width: 18px;
height: 18px;
place-items: center;
color: var(--accent-text);
font-size: .72rem;
font-weight: 900;
}
.header-preference-option__copy {
overflow: hidden;
font-size: .78rem;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-header {
justify-content: space-between;
}
@media (max-width: 720px) {
.header-preference-trigger {
min-width: 36px;
height: 34px;
padding-inline: 8px;
}
.header-preference-picker--theme .header-preference-trigger__value,
.header-preference-trigger__chevron {
display: none;
}
.public-header {
gap: 8px;
padding-block: 8px;
}
.public-header__actions {
justify-content: flex-end;
gap: 7px;
flex-wrap: wrap;
}
}
@media (max-width: 520px) {
.header-preferences {
gap: 4px;
}
.header-preference-trigger {
width: 34px;
min-width: 34px;
height: 34px;
padding: 0;
}
.header-preference-trigger__value {
display: none;
}
.header-preference-menu {
max-width: calc(100vw - 20px);
}
.public-header {
align-items: flex-start;
flex-wrap: wrap;
}
.public-header__actions {
width: 100%;
}
.public-header__actions .header-preferences {
margin-left: auto;
}
}
+1
View File
@@ -33,6 +33,7 @@
class="use-global-color" type="button" title="Use global profile color"
aria-label="Use global profile color" hidden>↺</button></span>
<div class="header-navigation">
<div class="header-preferences" data-header-preferences></div>
<button id="header-menu-toggle" class="header-menu-toggle" type="button" aria-expanded="false"
aria-controls="header-actions" aria-label="Open navigation menu">
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
+6 -6
View File
@@ -13,7 +13,7 @@
</head>
<body class="home-page" data-registration-enabled="__REGISTRATION_ENABLED__">
<header class="site-header home-header"><a class="brand home-brand" href="/">RustPad</a></header>
<header class="site-header home-header"><a class="brand home-brand" href="/">RustPad</a><div class="header-preferences" data-header-preferences></div></header>
<main class="home-layout home-layout--wide">
<section class="home-intro">
<h1>Write. Share. Collaborate.</h1>
@@ -105,11 +105,11 @@
<div class="about-license">
<strong>Licence</strong>
<p>Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl</p>
<p>This program is free software: you can redistribute it and/or modify it under the terms of the GNU General
Public License as published by the Free Software Foundation, either version 3 of the License.</p>
<p><strong>COMMERCIAL LICENSE NOTICE:</strong> Commercial use in proprietary applications (including by
linuxiarz.pl Mateusz Gruszczyński) is not permitted under GPLv3 without an explicit Commercial License
Agreement. <br>Contact: linuxiarz&lt;at&gt;linuxiarz.pl</p>
<p>This software is available under GNU GPLv3, which also permits commercial use provided that its terms are
followed.</p>
<p><strong>COMMERCIAL LICENSING NOTICE:</strong> If you want to distribute this software or a derivative work as
part of a proprietary (closed-source) product without the obligations imposed by GPLv3, contact us about a
separate commercial license. <br>Contact: linuxiarz&lt;at&gt;linuxiarz.pl</p>
</div>
</div>
</div>
+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 });
});
+2 -1
View File
@@ -16,6 +16,7 @@
<header class="public-header">
<a class="brand home-brand" href="/">RustPad</a>
<div class="public-header__actions">
<div class="header-preferences" data-header-preferences></div>
<label class="line-toggle"><input id="public-line-numbers-toggle" type="checkbox"> Line numbers</label>
<label class="line-toggle"><input id="public-line-links-toggle" type="checkbox"> Line links</label>
<label class="line-toggle"><input id="public-full-width-toggle" type="checkbox"> Full width</label>
@@ -23,7 +24,7 @@
</div>
</header>
<main class="public-document">
<h1 id="public-title">Loading…</h1>
<h1 id="public-title" data-i18n="common.loading">Loading…</h1>
<p id="public-meta" class="public-meta"></p>
<article id="public-content" class="markdown-body public-content"></article>
</main>
+1 -1
View File
@@ -25,7 +25,7 @@
<p id="workspace-url" class="document-url"></p>
</div>
</div>
<div class="header-actions"><button id="copy-workspace-link" class="secondary-button">Copy link</button></div>
<div class="header-actions"><div class="header-preferences" data-header-preferences></div><button id="copy-workspace-link" class="secondary-button">Copy link</button></div>
</header>
<main id="workspace-content" class="workspace-page" hidden>
<section class="workspace-top">