diff --git a/README.md b/README.md index e25bd68d..8a41cb7c 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,7 @@ services: - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs). - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details + # - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links # - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy # - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended. @@ -309,6 +310,7 @@ trek.yourdomain.com { | `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto | | `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` | | `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` | +| `DEFAULT_LANGUAGE` | Default language shown on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback when no match is found. Supported values: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` | | `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin | | `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Only useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY` to be set so Express can detect the forwarded protocol. | `false` | | `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: secure is on when `NODE_ENV=production` **or** `FORCE_HTTPS=true`. Set to `false` as an escape hatch to allow session cookies over plain HTTP (e.g. LAN testing without TLS). **Not recommended to disable in production.** | auto (`true` in production) | diff --git a/charts/trek/values.yaml b/charts/trek/values.yaml index 05e459d6..42c86b1f 100644 --- a/charts/trek/values.yaml +++ b/charts/trek/values.yaml @@ -19,6 +19,10 @@ env: # Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin). # LOG_LEVEL: "info" # "info" = concise user actions, "debug" = verbose details. + # DEFAULT_LANGUAGE: "en" + # Default language on the login page for users with no saved preference. + # Browser/OS language is auto-detected first; this is the fallback when no match is found. + # Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar # ALLOWED_ORIGINS: "" # NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration. # APP_URL: "https://trek.example.com" diff --git a/client/src/api/client.ts b/client/src/api/client.ts index ac3e6f65..f296ee39 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -395,6 +395,11 @@ export const weatherApi = { getDetailed: (lat: number, lng: number, date: string, lang?: string) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data), } +export const configApi = { + getPublicConfig: (): Promise<{ defaultLanguage: string }> => + apiClient.get('/config').then(r => r.data), +} + export const settingsApi = { get: () => apiClient.get('/settings').then(r => r.data), set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data), diff --git a/client/src/i18n/TranslationContext.tsx b/client/src/i18n/TranslationContext.tsx index a8a595a9..964a9af7 100644 --- a/client/src/i18n/TranslationContext.tsx +++ b/client/src/i18n/TranslationContext.tsx @@ -14,28 +14,22 @@ import ar from './translations/ar' import br from './translations/br' import cs from './translations/cs' import pl from './translations/pl' +import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages' + +export { SUPPORTED_LANGUAGES } type TranslationStrings = Record -export const SUPPORTED_LANGUAGES = [ - { value: 'de', label: 'Deutsch' }, - { value: 'en', label: 'English' }, - { value: 'es', label: 'Español' }, - { value: 'fr', label: 'Français' }, - { value: 'hu', label: 'Magyar' }, - { value: 'nl', label: 'Nederlands' }, - { value: 'br', label: 'Português (Brasil)' }, - { value: 'cs', label: 'Česky' }, - { value: 'pl', label: 'Polski' }, - { value: 'ru', label: 'Русский' }, - { value: 'zh', label: '简体中文' }, - { value: 'zh-TW', label: '繁體中文' }, - { value: 'it', label: 'Italiano' }, - { value: 'ar', label: 'العربية' }, -] as const +// Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation. +const translations: Record = { + de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, ar, br, cs, pl, +} + +// Derived from SUPPORTED_LANGUAGES — add new languages there, not here. +const LOCALES: Record = Object.fromEntries( + SUPPORTED_LANGUAGES.map(l => [l.value, l.locale]) +) -const translations: Record = { de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, ar, br, cs, pl } -const LOCALES: Record = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', hu: 'hu-HU', it: 'it-IT', ru: 'ru-RU', zh: 'zh-CN', 'zh-TW': 'zh-TW', nl: 'nl-NL', ar: 'ar-SA', br: 'pt-BR', cs: 'cs-CZ', pl: 'pl-PL' } const RTL_LANGUAGES = new Set(['ar']) export function getLocaleForLanguage(language: string): string { @@ -51,6 +45,34 @@ export function isRtlLanguage(language: string): boolean { return RTL_LANGUAGES.has(language) } +// Detects the user's preferred language from the browser/OS settings and maps +// it to one of the supported language codes. Returns null if no match is found. +export function detectBrowserLanguage(): string | null { + if (typeof navigator === 'undefined') return null + const browserLangs = navigator.languages?.length + ? navigator.languages + : navigator.language ? [navigator.language] : [] + const supported = SUPPORTED_LANGUAGES.map(l => l.value) + + for (const lang of browserLangs) { + // Exact match (e.g. 'de', 'zh-TW') — case-insensitive + const exactMatch = supported.find(s => s.toLowerCase() === lang.toLowerCase()) + if (exactMatch) return exactMatch + + // pt-BR has no exact match (our code is 'br', not 'pt-BR'), so map it explicitly. + // pt-PT and bare 'pt' are NOT mapped — they fall through to null and let the + // server default or 'en' fallback apply instead. + if (lang.toLowerCase() === 'pt-br') return 'br' + + // Prefix match (e.g. 'de-AT' → 'de', 'zh-CN' → 'zh') — case-insensitive + const prefix = lang.split('-')[0].toLowerCase() + const prefixMatch = supported.find(s => s.toLowerCase() === prefix) + if (prefixMatch) return prefixMatch + } + + return null +} + interface TranslationContextValue { t: (key: string, params?: Record) => string language: string diff --git a/client/src/i18n/index.ts b/client/src/i18n/index.ts index 4d221cd0..6895bdf2 100644 --- a/client/src/i18n/index.ts +++ b/client/src/i18n/index.ts @@ -4,5 +4,6 @@ export { getLocaleForLanguage, getIntlLanguage, isRtlLanguage, + detectBrowserLanguage, SUPPORTED_LANGUAGES, } from './TranslationContext' diff --git a/client/src/i18n/supportedLanguages.ts b/client/src/i18n/supportedLanguages.ts new file mode 100644 index 00000000..28d957b8 --- /dev/null +++ b/client/src/i18n/supportedLanguages.ts @@ -0,0 +1,20 @@ +export const SUPPORTED_LANGUAGES = [ + { value: 'de', label: 'Deutsch', locale: 'de-DE' }, + { value: 'en', label: 'English', locale: 'en-US' }, + { value: 'es', label: 'Español', locale: 'es-ES' }, + { value: 'fr', label: 'Français', locale: 'fr-FR' }, + { value: 'hu', label: 'Magyar', locale: 'hu-HU' }, + { value: 'nl', label: 'Nederlands', locale: 'nl-NL' }, + { value: 'br', label: 'Português (Brasil)', locale: 'pt-BR' }, + { value: 'cs', label: 'Česky', locale: 'cs-CZ' }, + { value: 'pl', label: 'Polski', locale: 'pl-PL' }, + { value: 'ru', label: 'Русский', locale: 'ru-RU' }, + { value: 'zh', label: '简体中文', locale: 'zh-CN' }, + { value: 'zh-TW', label: '繁體中文', locale: 'zh-TW' }, + { value: 'it', label: 'Italiano', locale: 'it-IT' }, + { value: 'ar', label: 'العربية', locale: 'ar-SA' }, +] as const + +export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value'] + +export const SUPPORTED_LANGUAGE_CODES: string[] = SUPPORTED_LANGUAGES.map(l => l.value) diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index ac5fb4f3..4750531e 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -2,10 +2,11 @@ import React, { useState, useEffect, useMemo, useRef } from 'react' import { useNavigate, useLocation } from 'react-router-dom' import { useAuthStore } from '../store/authStore' import { useSettingsStore } from '../store/settingsStore' -import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n' -import { authApi } from '../api/client' +import { SUPPORTED_LANGUAGES, useTranslation, detectBrowserLanguage } from '../i18n' +import { authApi, configApi } from '../api/client' +import { hasStoredLanguage } from '../store/settingsStore' import { getApiErrorMessage } from '../types' -import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound } from 'lucide-react' +import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound, ChevronDown } from 'lucide-react' interface AppConfig { has_users: boolean @@ -36,8 +37,10 @@ export default function LoginPage(): React.ReactElement { const [inviteValid, setInviteValid] = useState(false) const exchangeInitiated = useRef(false) + const [langDropdownOpen, setLangDropdownOpen] = useState(false) + const { login, register, demoLogin, completeMfaLogin, loadUser } = useAuthStore() - const { setLanguageLocal } = useSettingsStore() + const { setLanguageLocal, setLanguageTransient } = useSettingsStore() const navigate = useNavigate() const location = useLocation() const noRedirect = !!(location.state as { noRedirect?: boolean } | null)?.noRedirect @@ -116,6 +119,32 @@ export default function LoginPage(): React.ReactElement { }) }, [navigate, t, noRedirect]) + // Language detection chain (runs once on mount, only if user has no saved preference): + // 1. localStorage → already in store initial state, skip + // 2. Browser/OS language (navigator.languages) + // 3. Server default (DEFAULT_LANGUAGE env var) + // 4. 'en' → hardcoded fallback already in store + useEffect(() => { + if (hasStoredLanguage()) return + + const detected = detectBrowserLanguage() + if (detected) { + setLanguageTransient(detected) + return + } + + configApi.getPublicConfig() + .then(({ defaultLanguage }) => { if (defaultLanguage) setLanguageTransient(defaultLanguage) }) + .catch((err) => console.warn('Failed to fetch default language config:', err)) + }, [setLanguageTransient]) + + useEffect(() => { + if (!langDropdownOpen) return + const close = () => setLangDropdownOpen(false) + document.addEventListener('click', close) + return () => document.removeEventListener('click', close) + }, [langDropdownOpen]) + const handleDemoLogin = async (): Promise => { setError('') setIsLoading(true) @@ -364,29 +393,66 @@ export default function LoginPage(): React.ReactElement { return (
- {/* Language toggle */} - + {/* Language dropdown */} +
+ + + {langDropdownOpen && ( +
e.stopPropagation()} + style={{ + position: 'absolute', top: '100%', right: 0, marginTop: 4, + background: 'white', borderRadius: 12, + boxShadow: '0 4px 24px rgba(0,0,0,0.12)', + border: '1px solid rgba(0,0,0,0.08)', + minWidth: 190, maxHeight: 320, overflowY: 'auto', + }} + > + {SUPPORTED_LANGUAGES.map(({ value, label }) => ( + + ))} +
+ )} +
{/* Left — branding */}
Promise updateSetting: (key: keyof Settings, value: Settings[keyof Settings]) => Promise setLanguageLocal: (lang: string) => void + setLanguageTransient: (lang: string) => void updateSettings: (settingsObj: Partial) => Promise } +// Returns true when the user has explicitly chosen a language (persisted in localStorage). +// Use this instead of reading localStorage directly so the key stays encapsulated here. +export const hasStoredLanguage = (): boolean => + typeof localStorage !== 'undefined' && !!localStorage.getItem('app_language') + export const useSettingsStore = create((set, get) => ({ settings: { map_tile_url: '', @@ -59,6 +66,14 @@ export const useSettingsStore = create((set, get) => ({ set((state) => ({ settings: { ...state.settings, language: lang } })) }, + // Applies a language for the current session without persisting to localStorage. + // Used for automatic detection (browser/server default) — only explicit user + // choices via the UI should be persisted. + setLanguageTransient: (lang: string) => { + if (!SUPPORTED_LANGUAGE_CODES.includes(lang)) return + set((state) => ({ settings: { ...state.settings, language: lang } })) + }, + updateSettings: async (settingsObj: Partial) => { set((state) => ({ settings: { ...state.settings, ...settingsObj }, diff --git a/client/tests/unit/i18n/index.test.ts b/client/tests/unit/i18n/index.test.ts index 3e529b3f..3b15721b 100644 --- a/client/tests/unit/i18n/index.test.ts +++ b/client/tests/unit/i18n/index.test.ts @@ -8,6 +8,7 @@ import { getIntlLanguage, isRtlLanguage, SUPPORTED_LANGUAGES, + detectBrowserLanguage, } from '../../../src/i18n' import { resetAllStores, seedStore } from '../../helpers/store' import { useSettingsStore } from '../../../src/store/settingsStore' @@ -91,8 +92,58 @@ describe('SUPPORTED_LANGUAGES', () => { it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => { expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true) expect(SUPPORTED_LANGUAGES).toHaveLength(14) - expect(SUPPORTED_LANGUAGES).toContainEqual({ value: 'en', label: 'English' }) - expect(SUPPORTED_LANGUAGES).toContainEqual({ value: 'ar', label: 'العربية' }) + expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'en', label: 'English' })) + expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' })) + }) +}) + +// ── FE-COMP-I18N-016 to 023: detectBrowserLanguage ─────────────────────────── + +describe('detectBrowserLanguage', () => { + afterEach(() => { + Object.defineProperty(navigator, 'languages', { value: [], configurable: true }) + Object.defineProperty(navigator, 'language', { value: '', configurable: true }) + }) + + it('FE-COMP-I18N-016: exact match returns the matched code', () => { + Object.defineProperty(navigator, 'languages', { value: ['de'], configurable: true }) + expect(detectBrowserLanguage()).toBe('de') + }) + + it('FE-COMP-I18N-017: region-tagged exact match (zh-TW) returns zh-TW', () => { + Object.defineProperty(navigator, 'languages', { value: ['zh-TW'], configurable: true }) + expect(detectBrowserLanguage()).toBe('zh-TW') + }) + + it('FE-COMP-I18N-018: prefix match (de-AT → de)', () => { + Object.defineProperty(navigator, 'languages', { value: ['de-AT'], configurable: true }) + expect(detectBrowserLanguage()).toBe('de') + }) + + it('FE-COMP-I18N-019: pt-PT returns null (European Portuguese is a distinct language)', () => { + Object.defineProperty(navigator, 'languages', { value: ['pt-PT'], configurable: true }) + expect(detectBrowserLanguage()).toBeNull() + }) + + it('FE-COMP-I18N-020: pt-BR maps to br', () => { + Object.defineProperty(navigator, 'languages', { value: ['pt-BR'], configurable: true }) + expect(detectBrowserLanguage()).toBe('br') + }) + + it('FE-COMP-I18N-021: first-match-wins across multiple entries', () => { + Object.defineProperty(navigator, 'languages', { value: ['xx-XX', 'fr'], configurable: true }) + expect(detectBrowserLanguage()).toBe('fr') + }) + + it('FE-COMP-I18N-022: unknown language returns null', () => { + Object.defineProperty(navigator, 'languages', { value: ['xx'], configurable: true }) + expect(detectBrowserLanguage()).toBeNull() + }) + + it('FE-COMP-I18N-023: falls back to navigator.language when navigator.languages is empty', () => { + Object.defineProperty(navigator, 'languages', { value: [], configurable: true }) + Object.defineProperty(navigator, 'language', { value: 'es', configurable: true }) + expect(detectBrowserLanguage()).toBe('es') }) }) diff --git a/client/tests/unit/stores/settingsStore.test.ts b/client/tests/unit/stores/settingsStore.test.ts index 508abea2..cc020f5f 100644 --- a/client/tests/unit/stores/settingsStore.test.ts +++ b/client/tests/unit/stores/settingsStore.test.ts @@ -170,6 +170,37 @@ describe('settingsStore', () => { }); }); + describe('FE-STORE-SETTINGS-015: setLanguageTransient updates state without touching localStorage', () => { + it('sets language in state but does not write to localStorage', () => { + localStorage.clear(); + + useSettingsStore.getState().setLanguageTransient('fr'); + + expect(useSettingsStore.getState().settings.language).toBe('fr'); + expect(localStorage.getItem('app_language')).toBeNull(); + }); + }); + + describe('FE-STORE-SETTINGS-016: setLanguageTransient rejects unsupported language code', () => { + it('leaves state unchanged for an unknown code', () => { + const before = useSettingsStore.getState().settings.language; + + useSettingsStore.getState().setLanguageTransient('xx'); + + expect(useSettingsStore.getState().settings.language).toBe(before); + }); + }); + + describe('FE-STORE-SETTINGS-017: setLanguageTransient does not overwrite an explicit localStorage choice', () => { + it('localStorage remains unchanged after a transient set', () => { + localStorage.setItem('app_language', 'de'); + + useSettingsStore.getState().setLanguageTransient('es'); + + expect(localStorage.getItem('app_language')).toBe('de'); + }); + }); + describe('FE-STORE-SETTINGS-014: updateSetting API failure leaves optimistic state', () => { it('throws on API failure but keeps the optimistic state', async () => { server.use( diff --git a/docker-compose.yml b/docker-compose.yml index 946c133d..e0d84418 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,7 @@ services: - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs). - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details +# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links # - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy # - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended. diff --git a/server/.env.example b/server/.env.example index 932a274f..ba9da901 100644 --- a/server/.env.example +++ b/server/.env.example @@ -6,6 +6,9 @@ NODE_ENV=development # development = development mode; production = production m # existing encrypted data remains readable, then re-save credentials via the admin panel. # Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" TZ=UTC # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) +# DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference (default: en) +# Supported values: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar +# Note: browser/OS language is detected automatically first; this is the fallback when no match is found. LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level details ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links diff --git a/server/src/app.ts b/server/src/app.ts index 4187dc44..f4c91791 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -41,6 +41,7 @@ import notificationRoutes from './routes/notifications'; import shareRoutes from './routes/share'; import journeyRoutes from './routes/journey'; import journeyPublicRoutes from './routes/journeyPublic'; +import publicConfigRoutes from './routes/publicConfig'; import { mcpHandler } from './mcp'; import { Addon } from './types'; import { getPhotoProviderConfig } from './services/memories/helpersService'; @@ -194,6 +195,7 @@ export function createApp(): express.Application { app.use('/api/trips/:tripId/reservations', reservationsRoutes); app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes); app.get('/api/health', (_req: Request, res: Response) => res.json({ status: 'ok' })); + app.use('/api/config', publicConfigRoutes); app.use('/api', assignmentsRoutes); app.use('/api/tags', tagsRoutes); app.use('/api/categories', categoriesRoutes); diff --git a/server/src/config.ts b/server/src/config.ts index be5d2bcc..2941a87f 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -98,3 +98,15 @@ if (_encryptionKey) { } export const ENCRYPTION_KEY = _encryptionKey; + +// DEFAULT_LANGUAGE sets the language shown on the login page before the user +// selects one. Only applies when the user has no saved language preference. +// Supported values: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar +// Must stay in sync with client/src/i18n/supportedLanguages.ts (canonical source). +// Kept duplicated here because server and client are separate npm packages. +const SUPPORTED_LANG_CODES = ['de', 'en', 'es', 'fr', 'hu', 'nl', 'br', 'cs', 'pl', 'ru', 'zh', 'zh-TW', 'it', 'ar']; +const rawDefaultLang = process.env.DEFAULT_LANGUAGE || 'en'; +if (!SUPPORTED_LANG_CODES.includes(rawDefaultLang)) { + console.warn(`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`); +} +export const DEFAULT_LANGUAGE = SUPPORTED_LANG_CODES.includes(rawDefaultLang) ? rawDefaultLang : 'en'; diff --git a/server/src/routes/publicConfig.ts b/server/src/routes/publicConfig.ts new file mode 100644 index 00000000..f39dbb28 --- /dev/null +++ b/server/src/routes/publicConfig.ts @@ -0,0 +1,10 @@ +import express, { Request, Response } from 'express'; +import { DEFAULT_LANGUAGE } from '../config'; + +const router = express.Router(); + +router.get('/', (_req: Request, res: Response) => { + res.json({ defaultLanguage: DEFAULT_LANGUAGE }); +}); + +export default router; diff --git a/unraid-template.xml b/unraid-template.xml index fa3f2fc6..69ca38f6 100644 --- a/unraid-template.xml +++ b/unraid-template.xml @@ -33,6 +33,7 @@ production UTC info + en false