translations and pickers
This commit is contained in:
Generated
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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<at>linuxiarz.pl</p>
|
separate commercial license. <br>Contact: linuxiarz<at>linuxiarz.pl</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+5
-3
@@ -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
@@ -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
@@ -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 ?? "");
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user