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]] [[package]]
name = "rustpad" name = "rustpad"
version = "0.2.64" version = "0.2.65"
dependencies = [ dependencies = [
"argon2", "argon2",
"aws-config", "aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustpad" name = "rustpad"
version = "0.2.64" version = "0.2.65"
edition = "2024" edition = "2024"
rust-version = "1.94" rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" 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 Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
This program is free software: you can redistribute it and/or modify This software is available under the GNU General Public License version 3
it under the terms of the GNU General Public License as published by (GPLv3). GPLv3 permits commercial use, modification, and redistribution,
the Free Software Foundation, either version 3 of the License. provided that its terms are followed.
COMMERCIAL LICENSE NOTICE: This program is free software: you can redistribute it and/or modify it under
Commercial use in proprietary applications (including by linuxiarz.pl Mateusz Gruszczyński) the terms of the GNU General Public License as published by the Free Software
is not permitted under GPLv3 without an explicit Commercial License Agreement. Foundation, version 3 of the License.
Contact: linuxiarz<at>>linuxiarz.pl
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": { "translations": {
"about.author": "Author", "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.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 LICENSE NOTICE:", "about.commercialTitle": "COMMERCIAL LICENSING NOTICE:",
"about.copy": "RustPad project information and licensing.", "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.license": "Licence",
"about.repository": "Git repository", "about.repository": "Git repository",
"api.accountAlreadyConfirmed": "This account is already confirmed.", "api.accountAlreadyConfirmed": "This account is already confirmed.",
@@ -921,4 +921,4 @@
"error.document.methodNotAllowed": "Method not allowed · RustPad", "error.document.methodNotAllowed": "Method not allowed · RustPad",
"error.document.serverError": "Server error · RustPad" "error.document.serverError": "Server error · RustPad"
} }
} }
+5 -5
View File
@@ -7,10 +7,10 @@
}, },
"translations": { "translations": {
"about.author": "Autor", "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.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 LICENCJI KOMERCYJNEJ:", "about.commercialTitle": "INFORMACJA O LICENCJONOWANIU KOMERCYJNYM:",
"about.copy": "Informacje o projekcie RustPad i licencji.", "about.copy": "Informacje o projekcie RustPad oraz zasadach licencjonowania.",
"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.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.license": "Licencja",
"about.repository": "Repozytorium Git", "about.repository": "Repozytorium Git",
"api.accountAlreadyConfirmed": "To konto jest już potwierdzone.", "api.accountAlreadyConfirmed": "To konto jest już potwierdzone.",
@@ -921,4 +921,4 @@
"error.document.methodNotAllowed": "Metoda niedozwolona · RustPad", "error.document.methodNotAllowed": "Metoda niedozwolona · RustPad",
"error.document.serverError": "Błąd serwera · RustPad" "error.document.serverError": "Błąd serwera · RustPad"
} }
} }
+4 -3
View File
@@ -33,6 +33,7 @@ const MODULES: &[&str] = &[
"note-editor", "note-editor",
"note-files", "note-files",
"preview-edit", "preview-edit",
"preferences",
"render-queue", "render-queue",
"session", "session",
"socket", "socket",
@@ -93,11 +94,11 @@ pub fn render_html(
} }
pub fn theme_bootstrap() -> &'static str { 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 { 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 { pub fn stylesheet_tag(asset_version: &str, name: &str) -> String {
@@ -151,7 +152,7 @@ impl<'a> AssetUrls<'a> {
fn entrypoint(&self, name: &str) -> String { fn entrypoint(&self, name: &str) -> String {
let entrypoint = self.url(&format!("js/{name}.js")); let entrypoint = self.url(&format!("js/{name}.js"));
format!( 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) escape_js_string(&entrypoint)
) )
} }
+224
View File
@@ -8102,3 +8102,227 @@ dialog::backdrop {
transform: none; 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" class="use-global-color" type="button" title="Use global profile color"
aria-label="Use global profile color" hidden></button></span> aria-label="Use global profile color" hidden></button></span>
<div class="header-navigation"> <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" <button id="header-menu-toggle" class="header-menu-toggle" type="button" aria-expanded="false"
aria-controls="header-actions" aria-label="Open navigation menu"> aria-controls="header-actions" aria-label="Open navigation menu">
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span> <span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
+6 -6
View File
@@ -13,7 +13,7 @@
</head> </head>
<body class="home-page" data-registration-enabled="__REGISTRATION_ENABLED__"> <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"> <main class="home-layout home-layout--wide">
<section class="home-intro"> <section class="home-intro">
<h1>Write. Share. Collaborate.</h1> <h1>Write. Share. Collaborate.</h1>
@@ -105,11 +105,11 @@
<div class="about-license"> <div class="about-license">
<strong>Licence</strong> <strong>Licence</strong>
<p>Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl</p> <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 <p>This software is available under GNU GPLv3, which also permits commercial use provided that its terms are
Public License as published by the Free Software Foundation, either version 3 of the License.</p> followed.</p>
<p><strong>COMMERCIAL LICENSE NOTICE:</strong> Commercial use in proprietary applications (including by <p><strong>COMMERCIAL LICENSING NOTICE:</strong> If you want to distribute this software or a derivative work as
linuxiarz.pl Mateusz Gruszczyński) is not permitted under GPLv3 without an explicit Commercial License part of a proprietary (closed-source) product without the obligations imposed by GPLv3, contact us about a
Agreement. <br>Contact: linuxiarz&lt;at&gt;linuxiarz.pl</p> separate commercial license. <br>Contact: linuxiarz&lt;at&gt;linuxiarz.pl</p>
</div> </div>
</div> </div>
</div> </div>
+5 -3
View File
@@ -8,7 +8,8 @@
*/ */
import { logDebug, logError, logWarn } from "@rustpad/logger"; 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 = { const DEFAULT_ERRORS = {
400: "Invalid request.", 400: "Invalid request.",
@@ -70,10 +71,11 @@ function clearExpiredSession() {
sessionStorage.removeItem("rustpad:auth-token"); sessionStorage.removeItem("rustpad:auth-token");
localStorage.removeItem("rustpad:nickname"); localStorage.removeItem("rustpad:nickname");
sessionStorage.removeItem("rustpad:nickname"); sessionStorage.removeItem("rustpad:nickname");
localStorage.removeItem("rustpad:language");
document.cookie = "rustpad_nickname=; Path=/; SameSite=Lax; Max-Age=0"; 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-expired"));
window.dispatchEvent(new CustomEvent("rustpad:session-change", { detail: { session: null } }));
} }
async function clearSessionIfInvalid() { async function clearSessionIfInvalid() {
+8 -6
View File
@@ -17,7 +17,7 @@ import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard"; import { copyText } from "@rustpad/clipboard";
import { safeAppUrl } from "@rustpad/security"; import { safeAppUrl } from "@rustpad/security";
import { toast } from "@rustpad/toast"; 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) { function slugify(value, fallback) {
return value.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || 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}`; const signedAs = t("profile.signedAs", { identity: primaryIdentity }, `Signed as ${primaryIdentity}`);
userLabelPrimary.title = `Signed as ${primaryIdentity}`; userLabelPrimary.textContent = signedAs;
userLabelPrimary.title = signedAs;
userLabelSecondary.textContent = secondaryIdentity; userLabelSecondary.textContent = secondaryIdentity;
userLabelSecondary.title = secondaryIdentity; userLabelSecondary.title = secondaryIdentity;
userLabelSecondary.hidden = !secondaryIdentity; userLabelSecondary.hidden = !secondaryIdentity;
@@ -461,9 +462,9 @@ if (identityDialog) {
document.querySelector("#profile-nickname").value = currentSession?.nickname || ""; document.querySelector("#profile-nickname").value = currentSession?.nickname || "";
const profileColor = document.querySelector("#profile-color"); const profileColor = document.querySelector("#profile-color");
profileColor.value = currentSession?.editor_color || "#7c6cff"; 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; }); 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-current-email").value = currentSession?.email || "";
document.querySelector("#profile-email").value = ""; document.querySelector("#profile-email").value = "";
document.querySelector("#profile-new-password").value = ""; document.querySelector("#profile-new-password").value = "";
@@ -486,8 +487,9 @@ if (identityDialog) {
profileDialog.showModal(); profileDialog.showModal();
}); });
document.addEventListener("rustpad:languagechange", () => { document.addEventListener("rustpad:languagechange", () => {
renderAccount(currentSession);
if (!profileDialog?.open) return; 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()); document.querySelector("#close-profile")?.addEventListener("click", () => profileDialog.close());
profileDialog?.addEventListener("click", event => { if (event.target === profileDialog) 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 STORAGE_KEY = "rustpad:language";
const GUEST_STORAGE_KEY = "rustpad:guest-language";
const AUTH_STATE_KEY = "rustpad:auth-state";
const FALLBACK_LANGUAGE = "en"; const FALLBACK_LANGUAGE = "en";
const TRANSLATABLE_ATTRIBUTES = ["placeholder", "title", "aria-label", "aria-description"]; const TRANSLATABLE_ATTRIBUTES = ["placeholder", "title", "aria-label", "aria-description"];
const SKIP_SELECTOR = [ const SKIP_SELECTOR = [
@@ -285,21 +287,50 @@ async function loadBundle(code) {
return bundle; return bundle;
} }
function hasAccountSession() {
try { return localStorage.getItem(AUTH_STATE_KEY) === "1"; } catch { return false; }
}
function preferredCode() { 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 = ""; let saved = "";
try { try {
hasAccountSession = localStorage.getItem("rustpad:auth-state") === "1"; saved = hasAccountSession()
if (hasAccountSession) saved = localStorage.getItem(STORAGE_KEY) || ""; ? (localStorage.getItem(STORAGE_KEY) || "")
: (localStorage.getItem(GUEST_STORAGE_KEY) || localStorage.getItem(STORAGE_KEY) || "");
} catch { /* storage may be blocked */ } } catch { /* storage may be blocked */ }
if (!hasAccountSession) return FALLBACK_LANGUAGE;
const available = new Set(catalog.map(item => item.code)); const available = new Set(catalog.map(item => item.code));
return available.has(saved) ? saved : FALLBACK_LANGUAGE; 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() { function applyDocumentLanguage() {
const meta = activeBundle?.meta || fallbackBundle?.meta; const meta = activeBundle?.meta || fallbackBundle?.meta;
if (!meta) return; if (!meta) return;
@@ -307,16 +338,14 @@ function applyDocumentLanguage() {
document.documentElement.dataset.locale = meta.locale; document.documentElement.dataset.locale = meta.locale;
} }
export async function setLanguage(code, { persist = true } = {}) { export async function setLanguage(code, { persist = true, scope = "auto" } = {}) {
await initI18n(); await initI18n();
const available = catalog.find(item => item.code === code); const available = catalog.find(item => item.code === code);
const target = available ? available.code : FALLBACK_LANGUAGE; const target = available ? available.code : FALLBACK_LANGUAGE;
activeBundle = await loadBundle(target); activeBundle = await loadBundle(target);
activeCode = activeBundle.meta.code; activeCode = activeBundle.meta.code;
rebuildSourceIndex(); rebuildSourceIndex();
if (persist) { if (persist) persistLanguage(activeCode, scope);
try { localStorage.setItem(STORAGE_KEY, activeCode); } catch { /* storage may be blocked */ }
}
applyDocumentLanguage(); applyDocumentLanguage();
translateTree(document, true); translateTree(document, true);
document.dispatchEvent(new CustomEvent("rustpad:languagechange", { 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) { export function formatDateTime(value, options) {
const date = value instanceof Date ? value : new Date(value); const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return String(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 fullWidthToggle = document.querySelector("#public-full-width-toggle");
const fullWidthStorageKey = "rustpad:public-full-width"; 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 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 pagePassword = "";
let mermaidRenderVersion = 0; let mermaidRenderVersion = 0;
let publicAnchorSettleCleanup = null; let publicAnchorSettleCleanup = null;
@@ -179,7 +182,7 @@ async function initialize() {
const page = await api(`/api/public/${encodeURIComponent(token)}`, { headers: pageHeaders() }); const page = await api(`/api/public/${encodeURIComponent(token)}`, { headers: pageHeaders() });
if (passwordDialog.open) passwordDialog.close(); if (passwordDialog.open) passwordDialog.close();
passwordError.textContent = ""; passwordError.textContent = "";
document.querySelector("#public-title").textContent = page.title; publicTitle.textContent = page.title;
document.querySelector("#public-meta").textContent = page.allow_task_updates 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.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)}`); : 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. * See LICENSE file in repository root for details.
*/ */
import { applySessionTheme } from "@rustpad/theme"; import { applySessionTheme, rememberGuestTheme, restoreGuestTheme } from "@rustpad/theme";
import { setLanguage } from "@rustpad/i18n"; import { getLanguage, rememberGuestLanguage, restoreGuestLanguage, setLanguage } from "@rustpad/i18n";
const ACCESS_STORAGE_VERSION_KEY = "rustpad:access-storage-version"; const ACCESS_STORAGE_VERSION_KEY = "rustpad:access-storage-version";
const ACCESS_STORAGE_VERSION = "http-only-cookie-v1"; const ACCESS_STORAGE_VERSION = "http-only-cookie-v1";
@@ -84,10 +84,16 @@ localStorage.removeItem("rustpad:auth-token");
sessionStorage.removeItem("rustpad:auth-token"); sessionStorage.removeItem("rustpad:auth-token");
export function getAuthToken() { return localStorage.getItem(AUTH_STATE_KEY) ? "cookie" : ""; } export function getAuthToken() { return localStorage.getItem(AUTH_STATE_KEY) ? "cookie" : ""; }
export async function setAuthSession(session) { export async function setAuthSession(session) {
const hadAccountSession = localStorage.getItem(AUTH_STATE_KEY) === "1";
if (!hadAccountSession) {
rememberGuestTheme();
rememberGuestLanguage(getLanguage());
}
localStorage.setItem(AUTH_STATE_KEY, "1"); localStorage.setItem(AUTH_STATE_KEY, "1");
setNickname(session.nickname); setNickname(session.nickname);
applySessionTheme(session); 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() { export async function clearAuthSession() {
localStorage.removeItem(AUTH_STATE_KEY); localStorage.removeItem(AUTH_STATE_KEY);
@@ -95,7 +101,8 @@ export async function clearAuthSession() {
sessionStorage.removeItem("rustpad:auth-token"); sessionStorage.removeItem("rustpad:auth-token");
localStorage.removeItem(NICKNAME_KEY); localStorage.removeItem(NICKNAME_KEY);
sessionStorage.removeItem(NICKNAME_KEY); sessionStorage.removeItem(NICKNAME_KEY);
localStorage.removeItem("rustpad:language");
setNicknameCookie(""); 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. * See LICENSE file in repository root for details.
*/ */
const STORAGE_KEY = "rustpad:theme"; 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 THEMES = new Set(["dark", "light"]);
const systemThemeQuery = window.matchMedia("(prefers-color-scheme: light)"); const systemThemeQuery = window.matchMedia("(prefers-color-scheme: light)");
@@ -28,31 +30,69 @@ export function getTheme() {
return normalizeTheme(document.documentElement.dataset.theme); 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 theme = normalizeTheme(value);
const previousTheme = getTheme(); const previousTheme = getTheme();
updateBrowserChrome(theme); updateBrowserChrome(theme);
if (persist) { if (persist) persistTheme(theme, scope);
try { localStorage.setItem(STORAGE_KEY, theme); } catch { }
}
if (theme !== previousTheme) { if (theme !== previousTheme) {
window.dispatchEvent(new CustomEvent("rustpad:theme-change", { detail: { theme } })); window.dispatchEvent(new CustomEvent("rustpad:theme-change", { detail: { theme } }));
} }
return 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) { export function applySessionTheme(session) {
if (session?.theme) applyTheme(session.theme); if (session?.theme) applyTheme(session.theme, { scope: "account" });
} }
window.addEventListener("storage", event => { window.addEventListener("storage", event => {
if (event.key !== STORAGE_KEY) return; if (event.key !== STORAGE_KEY && event.key !== GUEST_STORAGE_KEY && event.key !== AUTH_STATE_KEY) return;
applyTheme(THEMES.has(event.newValue) ? event.newValue : systemTheme(), { persist: false }); 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", () => { systemThemeQuery.addEventListener("change", () => {
try { 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 { } } catch { }
applyTheme(systemTheme(), { persist: false }); applyTheme(systemTheme(), { persist: false });
}); });
+2 -1
View File
@@ -16,6 +16,7 @@
<header class="public-header"> <header class="public-header">
<a class="brand home-brand" href="/">RustPad</a> <a class="brand home-brand" href="/">RustPad</a>
<div class="public-header__actions"> <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-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-line-links-toggle" type="checkbox"> Line links</label>
<label class="line-toggle"><input id="public-full-width-toggle" type="checkbox"> Full width</label> <label class="line-toggle"><input id="public-full-width-toggle" type="checkbox"> Full width</label>
@@ -23,7 +24,7 @@
</div> </div>
</header> </header>
<main class="public-document"> <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> <p id="public-meta" class="public-meta"></p>
<article id="public-content" class="markdown-body public-content"></article> <article id="public-content" class="markdown-body public-content"></article>
</main> </main>
+1 -1
View File
@@ -25,7 +25,7 @@
<p id="workspace-url" class="document-url"></p> <p id="workspace-url" class="document-url"></p>
</div> </div>
</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> </header>
<main id="workspace-content" class="workspace-page" hidden> <main id="workspace-content" class="workspace-page" hidden>
<section class="workspace-top"> <section class="workspace-top">