Skip to content

Commit 042451b

Browse files
authored
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
1 parent 30106d5 commit 042451b

21 files changed

+625
-475
lines changed

.github/instructions/i18n-convert.instructions.md

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
applyTo: '**/*.vue'
33
---
44

5-
You are given a Nuxt/Vue single-file component (.vue). Your task is to convert every hard-coded natural-language string in the <template> into our localization system using @vintl/vintl-nuxt (which wraps FormatJS).
5+
You are given a Nuxt/Vue single-file component (.vue). Your task is to convert every hard-coded natural-language string in the <template> into our localization system using vue-i18n with utilities from `@modrinth/ui`.
66

77
Please follow these rules precisely:
88

@@ -13,40 +13,53 @@ Please follow these rules precisely:
1313

1414
2. Create message definitions
1515

16-
- In the <script setup> block, import `defineMessage` or `defineMessages` from `@vintl/vintl`.
16+
- In the <script setup> block, import `defineMessage` or `defineMessages` from `@modrinth/ui`.
1717
- For each extracted string, define a message with a unique `id` (use a descriptive prefix based on the component path, e.g. `auth.welcome.long-title`) and a `defaultMessage` equal to the original English string.
1818
Example:
1919
const messages = defineMessages({
2020
welcomeTitle: { id: 'auth.welcome.title', defaultMessage: 'Welcome' },
21-
welcomeDescription: { id: 'auth.welcome.description', defaultMessage: 'Youre now part of the community…' },
21+
welcomeDescription: { id: 'auth.welcome.description', defaultMessage: 'You're now part of the community…' },
2222
})
2323

2424
3. Handle variables and ICU formats
2525

2626
- Replace dynamic parts with ICU placeholders: "Hello, ${user.name}!" → `{name}` and defaultMessage: 'Hello, {name}!'
27-
- For numbers/dates/times, use ICU/FormatJS options (e.g., currency): `{price, number, ::currency/USD}`
27+
- For numbers/dates/times, use ICU options (e.g., currency): `{price, number, ::currency/USD}`
2828
- For plurals/selects, use ICU: `'{count, plural, one {# message} other {# messages}}'`
2929

3030
4. Rich-text messages (links/markup)
3131

3232
- In `defaultMessage`, wrap link/markup ranges with tags, e.g.:
3333
"By creating an account, you agree to our <terms-link>Terms</terms-link> and <privacy-link>Privacy Policy</privacy-link>."
34-
- Render rich-text messages with `<IntlFormatted>` from `@vintl/vintl/components` and map tags via `values`:
35-
<IntlFormatted
36-
:message="messages.tosLabel"
37-
:values="{
38-
'terms-link': (chunks) => <NuxtLink to='/terms'>{chunks}</NuxtLink>,
39-
'privacy-link': (chunks) => <NuxtLink to='/privacy'>{chunks}</NuxtLink>,
40-
}"
41-
/>
42-
- For simple emphasis: `'Welcome to <strong>Modrinth</strong>!'` and map `'strong': (c) => <strong>{c}</strong>`
34+
- Render rich-text messages with `<IntlFormatted>` from `@modrinth/ui` using named slots:
35+
<IntlFormatted :message-id="messages.tosLabel">
36+
<template #terms-link="{ children }">
37+
<NuxtLink to="/terms">
38+
<component :is="() => children" />
39+
</NuxtLink>
40+
</template>
41+
<template #privacy-link="{ children }">
42+
<NuxtLink to="/privacy">
43+
<component :is="() => children" />
44+
</NuxtLink>
45+
</template>
46+
</IntlFormatted>
47+
- For simple emphasis: `'Welcome to <strong>Modrinth</strong>!'` with a slot:
48+
<template #strong="{ children }">
49+
<strong><component :is="() => children" /></strong>
50+
</template>
51+
- For more complex child handling, use `normalizeChildren` from `@modrinth/ui`:
52+
<template #bold="{ children }">
53+
<strong><component :is="() => normalizeChildren(children)" /></strong>
54+
</template>
4355

4456
5. Formatting in templates
4557

46-
- Import and use `useVIntl()`; prefer `formatMessage` for simple strings:
58+
- Import and use `useVIntl()` from `@modrinth/ui`; prefer `formatMessage` for simple strings:
4759
`const { formatMessage } = useVIntl()`
4860
`<button>{{ formatMessage(messages.welcomeTitle) }}</button>`
49-
- Vue methods like `$formatMessage`, `$formatNumber`, `$formatDate` are also available if needed.
61+
- Pass variables as a second argument:
62+
`{{ formatMessage(messages.greeting, { name: user.name }) }}`
5063

