feat: i18n switcher in app-frontend (#4990)

* feat: app i18n stuff

* feat: locale switching on load

* feat: db migration

* feat: polish + fade indicator impl onto TabbedModal

* fix: prepr checks

* fix: remove staging lock for language switching

* fix: lint
This commit is contained in:
Calum H.
2025-12-29 19:41:39 +00:00
committed by GitHub
parent 30106d5f82
commit 042451bad6
21 changed files with 624 additions and 474 deletions

View File

@@ -92,6 +92,7 @@ import {
isDev,
isNetworkMetered,
} from '@/helpers/utils.js'
import i18n from '@/i18n.config'
import {
provideAppUpdateDownloadProgress,
subscribeToDownloadProgress,
@@ -224,6 +225,7 @@ async function setupApp() {
const {
native_decorations,
theme,
locale,
telemetry,
collapsed_navigation,
advanced_rendering,
@@ -235,6 +237,11 @@ async function setupApp() {
pending_update_toast_for_version,
} = await getSettings()
// Initialize locale from saved settings
if (locale) {
i18n.global.locale.value = locale
}
if (default_page === 'Library') {
await router.push('/library')
}

View File

@@ -3,13 +3,21 @@ import {
CoffeeIcon,
GameIcon,
GaugeIcon,
LanguagesIcon,
ModrinthIcon,
PaintbrushIcon,
ReportIcon,
SettingsIcon,
ShieldIcon,
} from '@modrinth/assets'
import { defineMessage, defineMessages, ProgressBar, TabbedModal, useVIntl } from '@modrinth/ui'
import {
commonMessages,
defineMessage,
defineMessages,
ProgressBar,
TabbedModal,
useVIntl,
} from '@modrinth/ui'
import { getVersion } from '@tauri-apps/api/app'
import { platform as getOsPlatform, version as getOsVersion } from '@tauri-apps/plugin-os'
import { computed, ref, watch } from 'vue'
@@ -19,6 +27,7 @@ import AppearanceSettings from '@/components/ui/settings/AppearanceSettings.vue'
import DefaultInstanceSettings from '@/components/ui/settings/DefaultInstanceSettings.vue'
import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue'
import JavaSettings from '@/components/ui/settings/JavaSettings.vue'
import LanguageSettings from '@/components/ui/settings/LanguageSettings.vue'
import PrivacySettings from '@/components/ui/settings/PrivacySettings.vue'
import ResourceManagementSettings from '@/components/ui/settings/ResourceManagementSettings.vue'
import { get, set } from '@/helpers/settings.ts'
@@ -45,6 +54,15 @@ const tabs = [
icon: PaintbrushIcon,
content: AppearanceSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.language',
defaultMessage: 'Language',
}),
icon: LanguagesIcon,
content: LanguageSettings,
badge: commonMessages.beta,
},
{
name: defineMessage({
id: 'app.settings.tabs.privacy',

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import {
Admonition,
AutoLink,
IntlFormatted,
LanguageSelector,
languageSelectorMessages,
LOCALES,
useVIntl,
} from '@modrinth/ui'
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import i18n from '@/i18n.config'
const { formatMessage } = useVIntl()
const platform = formatMessage(languageSelectorMessages.platformApp)
const settings = ref(await get())
watch(
settings,
async () => {
await set(settings.value)
},
{ deep: true },
)
const $isChanging = ref(false)
async function onLocaleChange(newLocale: string) {
if (settings.value.locale === newLocale) return
$isChanging.value = true
try {
i18n.global.locale.value = newLocale
settings.value.locale = newLocale
} finally {
$isChanging.value = false
}
}
</script>
<template>
<h2 class="m-0 text-lg font-extrabold text-contrast">Language</h2>
<Admonition type="warning" class="mt-2 mb-4">
{{ formatMessage(languageSelectorMessages.languageWarning, { platform }) }}
</Admonition>
<p class="m-0 mb-4">
<IntlFormatted
:message-id="languageSelectorMessages.languagesDescription"
:values="{ platform }"
>
<template #~crowdin-link="{ children }">
<AutoLink to="https://translate.modrinth.com">
<component :is="() => children" />
</AutoLink>
</template>
</IntlFormatted>
</p>
<LanguageSelector
:current-locale="settings.locale"
:locales="LOCALES"
:on-locale-change="onLocaleChange"
:is-changing="$isChanging"
/>
</template>

View File

@@ -36,6 +36,7 @@ export type AppSettings = {
max_concurrent_writes: number
theme: ColorTheme
locale: string
default_page: 'home' | 'library'
collapsed_navigation: boolean
hide_nametag_skins_page: boolean

View File

@@ -23,6 +23,9 @@
"app.settings.tabs.java-installations": {
"message": "Java installations"
},
"app.settings.tabs.language": {
"message": "Language"
},
"app.settings.tabs.privacy": {
"message": "Privacy"
},

View File

@@ -2810,30 +2810,6 @@
"settings.display.theme.title": {
"message": "Color theme"
},
"settings.language.categories.default": {
"message": "Standard languages"
},
"settings.language.categories.search-result": {
"message": "Search results"
},
"settings.language.description": {
"message": "Choose your preferred language for the site. Translations are contributed by volunteers <crowdin-link>on Crowdin</crowdin-link>."
},
"settings.language.languages.automatic": {
"message": "Sync with the system language"
},
"settings.language.languages.search-field.placeholder": {
"message": "Search for a language..."
},
"settings.language.languages.search-results-announcement": {
"message": "{matches, plural, =0 {No languages match} one {# language matches} other {# languages match}} your search."
},
"settings.language.languages.search.no-results": {
"message": "No languages match your search."
},
"settings.language.warning": {
"message": "Changing the site language may cause some content to appear in English if a translation is not available. The site is not yet fully translated, so some content may remain in English for certain languages. We are still working on improving our localization system, so occasionally content may appear broken."
},
"settings.pats.action.create": {
"message": "Create a PAT"
},

View File

@@ -14,14 +14,12 @@
label: formatMessage(commonSettingsMessages.appearance),
icon: PaintbrushIcon,
},
isStaging
? {
link: '/settings/language',
label: formatMessage(commonSettingsMessages.language),
icon: LanguagesIcon,
badge: `${formatMessage(commonMessages.beta)}`,
}
: null,
{
link: '/settings/language',
label: formatMessage(commonSettingsMessages.language),
icon: LanguagesIcon,
badge: `${formatMessage(commonMessages.beta)}`,
},
auth.user ? { type: 'heading', label: 'Account' } : null,
auth.user
? {
@@ -103,5 +101,4 @@ const { formatMessage } = useVIntl()
const route = useNativeRoute()
const auth = await useAuth()
const isStaging = useRuntimeConfig().public.siteUrl !== 'https://modrinth.com'
</script>

View File

@@ -1,180 +1,31 @@
<script setup lang="ts">
import { RadioButtonCheckedIcon, RadioButtonIcon } from '@modrinth/assets'
import {
Admonition,
commonSettingsMessages,
defineMessages,
IntlFormatted,
LanguageSelector,
languageSelectorMessages,
LOCALES,
useVIntl,
} from '@modrinth/ui'
import Fuse from 'fuse.js/dist/fuse.basic'
import { isModifierKeyDown } from '~/helpers/events.ts'
const { formatMessage } = useVIntl()
const { locale, setLocale, locales } = useI18n()
const { locale, setLocale } = useI18n()
const messages = defineMessages({
languagesDescription: {
id: 'settings.language.description',
defaultMessage:
'Choose your preferred language for the site. Translations are contributed by volunteers <crowdin-link>on Crowdin</crowdin-link>.',
},
automaticLocale: {
id: 'settings.language.languages.automatic',
defaultMessage: 'Sync with the system language',
},
noResults: {
id: 'settings.language.languages.search.no-results',
defaultMessage: 'No languages match your search.',
},
searchFieldPlaceholder: {
id: 'settings.language.languages.search-field.placeholder',
defaultMessage: 'Search for a language...',
},
searchResultsAnnouncement: {
id: 'settings.language.languages.search-results-announcement',
defaultMessage:
'{matches, plural, =0 {No languages match} one {# language matches} other {# languages match}} your search.',
},
languageWarning: {
id: 'settings.language.warning',
defaultMessage:
'Changing the site language may cause some content to appear in English if a translation is not available. The site is not yet fully translated, so some content may remain in English for certain languages. We are still working on improving our localization system, so occasionally content may appear broken.',
},
})
const platform = formatMessage(languageSelectorMessages.platformSite)
const categoryNames = defineMessages({
default: {
id: 'settings.language.categories.default',
defaultMessage: 'Standard languages',
},
searchResult: {
id: 'settings.language.categories.search-result',
defaultMessage: 'Search results',
},
})
const $isChanging = ref(false)
type Category = keyof typeof categoryNames
type LocaleInfo = {
category: Category
tag: string
displayName: string
nativeName: string
searchTerms?: string[]
}
const displayNames = new Intl.DisplayNames(['en'], { type: 'language' })
const $locales = computed(() => {
const result: LocaleInfo[] = []
const localeList = Array.isArray(locales.value) ? locales.value : Object.keys(locales.value)
for (const loc of localeList) {
const tag = typeof loc === 'string' ? loc : loc.code
const name = typeof loc === 'object' && loc.name ? loc.name : (displayNames.of(tag) ?? tag)
const nativeDisplayNames = new Intl.DisplayNames([tag], { type: 'language' })
const nativeName = nativeDisplayNames.of(tag) ?? tag
result.push({
tag,
category: 'default',
displayName: name,
nativeName,
searchTerms: [tag, name, nativeName],
})
}
return result
})
const $query = ref('')
const isQueryEmpty = () => $query.value.trim().length === 0
const fuse = new Fuse<LocaleInfo>([], {
keys: ['tag', 'displayName', 'nativeName', 'searchTerms'],
threshold: 0.4,
distance: 100,
})
watchSyncEffect(() => fuse.setCollection($locales.value))
const $categories = computed(() => {
const categories = new Map<Category, LocaleInfo[]>()
categories.set('default', $locales.value)
return categories
})
const $searchResults = computed(() => {
return new Map<Category, LocaleInfo[]>([
['searchResult', isQueryEmpty() ? [] : fuse.search($query.value).map(({ item }) => item)],
])
})
const $displayCategories = computed(() =>
isQueryEmpty() ? $categories.value : $searchResults.value,
)
const $changingTo = ref<string | undefined>()
const isChanging = () => $changingTo.value != null
const $activeLocale = computed(() => {
if ($changingTo.value != null) return $changingTo.value
return locale.value
})
async function changeLocale(value: string) {
if ($activeLocale.value === value) return
$changingTo.value = value
async function onLocaleChange(newLocale: string) {
if (locale.value === newLocale) return
$isChanging.value = true
try {
await setLocale(value)
await setLocale(newLocale)
} finally {
$changingTo.value = undefined
$isChanging.value = false
}
}
const $languagesList = ref<HTMLDivElement | undefined>()
function onSearchKeydown(e: KeyboardEvent) {
if (e.key !== 'Enter' || isModifierKeyDown(e)) return
const focusableTarget = $languagesList.value?.querySelector(
'input, [tabindex]:not([tabindex="-1"])',
) as HTMLElement | undefined
focusableTarget?.focus()
}
function onItemKeydown(e: KeyboardEvent, loc: LocaleInfo) {
switch (e.key) {
case 'Enter':
case ' ':
break
default:
return
}
if (isModifierKeyDown(e) || isChanging()) return
changeLocale(loc.tag)
}
function onItemClick(e: MouseEvent, loc: LocaleInfo) {
if (isModifierKeyDown(e) || isChanging()) return
changeLocale(loc.tag)
}
function getItemLabel(loc: LocaleInfo) {
return `${loc.nativeName}. ${loc.displayName}`
}
</script>
<template>
@@ -183,11 +34,14 @@ function getItemLabel(loc: LocaleInfo) {
<h2 class="text-2xl">{{ formatMessage(commonSettingsMessages.language) }}</h2>
<Admonition type="warning">
{{ formatMessage(messages.languageWarning) }}
{{ formatMessage(languageSelectorMessages.languageWarning, { platform }) }}
</Admonition>
<div class="card-description mt-4">
<IntlFormatted :message-id="messages.languagesDescription">
<IntlFormatted
:message-id="languageSelectorMessages.languagesDescription"
:values="{ platform }"
>
<template #~crowdin-link="{ children }">
<a href="https://translate.modrinth.com">
<component :is="() => children" />
@@ -196,180 +50,17 @@ function getItemLabel(loc: LocaleInfo) {
</IntlFormatted>
</div>
<div v-if="$locales.length > 1" class="search-container">
<input
id="language-search"
v-model="$query"
name="language"
type="search"
:placeholder="formatMessage(messages.searchFieldPlaceholder)"
class="language-search"
:disabled="isChanging()"
@keydown="onSearchKeydown"
/>
<div id="language-search-results-announcements" class="visually-hidden" aria-live="polite">
{{
isQueryEmpty()
? ''
: formatMessage(messages.searchResultsAnnouncement, {
matches: $searchResults.get('searchResult')?.length ?? 0,
})
}}
</div>
</div>
<div ref="$languagesList" class="languages-list">
<template v-for="[category, categoryLocales] in $displayCategories" :key="category">
<strong class="category-name">
{{ formatMessage(categoryNames[category]) }}
</strong>
<div
v-if="category === 'searchResult' && categoryLocales.length === 0"
class="no-results"
tabindex="0"
>
{{ formatMessage(messages.noResults) }}
</div>
<template v-for="loc in categoryLocales" :key="loc.tag">
<div
role="button"
:aria-pressed="$activeLocale === loc.tag"
:class="{
'language-item': true,
pending: $changingTo === loc.tag,
}"
:aria-disabled="isChanging() && $changingTo !== loc.tag"
:tabindex="0"
:aria-label="getItemLabel(loc)"
@click="(e) => onItemClick(e, loc)"
@keydown="(e) => onItemKeydown(e, loc)"
>
<RadioButtonCheckedIcon v-if="$activeLocale === loc.tag" class="radio" />
<RadioButtonIcon v-else class="radio" />
<div class="language-names">
<div class="language-name">
{{ loc.displayName }}
</div>
<div class="language-translated-name">
{{ loc.nativeName }}
</div>
</div>
</div>
</template>
</template>
</div>
<LanguageSelector
:current-locale="locale"
:locales="LOCALES"
:on-locale-change="onLocaleChange"
:is-changing="$isChanging"
/>
</section>
</div>
</template>
<style scoped lang="scss">
.languages-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.language-item {
display: flex;
align-items: center;
column-gap: 0.5rem;
border: 0.15rem solid transparent;
border-radius: var(--spacing-card-md);
background: var(--color-button-bg);
padding: var(--spacing-card-md);
cursor: pointer;
position: relative;
overflow: hidden;
&:not([aria-disabled='true']):hover {
border-color: var(--color-button-bg-hover);
}
&:focus-visible,
&:has(:focus-visible) {
outline: 2px solid var(--color-brand);
}
&.pending::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: linear-gradient(
102deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0) 20%,
rgba(0, 0, 0, 0.1) 45%,
rgba(0, 0, 0, 0.1) 50%,
rgba(0, 0, 0, 0) 80%,
rgba(0, 0, 0, 0) 100%
);
background-repeat: no-repeat;
animation: shimmerSliding 2.5s ease-out infinite;
.dark-mode &,
.oled-mode & {
background-image: linear-gradient(
102deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0) 20%,
rgba(255, 255, 255, 0.1) 45%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0) 80%,
rgba(255, 255, 255, 0) 100%
);
}
@keyframes shimmerSliding {
from {
left: -100%;
}
to {
left: 100%;
}
}
}
&[aria-disabled='true']:not(.pending) {
opacity: 0.8;
pointer-events: none;
cursor: default;
}
}
.radio {
width: 24px;
height: 24px;
}
.language-names {
display: flex;
justify-content: space-between;
flex: 1;
flex-wrap: wrap;
}
.language-name {
font-weight: bold;
}
.language-search {
width: 100%;
}
.search-container {
margin-bottom: var(--spacing-card-md);
}
.card-description {
margin-bottom: calc(var(--spacing-card-sm) + var(--spacing-card-md));
@@ -385,13 +76,4 @@ function getItemLabel(loc: LocaleInfo) {
}
}
}
.category-name {
margin-top: var(--spacing-card-md);
}
.no-results {
padding: var(--spacing-card-md);
color: var(--color-text-secondary);
}
</style>