diff --git a/Cargo.lock b/Cargo.lock index e7345bb..786efa5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.64" +version = "0.2.65" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 3215123..b98cc69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/LICENSE.md b/LICENSE.md index 3161d85..a2cd694 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -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>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: linuxiarzlinuxiarz.pl diff --git a/lang/en.json b/lang/en.json index b929708..478f9a7 100644 --- a/lang/en.json +++ b/lang/en.json @@ -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" } -} \ No newline at end of file +} diff --git a/lang/pl.json b/lang/pl.json index 7b79c81..1c1110c 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -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" } -} \ No newline at end of file +} diff --git a/src/assets.rs b/src/assets.rs index 682016e..c4857df 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -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#""# + r#""# } pub fn language_bootstrap() -> &'static str { - r#""# + r#""# } 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#""#, + r#""#, escape_js_string(&entrypoint) ) } diff --git a/static/css/styles.css b/static/css/styles.css index ecbf6b6..71e01f5 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -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; + } +} diff --git a/static/editor.html b/static/editor.html index b5488c9..2827b89 100644 --- a/static/editor.html +++ b/static/editor.html @@ -33,6 +33,7 @@ class="use-global-color" type="button" title="Use global profile color" aria-label="Use global profile color" hidden>↺
+
diff --git a/static/js/api.js b/static/js/api.js index c92ac04..60f60bb 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -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() { diff --git a/static/js/home.js b/static/js/home.js index 0d3b418..fbb1b70 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -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(); }); diff --git a/static/js/i18n.js b/static/js/i18n.js index 596cc83..3c4813a 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -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 ?? ""); diff --git a/static/js/preferences.js b/static/js/preferences.js new file mode 100644 index 0000000..5044218 --- /dev/null +++ b/static/js/preferences.js @@ -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 ''; + } + if (name === "light") { + return ''; + } + return ''; +} + +function closeOtherPickers(except = null) { + document.querySelectorAll(".header-preference-picker[open]").forEach(details => { + if (details !== except) details.open = false; + }); +} + +function preferenceButton({ value, label, selected, languageCode }) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "header-preference-option"; + button.dataset.value = value; + button.setAttribute("role", "option"); + button.setAttribute("aria-selected", selected ? "true" : "false"); + if (languageCode) button.lang = languageCode; + + const check = document.createElement("span"); + check.className = "header-preference-option__check"; + check.setAttribute("aria-hidden", "true"); + check.textContent = selected ? "✓" : ""; + + const copy = document.createElement("span"); + copy.className = "header-preference-option__copy"; + copy.textContent = label; + + button.append(check, copy); + return button; +} + +function createPicker(kind) { + const details = document.createElement("details"); + details.className = `header-preference-picker header-preference-picker--${kind}`; + details.dataset.preferenceKind = kind; + + const summary = document.createElement("summary"); + summary.className = "header-preference-trigger"; + + const iconWrap = document.createElement("span"); + iconWrap.className = "header-preference-trigger__icon"; + iconWrap.innerHTML = icon(kind === "theme" ? getTheme() : "language"); + + const compactValue = document.createElement("span"); + compactValue.className = "header-preference-trigger__value"; + compactValue.setAttribute("aria-hidden", "true"); + + const chevron = document.createElement("span"); + chevron.className = "header-preference-trigger__chevron"; + chevron.setAttribute("aria-hidden", "true"); + chevron.textContent = "▾"; + + summary.append(iconWrap, compactValue, chevron); + + const menu = document.createElement("div"); + menu.className = "header-preference-menu"; + menu.setAttribute("role", "listbox"); + + details.append(summary, menu); + details.addEventListener("toggle", () => { + if (details.open) closeOtherPickers(details); + }); + return details; +} + +async function persistAccountPreference(patch) { + if (!hasAccountSession()) return; + saveChain = saveChain.catch(() => undefined).then(() => api("/api/auth/profile", { + method: "POST", + body: JSON.stringify(patch), + })); + return saveChain; +} + +function renderLanguagePicker(details) { + const languages = getAvailableLanguages(); + const current = getLanguage(); + const selected = languages.find(language => language.code === current) || languages[0]; + const summary = details.querySelector(".header-preference-trigger"); + const value = details.querySelector(".header-preference-trigger__value"); + const menu = details.querySelector(".header-preference-menu"); + const label = t("common.language", {}, "Language"); + + summary.setAttribute("aria-label", label); + summary.title = label; + menu.setAttribute("aria-label", label); + value.textContent = String(selected?.code || current || "en").toUpperCase(); + menu.replaceChildren(...languages.map(language => preferenceButton({ + value: language.code, + label: language.native_name || language.name || language.code, + selected: language.code === current, + languageCode: language.code, + }))); +} + +function renderThemePicker(details) { + const current = getTheme(); + const summary = details.querySelector(".header-preference-trigger"); + const iconWrap = details.querySelector(".header-preference-trigger__icon"); + const value = details.querySelector(".header-preference-trigger__value"); + const menu = details.querySelector(".header-preference-menu"); + const label = t("profile.theme", {}, "Interface theme"); + + summary.setAttribute("aria-label", label); + summary.title = label; + menu.setAttribute("aria-label", label); + iconWrap.innerHTML = icon(current); + value.textContent = t(`theme.${current}`, {}, current === "light" ? "Light" : "Dark"); + menu.replaceChildren(...THEMES.map(theme => preferenceButton({ + value: theme, + label: t(`theme.${theme}`, {}, theme === "light" ? "Light" : "Dark"), + selected: theme === current, + }))); +} + +function renderPicker(details) { + if (details.dataset.preferenceKind === "language") renderLanguagePicker(details); + else renderThemePicker(details); +} + +function renderAll() { + document.querySelectorAll(".header-preference-picker").forEach(renderPicker); +} + +function bindPicker(details) { + details.querySelector(".header-preference-menu")?.addEventListener("click", async event => { + const button = event.target.closest(".header-preference-option"); + if (!(button instanceof HTMLButtonElement)) return; + const next = button.dataset.value || ""; + const kind = details.dataset.preferenceKind; + details.open = false; + + if (kind === "language") { + const previous = getLanguage(); + if (!next || next === previous) return; + await setLanguage(next); + renderAll(); + try { + await persistAccountPreference({ language: next }); + } catch (error) { + if (error?.status !== 401) { + await setLanguage(previous); + renderAll(); + toast.danger(error.message, { title: t("profile.language.failed", {}, "Could not change language") }); + } + } + return; + } + + const previous = getTheme(); + if (!THEMES.includes(next) || next === previous) return; + applyTheme(next); + renderAll(); + try { + await persistAccountPreference({ theme: next }); + } catch (error) { + if (error?.status !== 401) { + applyTheme(previous); + renderAll(); + toast.danger(error.message, { title: t("profile.updateFailed", {}, "Could not update profile") }); + } + } + }); +} + +function mount(container) { + if (!(container instanceof HTMLElement) || container.dataset.headerPreferencesMounted === "true") return; + container.dataset.headerPreferencesMounted = "true"; + container.setAttribute("data-i18n-ignore", ""); + const language = createPicker("language"); + const theme = createPicker("theme"); + container.replaceChildren(language, theme); + bindPicker(language); + bindPicker(theme); + renderPicker(language); + renderPicker(theme); +} + +export function initHeaderPreferences() { + document.querySelectorAll("[data-header-preferences]").forEach(mount); + if (initialized) return; + initialized = true; + + document.addEventListener("pointerdown", event => { + const target = event.target instanceof Element ? event.target : null; + if (!target?.closest(".header-preference-picker")) closeOtherPickers(); + }, { passive: true }); + document.addEventListener("keydown", event => { + if (event.key === "Escape") closeOtherPickers(); + }); + document.addEventListener("rustpad:languagechange", renderAll); + window.addEventListener("rustpad:theme-change", renderAll); + window.addEventListener("rustpad:session-change", renderAll); + window.addEventListener("rustpad:session-expired", renderAll); +} diff --git a/static/js/public.js b/static/js/public.js index 1467d0c..226b64d 100644 --- a/static/js/public.js +++ b/static/js/public.js @@ -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)}`); diff --git a/static/js/session.js b/static/js/session.js index f6fce8d..097b0db 100644 --- a/static/js/session.js +++ b/static/js/session.js @@ -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 } })); } diff --git a/static/js/theme.js b/static/js/theme.js index e260cc5..2e0607b 100644 --- a/static/js/theme.js +++ b/static/js/theme.js @@ -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 }); }); diff --git a/static/public.html b/static/public.html index 1adff36..3dc645c 100644 --- a/static/public.html +++ b/static/public.html @@ -16,6 +16,7 @@
RustPad
+
@@ -23,7 +24,7 @@
-

Loading…

+

Loading…

diff --git a/static/workspace.html b/static/workspace.html index 1784c6d..d613f5a 100644 --- a/static/workspace.html +++ b/static/workspace.html @@ -25,7 +25,7 @@

-
+