From f05424c48812ee3c8a64d8cf03ba7b4cab8841a8 Mon Sep 17 00:00:00 2001 From: Creeperkatze Date: Mon, 19 Oct 2026 01:03:42 +0200 Subject: [PATCH] feat: use system language when no locale has been chosen --- apps/app-frontend/src/App.vue | 11 ++++------ apps/app-frontend/src/i18n.config.ts | 5 +++++ apps/frontend/src/app.vue | 5 +++-- apps/frontend/src/plugins/i18n.ts | 16 ++------------ apps/labrinth/src/models/v3/preferences.rs | 14 ++++--------- .../api-client/src/modules/labrinth/types.ts | 2 +- .../20261019120000_detect-system-locale.sql | 1 + packages/ui/src/composables/i18n.ts | 21 +++++++++++++++++++ .../settings/language-settings/index.vue | 2 +- 9 files changed, 42 insertions(+), 35 deletions(-) create mode 100644 packages/app-lib/migrations/20261019120000_detect-system-locale.sql diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index e9b449e7253..00018c997f3 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -151,7 +151,7 @@ import { setRestartAfterPendingUpdate, } from '@/helpers/utils.js' import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts' -import i18n, { setLocale } from '@/i18n.config' +import i18n, { detectSystemLocale, setLocale } from '@/i18n.config' import { instanceKeys, instanceListQueryOptions, @@ -703,10 +703,7 @@ async function setupApp() { pending_update_toast_for_version, } = await traceStartupStep('Read startup settings', getSettings) - // Initialize locale from saved settings - if (locale) { - await traceStartupStep('Apply startup locale', () => setLocale(locale)) - } + await traceStartupStep('Apply startup locale', () => setLocale(locale || detectSystemLocale())) Object.assign(appSettings.featureFlags, feature_flags) isMaximized.value = await traceStartupStep('Read window maximized state', () => @@ -1009,7 +1006,7 @@ watch( if (appTheme.syncAcrossDevices && appTheme.preferred !== selectedTheme) { appTheme.preferred = selectedTheme } - if (i18n.global.locale.value !== locale) { + if (locale && i18n.global.locale.value !== locale) { await setLocale(locale) } @@ -1017,7 +1014,7 @@ watch( settings.theme = selectedTheme settingsChanged = true } - if (settings.locale !== locale) { + if (locale && settings.locale !== locale) { settings.locale = locale settingsChanged = true } diff --git a/apps/app-frontend/src/i18n.config.ts b/apps/app-frontend/src/i18n.config.ts index 697718f7f89..4eb41c6188f 100644 --- a/apps/app-frontend/src/i18n.config.ts +++ b/apps/app-frontend/src/i18n.config.ts @@ -3,6 +3,7 @@ import { createMessageCompiler, type CrowdinMessages, LOCALES, + matchLocale, } from '@modrinth/ui' import englishUi from '@modrinth/ui/src/locales/en-US/index.json' import { createI18n } from 'vue-i18n' @@ -42,6 +43,10 @@ async function fetchMessages(url: string): Promise { return response.json() } +export function detectSystemLocale(): string { + return matchLocale(navigator.languages) ?? 'en-US' +} + export async function setLocale(requestedLocale: string): Promise { const request = ++localeRequest const locale = LOCALES.some((candidate) => candidate.code === requestedLocale) diff --git a/apps/frontend/src/app.vue b/apps/frontend/src/app.vue index 3741d3f1027..30d6b37eee0 100644 --- a/apps/frontend/src/app.vue +++ b/apps/frontend/src/app.vue @@ -66,8 +66,9 @@ watch( rememberStoredAccountAppearance(userId, preferences.appearance) } - if (locale.value !== preferences.localization.locale) { - void setLocale(preferences.localization.locale) + const accountLocale = preferences.localization.locale + if (accountLocale && locale.value !== accountLocale) { + void setLocale(accountLocale) } cosmetics.value.searchDisplayMode.mod = preferences.layouts.mods === 'rows' ? 'list' : 'grid' diff --git a/apps/frontend/src/plugins/i18n.ts b/apps/frontend/src/plugins/i18n.ts index 01ed4a65e32..661bb56acc2 100644 --- a/apps/frontend/src/plugins/i18n.ts +++ b/apps/frontend/src/plugins/i18n.ts @@ -4,6 +4,7 @@ import { I18N_INJECTION_KEY, type I18nContext, LOCALES, + matchLocale, transformCrowdinMessages, uiLocaleModules, useDebugLogger, @@ -127,20 +128,7 @@ async function loadLocale(code: string): Promise { } function parseAcceptLanguage(header: string): string | null { - try { - for (const lang of header - .split(',') - .map((l) => l.split(';')[0]?.trim()) - .filter(Boolean)) { - const exact = LOCALES.find((loc) => loc.code === lang) - if (exact) return exact.code - const prefix = LOCALES.find((loc) => loc.code.startsWith(lang.split('-')[0] + '-')) - if (prefix) return prefix.code - } - } catch { - // Malformed header, ignore - } - return null + return matchLocale(header.split(',').map((l) => l.split(';')[0])) } export default defineNuxtPlugin({ diff --git a/apps/labrinth/src/models/v3/preferences.rs b/apps/labrinth/src/models/v3/preferences.rs index 61044956295..5505b898af2 100644 --- a/apps/labrinth/src/models/v3/preferences.rs +++ b/apps/labrinth/src/models/v3/preferences.rs @@ -89,17 +89,11 @@ pub enum Theme { Retro, } -#[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq, Component)] +#[derive( + Debug, Serialize, Deserialize, ToSchema, Default, PartialEq, Component, +)] pub struct LocalizationPreferences { - pub locale: String, -} - -impl Default for LocalizationPreferences { - fn default() -> Self { - Self { - locale: "en-US".to_owned(), - } - } + pub locale: Option, } #[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq, Component)] diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index c6ceed2f157..bf178fd0453 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -1834,7 +1834,7 @@ export namespace Labrinth { } export type LocalizationPreferences = { - locale: string + locale: string | null } export type LayoutPreferences = { diff --git a/packages/app-lib/migrations/20261019120000_detect-system-locale.sql b/packages/app-lib/migrations/20261019120000_detect-system-locale.sql new file mode 100644 index 00000000000..2c320b7e63f --- /dev/null +++ b/packages/app-lib/migrations/20261019120000_detect-system-locale.sql @@ -0,0 +1 @@ +UPDATE settings SET locale = '' WHERE locale = 'en-US'; diff --git a/packages/ui/src/composables/i18n.ts b/packages/ui/src/composables/i18n.ts index 1fd94370250..f8279ca7f3a 100644 --- a/packages/ui/src/composables/i18n.ts +++ b/packages/ui/src/composables/i18n.ts @@ -233,6 +233,27 @@ export function transformCrowdinMessages(messages: CrowdinMessages): Record l.code)) +/** + * Picks the best supported locale for a list of language tags, most preferred first. + * Falls back to the same language, preferring its home region (e.g. `de` becomes `de-DE`). + */ +export function matchLocale(languages: readonly string[]): string | null { + for (const language of languages) { + const tag = language.trim().toLowerCase() + if (!tag) continue + + const exact = LOCALES.find((l) => l.code.toLowerCase() === tag) + if (exact) return exact.code + + const base = tag.split('-')[0] + const sameLanguage = LOCALES.filter((l) => l.code.toLowerCase().startsWith(`${base}-`)) + const match = + sameLanguage.find((l) => l.code.toLowerCase() === `${base}-${base}`) ?? sameLanguage[0] + if (match) return match.code + } + return null +} + /** * Builds locale messages from glob-imported modules. * Only includes locales that are defined in the LOCALES array. diff --git a/packages/ui/src/layouts/wrapped/settings/language-settings/index.vue b/packages/ui/src/layouts/wrapped/settings/language-settings/index.vue index 75606ef3ddc..4cd53a7812d 100644 --- a/packages/ui/src/layouts/wrapped/settings/language-settings/index.vue +++ b/packages/ui/src/layouts/wrapped/settings/language-settings/index.vue @@ -99,7 +99,7 @@ watch( preferences, (value) => { if (!value || hasChanges.value) return - persistedLocale.value = value.localization.locale + persistedLocale.value = value.localization.locale ?? locale.value }, { immediate: true, flush: 'sync' }, )