5164
6. Naming conventions and id stability
5265

@@ -58,7 +71,8 @@ Please follow these rules precisely:
5871

5972
8. Update imports and remove literals
6073

61-
- Ensure imports for `defineMessage`/`defineMessages`, `useVIntl`, and `<IntlFormatted>` are present. Replace all hard-coded strings with `formatMessage(...)` or `<IntlFormatted>` and remove the literals.
74+
- Ensure imports from `@modrinth/ui` are present: `defineMessage`/`defineMessages`, `useVIntl`, `IntlFormatted`, and optionally `normalizeChildren`.
75+
- Replace all hard-coded strings with `formatMessage(...)` or `<IntlFormatted>` and remove the literals.
6276

6377
9. Preserve functionality
6478

apps/app-frontend/src/App.vue

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ import {
9292
isDev,
9393
isNetworkMetered,
9494
} from '@/helpers/utils.js'
95+
import i18n from '@/i18n.config'
9596
import {
9697
provideAppUpdateDownloadProgress,
9798
subscribeToDownloadProgress,
@@ -224,6 +225,7 @@ async function setupApp() {
224225
const {
225226
native_decorations,
226227
theme,
228+
locale,
227229
telemetry,
228230
collapsed_navigation,
229231
advanced_rendering,
@@ -235,6 +237,11 @@ async function setupApp() {
235237
pending_update_toast_for_version,
236238
} = await getSettings()
237239
240+
// Initialize locale from saved settings
241+
if (locale) {
242+
i18n.global.locale.value = locale
243+
}
244+
238245
if (default_page === 'Library') {
239246
await router.push('/library')
240247
}

apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,21 @@ import {
33
CoffeeIcon,
44
GameIcon,
55
GaugeIcon,
6+
LanguagesIcon,
67
ModrinthIcon,
78
PaintbrushIcon,
89
ReportIcon,
910
SettingsIcon,
1011
ShieldIcon,
1112
} from '@modrinth/assets'
12-
import { defineMessage, defineMessages, ProgressBar, TabbedModal, useVIntl } from '@modrinth/ui'
13+
import {
14+
commonMessages,
15+
defineMessage,
16+
defineMessages,
17+
ProgressBar,
18+
TabbedModal,
19+
useVIntl,
20+
} from '@modrinth/ui'
1321
import { getVersion } from '@tauri-apps/api/app'
1422
import { platform as getOsPlatform, version as getOsVersion } from '@tauri-apps/plugin-os'
1523
import { computed, ref, watch } from 'vue'
@@ -19,6 +27,7 @@ import AppearanceSettings from '@/components/ui/settings/AppearanceSettings.vue'
1927
import DefaultInstanceSettings from '@/components/ui/settings/DefaultInstanceSettings.vue'
2028
import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue'
2129
import JavaSettings from '@/components/ui/settings/JavaSettings.vue'
30+
import LanguageSettings from '@/components/ui/settings/LanguageSettings.vue'
2231
import PrivacySettings from '@/components/ui/settings/PrivacySettings.vue'
2332
import ResourceManagementSettings from '@/components/ui/settings/ResourceManagementSettings.vue'
2433
import { get, set } from '@/helpers/settings.ts'
@@ -45,6 +54,15 @@ const tabs = [
4554
icon: PaintbrushIcon,
4655
content: AppearanceSettings,
4756
},
57+
{
58+
name: defineMessage({
59+
id: 'app.settings.tabs.language',
60+
defaultMessage: 'Language',
61+
}),
62+
icon: LanguagesIcon,
63+
content: LanguageSettings,
64+
badge: commonMessages.beta,
65+
},
4866
{
4967
name: defineMessage({
5068
id: 'app.settings.tabs.privacy',
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<script setup lang="ts">
2+
import {
3+
Admonition,
4+
AutoLink,
5+
IntlFormatted,
6+
LanguageSelector,
7+
languageSelectorMessages,
8+
LOCALES,
9+
useVIntl,
10+
} from '@modrinth/ui'
11+
import { ref, watch } from 'vue'
12+
13+
import { get, set } from '@/helpers/settings.ts'
14+
import i18n from '@/i18n.config'
15+
16+
const { formatMessage } = useVIntl()
17+
18+
const platform = formatMessage(languageSelectorMessages.platformApp)
19+
20+
const settings = ref(await get())
21+
22+
watch(
23+
settings,
24+
async () => {
25+
await set(settings.value)
26+
},
27+
{ deep: true },
28+
)
29+
30+
const $isChanging = ref(false)
31+
32+
async function onLocaleChange(newLocale: string) {
33+
if (settings.value.locale === newLocale) return
34+
35+
$isChanging.value = true
36+
try {
37+
i18n.global.locale.value = newLocale
38+
settings.value.locale = newLocale
39+
} finally {
40+
$isChanging.value = false
41+
}
42+
}
43+
</script>
44+
45+
<template>
46+
<h2 class="m-0 text-lg font-extrabold text-contrast">Language</h2>
47+
48+
<Admonition type="warning" class="mt-2 mb-4">
49+
{{ formatMessage(languageSelectorMessages.languageWarning, { platform }) }}
50+
</Admonition>
51+
52+
<p class="m-0 mb-4">
53+
<IntlFormatted
54+
:message-id="languageSelectorMessages.languagesDescription"
55+
:values="{ platform }"
56+
>
57+
<template #~crowdin-link="{ children }">
58+
<AutoLink to="https://translate.modrinth.com">
59+
<component :is="() => children" />
60+
</AutoLink>
61+
</template>
62+
</IntlFormatted>
63+
</p>
64+
65+
<LanguageSelector
66+
:current-locale="settings.locale"
67+
:locales="LOCALES"
68+
:on-locale-change="onLocaleChange"
69+
:is-changing="$isChanging"
70+
/>
71+
</template>

apps/app-frontend/src/helpers/settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export type AppSettings = {
3636
max_concurrent_writes: number
3737

3838
theme: ColorTheme
39+
locale: string
3940
default_page: 'home' | 'library'
4041
collapsed_navigation: boolean
4142
hide_nametag_skins_page: boolean

apps/app-frontend/src/locales/en-US/index.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
"app.settings.tabs.java-installations": {
2424
"message": "Java installations"
2525
},
26+
"app.settings.tabs.language": {
27+
"message": "Language"
28+
},
2629
"app.settings.tabs.privacy": {
2730
"message": "Privacy"
2831
},

apps/frontend/src/locales/en-US/index.json

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2810,30 +2810,6 @@
28102810
"settings.display.theme.title": {
28112811
"message": "Color theme"
28122812
},
2813-
"settings.language.categories.default": {
2814-
"message": "Standard languages"
2815-
},
2816-
"settings.language.categories.search-result": {
2817-
"message": "Search results"
2818-
},
2819-
"settings.language.description": {
2820-
"message": "Choose your preferred language for the site. Translations are contributed by volunteers <crowdin-link>on Crowdin</crowdin-link>."
2821-
},
2822-
"settings.language.languages.automatic": {
2823-
"message": "Sync with the system language"
2824-
},
2825-
"settings.language.languages.search-field.placeholder": {
2826-
"message": "Search for a language..."
2827-
},
2828-
"settings.language.languages.search-results-announcement": {
2829-
"message": "{matches, plural, =0 {No languages match} one {# language matches} other {# languages match}} your search."
2830-
},
2831-
"settings.language.languages.search.no-results": {
2832-
"message": "No languages match your search."
2833-
},
2834-
"settings.language.warning": {
2835-
"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."
2836-
},
28372813
"settings.pats.action.create": {
28382814
"message": "Create a PAT"
28392815
},

apps/frontend/src/pages/settings.vue

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,12 @@
1414
label: formatMessage(commonSettingsMessages.appearance),
1515
icon: PaintbrushIcon,
1616
},
17-
isStaging
18-
? {
19-
link: '/settings/language',
20-
label: formatMessage(commonSettingsMessages.language),
21-
icon: LanguagesIcon,
22-
badge: `${formatMessage(commonMessages.beta)}`,
23-
}
24-
: null,
17+
{
18+
link: '/settings/language',
19+
label: formatMessage(commonSettingsMessages.language),
20+
icon: LanguagesIcon,
21+
badge: `${formatMessage(commonMessages.beta)}`,
22+
},
2523
auth.user ? { type: 'heading', label: 'Account' } : null,
2624
auth.user
2725
? {
@@ -103,5 +101,4 @@ const { formatMessage } = useVIntl()
103101
104102
const route = useNativeRoute()
105103
const auth = await useAuth()
106-
const isStaging = useRuntimeConfig().public.siteUrl !== 'https://modrinth.com'
107104
</script>

0 commit comments

Comments
 (0)