M app/javascript/flavours/glitch/styles/login.css => app/javascript/flavours/glitch/styles/login.css +81 -14
@@ 1,9 1,53 @@
@import 'styles/fonts/roboto';
@import 'reset';
-:root {
+:root,
+:root[data-theme="mastodon"] {
--color-bg: #191b22;
--color-fg: #fff;
+ --color-input-bg: #282c37;
+ --color-input-border: #373d4c;
+ --color-input-text: #9baec8;
+ --color-aside-bg: #39404f;
+ --color-aside-border: #485164;
+ --color-footer-text: #97a8b4;
+ --color-btn-bg: #66befe;
+ --color-btn-bg-hover: #89caff;
+ --color-btn-border: #89cdfe;
+ --color-btn-text: #2a2b2f;
+ --color-focus: #66befe;
+}
+
+:root[data-theme="mastodon-light"] {
+ --color-bg: #ffffff;
+ --color-fg: #282c37;
+ --color-input-bg: #f2f5f7;
+ --color-input-border: #c0cdd9;
+ --color-input-text: #282c37;
+ --color-aside-bg: #e6ebf0;
+ --color-aside-border: #c0cdd9;
+ --color-footer-text: #606984;
+ --color-btn-bg: #6364ff;
+ --color-btn-bg-hover: #7f80ff;
+ --color-btn-border: #7778ff;
+ --color-btn-text: #fff;
+ --color-focus: #6364ff;
+}
+
+:root[data-theme="contrast"] {
+ --color-bg: #000;
+ --color-fg: #fff;
+ --color-input-bg: #111;
+ --color-input-border: #555;
+ --color-input-text: #fff;
+ --color-aside-bg: #222;
+ --color-aside-border: #555;
+ --color-footer-text: #aaa;
+ --color-btn-bg: #66befe;
+ --color-btn-bg-hover: #89caff;
+ --color-btn-border: #89cdfe;
+ --color-btn-text: #000;
+ --color-focus: #66befe;
}
body {
@@ 64,25 108,26 @@ form {
:focus-visible,
button:focus-visible {
- outline: 2px solid #66befe;
+ outline: 2px solid var(--color-focus);
outline-offset: 3px;
}
button {
padding: 7px 10px;
- border: 1px solid #89cdfe;
+ border: 1px solid var(--color-btn-border);
border-radius: 4px;
box-sizing: border-box;
- color: #2a2b2f;
+ color: var(--color-btn-text);
font-family: inherit;
font-size: inherit;
font-weight: 500;
text-align: center;
white-space: nowrap;
- background-color: #66befe;
+ background-color: var(--color-btn-bg);
+ cursor: pointer;
&:hover {
- background-color: #89caff;
+ background-color: var(--color-btn-bg-hover);
}
&:disabled {
@@ 91,35 136,57 @@ button {
}
}
-input[type='text'] {
+input[type='text'],
+select {
display: block;
margin: 0;
padding: 15px;
- border: 1px solid #373d4c;
+ border: 1px solid var(--color-input-border);
border-radius: 4px;
box-sizing: border-box;
box-shadow: none;
- color: #9baec8;
+ color: var(--color-input-text);
font-family: inherit;
font-size: inherit;
line-height: 18px;
- background: #282c37;
+ background: var(--color-input-bg);
}
.content {
padding: 15px;
border-radius: 4px;
- border: 1px solid #485164;
- color: #fff;
- background-color: #39404f;
+ border: 1px solid var(--color-aside-border);
+ color: var(--color-fg);
+ background-color: var(--color-aside-bg);
}
.link-footer {
padding-inline: 10px;
- color: #97a8b4;
+ color: var(--color-footer-text);
font-size: 0.875rem;
a {
color: inherit;
}
}
+
+.login-settings {
+ display: flex;
+ gap: 10px;
+ justify-content: flex-end;
+ align-items: center;
+
+ select {
+ padding: 6px 10px;
+ font-size: 0.875rem;
+ line-height: 1.2;
+ cursor: pointer;
+ }
+
+ label {
+ font-size: 0.875rem;
+ font-weight: 400;
+ color: var(--color-footer-text);
+ display: inline;
+ }
+}
M public/auth.js => public/auth.js +6 -6
@@ 24,7 24,7 @@ async function ready() {
}
async function auth() {
- setMessage('Please wait');
+ setMessage(window.loginI18n?.('please_wait') || 'Please wait');
const instance = document.getElementById('instance').value.trim();
const matches = instance.match(/((?:http|https):\/\/)?(.*)/);
@@ 36,9 36,9 @@ async function auth() {
const domain = matches[2];
if (!domain) {
- setMessage('Invalid instance', true);
+ setMessage(window.loginI18n?.('invalid_instance') || 'Invalid instance', true);
await new Promise(r => setTimeout(r, 2000));
- setMessage('Authorize', false, false);
+ setMessage(window.loginI18n?.('authorize') || 'Authorize', false, false);
return;
}
localStorage.setItem('domain', domain);
@@ 52,7 52,7 @@ async function auth() {
}
async function registerApp(domain) {
- setMessage('Registering app');
+ setMessage(window.loginI18n?.('registering') || 'Registering app');
const protocol = localStorage.getItem(`protocol`) ?? `https://`;
const appsUrl = `${protocol}${domain}/api/v1/apps`;
@@ 78,14 78,14 @@ async function registerApp(domain) {
}
function authorize(domain) {
- setMessage('Authorizing');
+ setMessage(window.loginI18n?.('authorizing') || 'Authorizing');
const clientId = localStorage.getItem(`client_id`);
const protocol = localStorage.getItem(`protocol`) ?? `https://`;
document.location.href = `${protocol}${domain}/oauth/authorize?response_type=code&client_id=${clientId}&redirect_uri=${document.location.origin + document.location.pathname}&scope=read+write+follow+push`;
}
async function getToken(code, domain) {
- setMessage('Getting token');
+ setMessage(window.loginI18n?.('getting_token') || 'Getting token');
const protocol = localStorage.getItem(`protocol`) ?? `https://`;
const tokenUrl = `${protocol}${domain}/oauth/token`;
A public/login-i18n.js => public/login-i18n.js +308 -0
@@ 0,0 1,308 @@
+// Login page i18n and theme switching
+// Translations for login page strings (curated set of common languages)
+const LOGIN_TRANSLATIONS = {
+ en: {
+ title: "Log into your instance",
+ instance_url: "Instance URL",
+ authorize: "Authorize",
+ note_label: "Note:",
+ note_text: "this application is completely client-side, meaning everything happens in the browser on your machine. It does not store information anywhere else than your browser's local storage.",
+ source_code: "Source code",
+ please_wait: "Please wait",
+ registering: "Registering app",
+ authorizing: "Authorizing",
+ getting_token: "Getting token",
+ invalid_instance: "Invalid instance",
+ theme_dark: "Dark",
+ theme_light: "Light",
+ theme_contrast: "High contrast",
+ },
+ "zh-CN": {
+ title: "登录你的实例",
+ instance_url: "实例地址",
+ authorize: "授权",
+ note_label: "注意:",
+ note_text: "本应用完全在客户端运行,所有操作都在你的浏览器中完成。除了浏览器的本地存储外,不会在其他地方存储信息。",
+ source_code: "源代码",
+ please_wait: "请稍候",
+ registering: "正在注册应用",
+ authorizing: "正在授权",
+ getting_token: "正在获取令牌",
+ invalid_instance: "无效的实例",
+ theme_dark: "暗色",
+ theme_light: "亮色",
+ theme_contrast: "高对比度",
+ },
+ "zh-TW": {
+ title: "登入你的實例",
+ instance_url: "實例網址",
+ authorize: "授權",
+ note_label: "注意:",
+ note_text: "本應用完全在客戶端運行,所有操作都在你的瀏覽器中完成。除了瀏覽器的本地儲存外,不會在其他地方儲存資訊。",
+ source_code: "原始碼",
+ please_wait: "請稍候",
+ registering: "正在註冊應用",
+ authorizing: "正在授權",
+ getting_token: "正在取得權杖",
+ invalid_instance: "無效的實例",
+ theme_dark: "暗色",
+ theme_light: "亮色",
+ theme_contrast: "高對比度",
+ },
+ ja: {
+ title: "インスタンスにログイン",
+ instance_url: "インスタンスURL",
+ authorize: "認証",
+ note_label: "注意:",
+ note_text: "このアプリケーションは完全にクライアントサイドで動作します。すべての処理はブラウザ上で行われ、ブラウザのローカルストレージ以外にデータを保存しません。",
+ source_code: "ソースコード",
+ please_wait: "お待ちください",
+ registering: "アプリを登録中",
+ authorizing: "認証中",
+ getting_token: "トークンを取得中",
+ invalid_instance: "無効なインスタンス",
+ theme_dark: "ダーク",
+ theme_light: "ライト",
+ theme_contrast: "ハイコントラスト",
+ },
+ ko: {
+ title: "인스턴스에 로그인",
+ instance_url: "인스턴스 URL",
+ authorize: "인증",
+ note_label: "참고:",
+ note_text: "이 애플리케이션은 완전히 클라이언트 측에서 동작합니다. 모든 작업은 브라우저에서 이루어지며, 브라우저의 로컬 저장소 외에는 어디에도 정보를 저장하지 않습니다.",
+ source_code: "소스 코드",
+ please_wait: "잠시 기다려주세요",
+ registering: "앱 등록 중",
+ authorizing: "인증 중",
+ getting_token: "토큰 받는 중",
+ invalid_instance: "잘못된 인스턴스",
+ theme_dark: "다크",
+ theme_light: "라이트",
+ theme_contrast: "고대비",
+ },
+ de: {
+ title: "Bei deiner Instanz anmelden",
+ instance_url: "Instanz-URL",
+ authorize: "Autorisieren",
+ note_label: "Hinweis:",
+ note_text: "Diese Anwendung läuft vollständig clientseitig. Alles geschieht in deinem Browser. Daten werden nur im lokalen Speicher deines Browsers gespeichert.",
+ source_code: "Quellcode",
+ please_wait: "Bitte warten",
+ registering: "App wird registriert",
+ authorizing: "Autorisierung",
+ getting_token: "Token wird abgerufen",
+ invalid_instance: "Ungültige Instanz",
+ theme_dark: "Dunkel",
+ theme_light: "Hell",
+ theme_contrast: "Hoher Kontrast",
+ },
+ fr: {
+ title: "Connectez-vous à votre instance",
+ instance_url: "URL de l'instance",
+ authorize: "Autoriser",
+ note_label: "Note :",
+ note_text: "cette application fonctionne entièrement côté client. Tout se passe dans votre navigateur. Aucune donnée n'est stockée ailleurs que dans le stockage local de votre navigateur.",
+ source_code: "Code source",
+ please_wait: "Veuillez patienter",
+ registering: "Enregistrement de l'application",
+ authorizing: "Autorisation",
+ getting_token: "Obtention du jeton",
+ invalid_instance: "Instance invalide",
+ theme_dark: "Sombre",
+ theme_light: "Clair",
+ theme_contrast: "Contraste élevé",
+ },
+ es: {
+ title: "Inicia sesión en tu instancia",
+ instance_url: "URL de la instancia",
+ authorize: "Autorizar",
+ note_label: "Nota:",
+ note_text: "esta aplicación funciona completamente en el lado del cliente. Todo ocurre en tu navegador. No almacena información en ningún otro lugar que no sea el almacenamiento local de tu navegador.",
+ source_code: "Código fuente",
+ please_wait: "Por favor espera",
+ registering: "Registrando aplicación",
+ authorizing: "Autorizando",
+ getting_token: "Obteniendo token",
+ invalid_instance: "Instancia inválida",
+ theme_dark: "Oscuro",
+ theme_light: "Claro",
+ theme_contrast: "Alto contraste",
+ },
+ pt: {
+ title: "Entrar na sua instância",
+ instance_url: "URL da instância",
+ authorize: "Autorizar",
+ note_label: "Nota:",
+ note_text: "esta aplicação funciona inteiramente no lado do cliente. Tudo acontece no seu navegador. Não armazena informações em nenhum outro lugar além do armazenamento local do seu navegador.",
+ source_code: "Código-fonte",
+ please_wait: "Aguarde",
+ registering: "Registrando aplicação",
+ authorizing: "Autorizando",
+ getting_token: "Obtendo token",
+ invalid_instance: "Instância inválida",
+ theme_dark: "Escuro",
+ theme_light: "Claro",
+ theme_contrast: "Alto contraste",
+ },
+ ru: {
+ title: "Войти в свой инстанс",
+ instance_url: "URL инстанса",
+ authorize: "Авторизовать",
+ note_label: "Примечание:",
+ note_text: "это приложение полностью работает на стороне клиента. Все происходит в вашем браузере. Данные хранятся только в локальном хранилище вашего браузера.",
+ source_code: "Исходный код",
+ please_wait: "Пожалуйста, подождите",
+ registering: "Регистрация приложения",
+ authorizing: "Авторизация",
+ getting_token: "Получение токена",
+ invalid_instance: "Недействительный инстанс",
+ theme_dark: "Тёмная",
+ theme_light: "Светлая",
+ theme_contrast: "Высокая контрастность",
+ },
+};
+
+// Language display names (native names)
+const LANGUAGE_NAMES = {
+ en: "English",
+ "zh-CN": "简体中文",
+ "zh-TW": "繁體中文",
+ ja: "日本語",
+ ko: "한국어",
+ de: "Deutsch",
+ fr: "Français",
+ es: "Español",
+ pt: "Português",
+ ru: "Русский",
+};
+
+const STORAGE_KEY_LOCALE = "masto-fe-locale";
+const STORAGE_KEY_SETTINGS = "mastodon-settings";
+
+function getStoredLocale() {
+ return localStorage.getItem(STORAGE_KEY_LOCALE);
+}
+
+function getStoredTheme() {
+ try {
+ const settings = JSON.parse(localStorage.getItem(STORAGE_KEY_SETTINGS));
+ return settings?.theme;
+ } catch {
+ return null;
+ }
+}
+
+function setStoredLocale(locale) {
+ localStorage.setItem(STORAGE_KEY_LOCALE, locale);
+}
+
+function setStoredTheme(theme) {
+ let settings;
+ try {
+ settings = JSON.parse(localStorage.getItem(STORAGE_KEY_SETTINGS)) || {};
+ } catch {
+ settings = {};
+ }
+ settings.theme = theme;
+ localStorage.setItem(STORAGE_KEY_SETTINGS, JSON.stringify(settings));
+}
+
+function detectLocale() {
+ const stored = getStoredLocale();
+ if (stored && LOGIN_TRANSLATIONS[stored]) return stored;
+
+ const browserLang = navigator.language;
+ // Try exact match first
+ if (LOGIN_TRANSLATIONS[browserLang]) return browserLang;
+ // Try language without region (e.g., "zh" -> "zh-CN")
+ const base = browserLang.split("-")[0];
+ // Special case for Chinese
+ if (base === "zh") {
+ if (browserLang.includes("TW") || browserLang.includes("HK")) return "zh-TW";
+ return "zh-CN";
+ }
+ if (LOGIN_TRANSLATIONS[base]) return base;
+ return "en";
+}
+
+function resolveLocale(locale) {
+ // Map locale codes used by the main app to login page translations
+ // The main app uses locale codes like "zh-CN", but navigator.language
+ // might return "zh-Hans-CN" etc.
+ if (LOGIN_TRANSLATIONS[locale]) return locale;
+ const base = locale.split("-")[0];
+ if (base === "zh") return "zh-CN";
+ if (LOGIN_TRANSLATIONS[base]) return base;
+ return "en";
+}
+
+function applyTranslations(locale) {
+ const resolved = resolveLocale(locale);
+ const strings = LOGIN_TRANSLATIONS[resolved] || LOGIN_TRANSLATIONS.en;
+
+ document.querySelectorAll("[data-i18n]").forEach((el) => {
+ const key = el.getAttribute("data-i18n");
+ if (strings[key]) {
+ el.textContent = strings[key];
+ }
+ });
+
+ // Update theme select option labels
+ const themeSelect = document.getElementById("theme-select");
+ if (themeSelect) {
+ themeSelect.options[0].text = strings.theme_dark;
+ themeSelect.options[1].text = strings.theme_light;
+ themeSelect.options[2].text = strings.theme_contrast;
+ }
+
+ document.documentElement.lang = locale;
+}
+
+function applyTheme(theme) {
+ document.documentElement.setAttribute("data-theme", theme);
+}
+
+// Make translation function available to auth.js
+window.loginI18n = function (key) {
+ const locale = resolveLocale(
+ getStoredLocale() || detectLocale()
+ );
+ const strings = LOGIN_TRANSLATIONS[locale] || LOGIN_TRANSLATIONS.en;
+ return strings[key] || key;
+};
+
+document.addEventListener("DOMContentLoaded", function () {
+ // Initialize theme
+ const theme = getStoredTheme() || "mastodon";
+ applyTheme(theme);
+ const themeSelect = document.getElementById("theme-select");
+ if (themeSelect) {
+ themeSelect.value = theme;
+ themeSelect.addEventListener("change", function () {
+ applyTheme(this.value);
+ setStoredTheme(this.value);
+ });
+ }
+
+ // Initialize language selector
+ const langSelect = document.getElementById("lang-select");
+ if (langSelect) {
+ Object.entries(LANGUAGE_NAMES).forEach(([code, name]) => {
+ const option = document.createElement("option");
+ option.value = code;
+ option.textContent = name;
+ langSelect.appendChild(option);
+ });
+
+ const locale = detectLocale();
+ langSelect.value = locale;
+ applyTranslations(locale);
+
+ langSelect.addEventListener("change", function () {
+ const newLocale = this.value;
+ setStoredLocale(newLocale);
+ applyTranslations(newLocale);
+ });
+ }
+});
M public/login.html => public/login.html +18 -8
@@ 6,28 6,38 @@
<title>Login | Masto-FE (🦥 flavour)</title>
<meta content="width=device-width, initial-scale=1" name="viewport">
<link rel="stylesheet" media="all" href="/packs/css/flavours/glitch/login.css" />
+ <script src="/login-i18n.js"></script>
<script src="/auth.js"></script>
</head>
<body class="app-body">
<div class="login-container">
+ <div class="login-settings">
+ <select id="theme-select" aria-label="Theme">
+ <option value="mastodon">Dark</option>
+ <option value="mastodon-light">Light</option>
+ <option value="contrast">High contrast</option>
+ </select>
+ <select id="lang-select" aria-label="Language">
+ </select>
+ </div>
<header>
<img alt="a friendly smiling sloth" src="images/mascot.svg" />
</header>
<main>
- <h1>Log into your instance</h1>
+ <h1 data-i18n="title">Log into your instance</h1>
<form method="post" id="login">
- <label for="instance">Instance URL</label>
+ <label for="instance" data-i18n="instance_url">Instance URL</label>
<input type="text" id="instance" value="" class="input instance">
<button type="submit" class="button" id="btn">
- <span id="message">Authorize</span>
+ <span id="message" data-i18n="authorize">Authorize</span>
</button>
</form>
<aside class="content">
<p>
- <strong>Note:</strong>
- this application is completely client-side, meaning everything happens in the browser on your machine.
- It does not store information anywhere else than your browser's local storage.
+ <strong data-i18n="note_label">Note:</strong>
+ <span data-i18n="note_text">this application is completely client-side, meaning everything happens in the browser on your machine.
+ It does not store information anywhere else than your browser's local storage.</span>
</p>
</aside>
</main>
@@ 35,7 45,7 @@
<p>
<strong>Masto-FE (🦥 flavour)</strong>
<span aria-hidden="true"> · </span>
- <a href="https://codeberg.org/superseriousbusiness/masto-fe-standalone" rel="noopener noreferrer" target="_blank">
+ <a href="https://codeberg.org/superseriousbusiness/masto-fe-standalone" rel="noopener noreferrer" target="_blank" data-i18n="source_code">
Source code
</a>
</p>
@@ 43,4 53,4 @@
</div>
</body>
-</html>>
\ No newline at end of file
+</html>
M public/verify-state.js => public/verify-state.js +46 -1
@@ 1,3 1,48 @@
+// Available locale files in the app (must match files in mastodon/locales/)
+const AVAILABLE_LOCALES = [
+ "af","an","ar","ast","be","bg","bn","br","bs","ca","ckb","co","cs","cy",
+ "da","de","el","en-GB","en","eo","es-AR","es","es-MX","et","eu","fa","fi",
+ "fo","fr","fr-QC","fy","ga","gd","gl","he","hi","hr","hu","hy","id","ig",
+ "io","is","it","ja","kab","ka","kk","kn","ko","ku","kw","la","lt","lv",
+ "mk","ml","mr","ms","my","nl","nn","no","oc","pa","pl","pt-BR","pt-PT",
+ "ro","ru","sa","sc","sco","si","sk","sl","sq","sr","sr-Latn","sv","szl",
+ "tai","ta","te","th","tr","tt","ug","uk","ur","uz","vi","zgh","zh-CN","zh-HK","zh-TW"
+];
+
+function getUILocale() {
+ // Priority: localStorage > navigator.language > "en"
+ const stored = localStorage.getItem("masto-fe-locale");
+ if (stored) {
+ const resolved = resolveToAvailable(stored);
+ if (resolved) return resolved;
+ }
+
+ const browserLang = navigator.language;
+ const resolved = resolveToAvailable(browserLang);
+ return resolved || "en";
+}
+
+function resolveToAvailable(locale) {
+ if (AVAILABLE_LOCALES.includes(locale)) return locale;
+
+ // Try with region mapping (e.g., "zh-Hans-CN" -> "zh-CN")
+ const parts = locale.split("-");
+ if (parts[0] === "zh") {
+ if (locale.includes("TW") || locale.includes("Hant") || locale.includes("HK")) {
+ return "zh-TW";
+ }
+ return "zh-CN";
+ }
+ if (parts[0] === "pt") {
+ if (locale.includes("BR")) return "pt-BR";
+ return "pt-PT";
+ }
+
+ // Try base language
+ if (AVAILABLE_LOCALES.includes(parts[0])) return parts[0];
+ return null;
+}
+
loadState().then(_ => null);
async function loadState() {
@@ 86,7 131,7 @@ async function loadState() {
"display_sensitive_media": false,
"domain": domain,
"enable_reaction": true,
- "locale": "en",
+ "locale": getUILocale(),
"mascot": "/images/mascot.svg",
"me": credentials.id,
"reduce_motion": false,