1
0

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

@@ -2,7 +2,7 @@
applyTo: '**/*.vue'
---
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).
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`.
Please follow these rules precisely:
@@ -13,40 +13,53 @@ Please follow these rules precisely:
2. Create message definitions
- In the <script setup> block, import `defineMessage` or `defineMessages` from `@vintl/vintl`.
- In the <script setup> block, import `defineMessage` or `defineMessages` from `@modrinth/ui`.
- 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.
Example:
const messages = defineMessages({
welcomeTitle: { id: 'auth.welcome.title', defaultMessage: 'Welcome' },
welcomeDescription: { id: 'auth.welcome.description', defaultMessage: 'Youre now part of the community…' },
welcomeDescription: { id: 'auth.welcome.description', defaultMessage: 'You're now part of the community…' },
})
3. Handle variables and ICU formats
- Replace dynamic parts with ICU placeholders: "Hello, ${user.name}!" → `{name}` and defaultMessage: 'Hello, {name}!'
- For numbers/dates/times, use ICU/FormatJS options (e.g., currency): `{price, number, ::currency/USD}`
- For numbers/dates/times, use ICU options (e.g., currency): `{price, number, ::currency/USD}`
- For plurals/selects, use ICU: `'{count, plural, one {# message} other {# messages}}'`
4. Rich-text messages (links/markup)
- In `defaultMessage`, wrap link/markup ranges with tags, e.g.:
"By creating an account, you agree to our <terms-link>Terms</terms-link> and <privacy-link>Privacy Policy</privacy-link>."
- Render rich-text messages with `<IntlFormatted>` from `@vintl/vintl/components` and map tags via `values`:
<IntlFormatted
:message="messages.tosLabel"
:values="{
'terms-link': (chunks) => <NuxtLink to='/terms'>{chunks}</NuxtLink>,
'privacy-link': (chunks) => <NuxtLink to='/privacy'>{chunks}</NuxtLink>,
}"
/>
- For simple emphasis: `'Welcome to <strong>Modrinth</strong>!'` and map `'strong': (c) => <strong>{c}</strong>`
- Render rich-text messages with `<IntlFormatted>` from `@modrinth/ui` using named slots:
<IntlFormatted :message-id="messages.tosLabel">
<template #terms-link="{ children }">
<NuxtLink to="/terms">
<component :is="() => children" />
</NuxtLink>
</template>
<template #privacy-link="{ children }">
<NuxtLink to="/privacy">
<component :is="() => children" />
</NuxtLink>
</template>
</IntlFormatted>
- For simple emphasis: `'Welcome to <strong>Modrinth</strong>!'` with a slot:
<template #strong="{ children }">
<strong><component :is="() => children" /></strong>
</template>
- For more complex child handling, use `normalizeChildren` from `@modrinth/ui`:
<template #bold="{ children }">
<strong><component :is="() => normalizeChildren(children)" /></strong>
</template>
5. Formatting in templates
- Import and use `useVIntl()`; prefer `formatMessage` for simple strings:
- Import and use `useVIntl()` from `@modrinth/ui`; prefer `formatMessage` for simple strings:
`const { formatMessage } = useVIntl()`
`<button>{{ formatMessage(messages.welcomeTitle) }}</button>`
- Vue methods like `$formatMessage`, `$formatNumber`, `$formatDate` are also available if needed.
- Pass variables as a second argument:
`{{ formatMessage(messages.greeting, { name: user.name }) }}`
6. Naming conventions and id stability
@@ -58,7 +71,8 @@ Please follow these rules precisely:
8. Update imports and remove literals
- Ensure imports for `defineMessage`/`defineMessages`, `useVIntl`, and `<IntlFormatted>` are present. Replace all hard-coded strings with `formatMessage(...)` or `<IntlFormatted>` and remove the literals.
- Ensure imports from `@modrinth/ui` are present: `defineMessage`/`defineMessages`, `useVIntl`, `IntlFormatted`, and optionally `normalizeChildren`.
- Replace all hard-coded strings with `formatMessage(...)` or `<IntlFormatted>` and remove the literals.
9. Preserve functionality

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>

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n locale = $4,\n default_page = $5,\n collapsed_navigation = $6,\n advanced_rendering = $7,\n native_decorations = $8,\n\n discord_rpc = $9,\n developer_mode = $10,\n telemetry = $11,\n personalized_ads = $12,\n\n onboarded = $13,\n\n extra_launch_args = jsonb($14),\n custom_env_vars = jsonb($15),\n mc_memory_max = $16,\n mc_force_fullscreen = $17,\n mc_game_resolution_x = $18,\n mc_game_resolution_y = $19,\n hide_on_process_start = $20,\n\n hook_pre_launch = $21,\n hook_wrapper = $22,\n hook_post_exit = $23,\n\n custom_dir = $24,\n prev_custom_dir = $25,\n migrated = $26,\n\n toggle_sidebar = $27,\n feature_flags = $28,\n hide_nametag_skins_page = $29,\n\n skipped_update = $30,\n pending_update_toast_for_version = $31,\n auto_download_updates = $32,\n\n version = $33\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 33
},
"nullable": []
},
"hash": "175067f04e775f5469146f3cb77c422c3ab7203409083fd3c9c968b00b46918f"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates,\n version\n FROM settings\n ",
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates,\n version\n FROM settings\n ",
"describe": {
"columns": [
{
@@ -19,148 +19,153 @@
"type_info": "Text"
},
{
"name": "default_page",
"name": "locale",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "collapsed_navigation",
"name": "default_page",
"ordinal": 4,
"type_info": "Integer"
"type_info": "Text"
},
{
"name": "hide_nametag_skins_page",
"name": "collapsed_navigation",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "advanced_rendering",
"name": "hide_nametag_skins_page",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "native_decorations",
"name": "advanced_rendering",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "discord_rpc",
"name": "native_decorations",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "developer_mode",
"name": "discord_rpc",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "telemetry",
"name": "developer_mode",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "personalized_ads",
"name": "telemetry",
"ordinal": 11,
"type_info": "Integer"
},
{
"name": "onboarded",
"name": "personalized_ads",
"ordinal": 12,
"type_info": "Integer"
},
{
"name": "extra_launch_args",
"name": "onboarded",
"ordinal": 13,
"type_info": "Text"
"type_info": "Integer"
},
{
"name": "custom_env_vars",
"name": "extra_launch_args",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "mc_memory_max",
"name": "custom_env_vars",
"ordinal": 15,
"type_info": "Integer"
"type_info": "Text"
},
{
"name": "mc_force_fullscreen",
"name": "mc_memory_max",
"ordinal": 16,
"type_info": "Integer"
},
{
"name": "mc_game_resolution_x",
"name": "mc_force_fullscreen",
"ordinal": 17,
"type_info": "Integer"
},
{
"name": "mc_game_resolution_y",
"name": "mc_game_resolution_x",
"ordinal": 18,
"type_info": "Integer"
},
{
"name": "hide_on_process_start",
"name": "mc_game_resolution_y",
"ordinal": 19,
"type_info": "Integer"
},
{
"name": "hook_pre_launch",
"name": "hide_on_process_start",
"ordinal": 20,
"type_info": "Text"
"type_info": "Integer"
},
{
"name": "hook_wrapper",
"name": "hook_pre_launch",
"ordinal": 21,
"type_info": "Text"
},
{
"name": "hook_post_exit",
"name": "hook_wrapper",
"ordinal": 22,
"type_info": "Text"
},
{
"name": "custom_dir",
"name": "hook_post_exit",
"ordinal": 23,
"type_info": "Text"
},
{
"name": "prev_custom_dir",
"name": "custom_dir",
"ordinal": 24,
"type_info": "Text"
},
{
"name": "migrated",
"name": "prev_custom_dir",
"ordinal": 25,
"type_info": "Text"
},
{
"name": "migrated",
"ordinal": 26,
"type_info": "Integer"
},
{
"name": "feature_flags",
"ordinal": 26,
"ordinal": 27,
"type_info": "Text"
},
{
"name": "toggle_sidebar",
"ordinal": 27,
"ordinal": 28,
"type_info": "Integer"
},
{
"name": "skipped_update",
"ordinal": 28,
"type_info": "Text"
},
{
"name": "pending_update_toast_for_version",
"ordinal": 29,
"type_info": "Text"
},
{
"name": "auto_download_updates",
"name": "pending_update_toast_for_version",
"ordinal": 30,
"type_info": "Text"
},
{
"name": "auto_download_updates",
"ordinal": 31,
"type_info": "Integer"
},
{
"name": "version",
"ordinal": 31,
"ordinal": 32,
"type_info": "Integer"
}
],
@@ -181,6 +186,7 @@
false,
false,
false,
false,
null,
null,
false,
@@ -202,5 +208,5 @@
false
]
},
"hash": "07ea3a644644de61c4ed7c30ee711d29fd49f10534230b1b03097275a30cb50f"
"hash": "8e62fba05f331f91822ec204695ecb40567541c547181a4ef847318845cf3110"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27,\n hide_nametag_skins_page = $28,\n\n skipped_update = $29,\n pending_update_toast_for_version = $30,\n auto_download_updates = $31,\n\n version = $32\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 32
},
"nullable": []
},
"hash": "a40e60da6dd1312d4a1ed52fa8fd2394e7ad21de1cb44cf8b93c4b1459cdc716"
}

View File

@@ -0,0 +1,2 @@
-- Add migration script here
ALTER TABLE settings ADD COLUMN locale TEXT NOT NULL DEFAULT 'en-US';

View File

@@ -12,6 +12,7 @@ pub struct Settings {
pub max_concurrent_writes: usize,
pub theme: Theme,
pub locale: String,
pub default_page: DefaultPage,
pub collapsed_navigation: bool,
pub hide_nametag_skins_page: bool,
@@ -66,7 +67,7 @@ impl Settings {
"
SELECT
max_concurrent_writes, max_concurrent_downloads,
theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,
theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,
discord_rpc, developer_mode, telemetry, personalized_ads,
onboarded,
json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,
@@ -85,6 +86,7 @@ impl Settings {
max_concurrent_downloads: res.max_concurrent_downloads as usize,
max_concurrent_writes: res.max_concurrent_writes as usize,
theme: Theme::from_string(&res.theme),
locale: res.locale,
default_page: DefaultPage::from_string(&res.default_page),
collapsed_navigation: res.collapsed_navigation == 1,
hide_nametag_skins_page: res.hide_nametag_skins_page == 1,
@@ -157,47 +159,49 @@ impl Settings {
max_concurrent_downloads = $2,
theme = $3,
default_page = $4,
collapsed_navigation = $5,
advanced_rendering = $6,
native_decorations = $7,
locale = $4,
default_page = $5,
collapsed_navigation = $6,
advanced_rendering = $7,
native_decorations = $8,
discord_rpc = $8,
developer_mode = $9,
telemetry = $10,
personalized_ads = $11,
discord_rpc = $9,
developer_mode = $10,
telemetry = $11,
personalized_ads = $12,
onboarded = $12,
onboarded = $13,
extra_launch_args = jsonb($13),
custom_env_vars = jsonb($14),
mc_memory_max = $15,
mc_force_fullscreen = $16,
mc_game_resolution_x = $17,
mc_game_resolution_y = $18,
hide_on_process_start = $19,
extra_launch_args = jsonb($14),
custom_env_vars = jsonb($15),
mc_memory_max = $16,
mc_force_fullscreen = $17,
mc_game_resolution_x = $18,
mc_game_resolution_y = $19,
hide_on_process_start = $20,
hook_pre_launch = $20,
hook_wrapper = $21,
hook_post_exit = $22,
hook_pre_launch = $21,
hook_wrapper = $22,
hook_post_exit = $23,
custom_dir = $23,
prev_custom_dir = $24,
migrated = $25,
custom_dir = $24,
prev_custom_dir = $25,
migrated = $26,
toggle_sidebar = $26,
feature_flags = $27,
hide_nametag_skins_page = $28,
toggle_sidebar = $27,
feature_flags = $28,
hide_nametag_skins_page = $29,
skipped_update = $29,
pending_update_toast_for_version = $30,
auto_download_updates = $31,
skipped_update = $30,
pending_update_toast_for_version = $31,
auto_download_updates = $32,
version = $32
version = $33
",
max_concurrent_writes,
max_concurrent_downloads,
theme,
self.locale,
default_page,
self.collapsed_navigation,
self.advanced_rendering,

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { type Component, ref } from 'vue'
import { type Component, nextTick, ref } from 'vue'
import { type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { useScrollIndicator } from '../../composables/scroll-indicator'
const { formatMessage } = useVIntl()
@@ -10,6 +11,7 @@ export type Tab<Props> = {
icon: Component
content: Component<Props>
props?: Props
badge?: MessageDescriptor
}
defineProps<{
@@ -19,8 +21,13 @@ defineProps<{
const selectedTab = ref(0)
const scrollContainer = ref<HTMLElement | null>(null)
const { showTopFade, showBottomFade, checkScrollState, forceCheck } =
useScrollIndicator(scrollContainer)
function setTab(index: number) {
selectedTab.value = index
nextTick(() => forceCheck())
}
defineExpose({ selectedTab, setTab })
@@ -34,16 +41,56 @@ defineExpose({ selectedTab, setTab })
v-for="(tab, index) in tabs"
:key="index"
:class="`flex gap-2 items-center text-left rounded-xl px-4 py-2 border-none text-nowrap font-semibold cursor-pointer active:scale-[0.97] transition-all ${selectedTab === index ? 'bg-button-bgSelected text-button-textSelected' : 'bg-transparent text-button-text hover:bg-button-bg hover:text-contrast'}`"
@click="() => (selectedTab = index)"
@click="() => setTab(index)"
>
<component :is="tab.icon" class="w-4 h-4" />
<span>{{ formatMessage(tab.name) }}</span>
<span
v-if="tab.badge"
class="rounded-full px-1.5 py-0.5 text-xs font-bold bg-brand-highlight text-brand-green"
>
{{ formatMessage(tab.badge) }}
</span>
</button>
<slot name="footer" />
</div>
<div class="w-[600px] h-[500px] overflow-y-auto px-4">
<component :is="tabs[selectedTab].content" v-bind="tabs[selectedTab].props ?? {}" />
<div class="relative">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-24"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-24"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-24 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<div
ref="scrollContainer"
class="w-[600px] h-[500px] overflow-y-auto px-4"
@scroll="checkScrollState"
>
<component :is="tabs[selectedTab].content" v-bind="tabs[selectedTab].props ?? {}" />
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-24"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-24"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-24 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
</div>
</template>

View File

@@ -0,0 +1,243 @@
<script setup lang="ts">
import { RadioButtonCheckedIcon, RadioButtonIcon, SearchIcon } from '@modrinth/assets'
import Fuse from 'fuse.js/dist/fuse.basic'
import { computed, ref, watchSyncEffect } from 'vue'
import { defineMessages, type LocaleDefinition, useVIntl } from '../../composables/i18n'
import { isModifierKeyDown } from '../../utils/events'
const { formatMessage } = useVIntl()
const props = defineProps<{
currentLocale: string
locales: LocaleDefinition[]
onLocaleChange: (locale: string) => Promise<void>
isChanging?: boolean
}>()
const messages = defineMessages({
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.',
},
standardLanguages: {
id: 'settings.language.categories.default',
defaultMessage: 'Standard languages',
},
searchResults: {
id: 'settings.language.categories.search-result',
defaultMessage: 'Search results',
},
})
type Category = 'default' | 'searchResult'
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[] = []
for (const loc of props.locales) {
const tag = loc.code
const 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 isChangingLocale = () => $changingTo.value != null || props.isChanging
const $activeLocale = computed(() => {
if ($changingTo.value != null) return $changingTo.value
return props.currentLocale
})
async function changeLocale(value: string) {
if ($activeLocale.value === value) return
$changingTo.value = value
try {
await props.onLocaleChange(value)
} finally {
$changingTo.value = undefined
}
}
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) || isChangingLocale()) return
changeLocale(loc.tag)
}
function onItemClick(e: MouseEvent, loc: LocaleInfo) {
if (isModifierKeyDown(e) || isChangingLocale()) return
changeLocale(loc.tag)
}
function getItemLabel(loc: LocaleInfo) {
return `${loc.nativeName}. ${loc.displayName}`
}
function getCategoryName(category: Category): string {
if (category === 'searchResult') {
return formatMessage(messages.searchResults)
}
return formatMessage(messages.standardLanguages)
}
</script>
<template>
<div class="flex flex-col gap-4">
<div v-if="$locales.length > 1" class="iconified-input w-full -mb-4">
<SearchIcon />
<input
id="language-search"
v-model="$query"
name="language"
type="search"
:placeholder="formatMessage(messages.searchFieldPlaceholder)"
class="input-text-inherit"
:disabled="isChangingLocale()"
@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="flex flex-col gap-2.5">
<template v-for="[category, categoryLocales] in $displayCategories" :key="category">
<strong class="mt-4 font-bold">
{{ getCategoryName(category) }}
</strong>
<div
v-if="category === 'searchResult' && categoryLocales.length === 0"
class="p-4 text-secondary"
tabindex="0"
>
{{ formatMessage(messages.noResults) }}
</div>
<template v-for="loc in categoryLocales" :key="loc.tag">
<div
role="button"
:aria-pressed="$activeLocale === loc.tag"
:class="[
'flex items-center gap-2 border-2 rounded-lg bg-surface-4 p-4 py-2 cursor-pointer relative overflow-hidden border-transparent transition-colors duration-100',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand hover:border-surface-5 border-solid',
isChangingLocale() && $changingTo !== loc.tag
? 'opacity-80 pointer-events-none cursor-default'
: '',
]"
:aria-disabled="isChangingLocale() && $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="size-6" />
<RadioButtonIcon v-else class="size-6" />
<div class="flex flex-1 flex-wrap justify-between">
<div class="font-bold">
{{ loc.displayName }}
</div>
<div>
{{ loc.nativeName }}
</div>
</div>
</div>
</template>
</template>
</div>
</div>
</template>

View File

@@ -1 +1,2 @@
export { default as LanguageSelector } from './LanguageSelector.vue'
export { default as ThemeSelector } from './ThemeSelector.vue'

View File

@@ -1037,9 +1037,36 @@
"settings.display.theme.title": {
"defaultMessage": "Color theme"
},
"settings.language.categories.default": {
"defaultMessage": "Standard languages"
},
"settings.language.categories.search-result": {
"defaultMessage": "Search results"
},
"settings.language.description": {
"defaultMessage": "Choose your preferred language for the {platform}. Translations are contributed by volunteers <crowdin-link>on Crowdin</crowdin-link>."
},
"settings.language.languages.search-field.placeholder": {
"defaultMessage": "Search for a language..."
},
"settings.language.languages.search-results-announcement": {
"defaultMessage": "{matches, plural, =0 {No languages match} one {# language matches} other {# languages match}} your search."
},
"settings.language.languages.search.no-results": {
"defaultMessage": "No languages match your search."
},
"settings.language.platform.app": {
"defaultMessage": "app"
},
"settings.language.platform.site": {
"defaultMessage": "site"
},
"settings.language.title": {
"defaultMessage": "Language"
},
"settings.language.warning": {
"defaultMessage": "Changing the {platform} language may cause some content to appear in English if a translation is not available. The {platform} is not yet fully translated, so some content may remain in English for certain languages."
},
"settings.pats.title": {
"defaultMessage": "Personal access tokens"
},

View File

@@ -611,6 +611,48 @@ export const commonProjectSettingsMessages = defineMessages({
},
})
export const languageSelectorMessages = defineMessages({
platformApp: {
id: 'settings.language.platform.app',
defaultMessage: 'app',
},
platformSite: {
id: 'settings.language.platform.site',
defaultMessage: 'site',
},
languagesDescription: {
id: 'settings.language.description',
defaultMessage:
'Choose your preferred language for the {platform}. Translations are contributed by volunteers <crowdin-link>on Crowdin</crowdin-link>.',
},
languageWarning: {
id: 'settings.language.warning',
defaultMessage:
'Changing the {platform} language may cause some content to appear in English if a translation is not available. The {platform} is not yet fully translated, so some content may remain in English for certain languages.',
},
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.',
},
standardLanguages: {
id: 'settings.language.categories.default',
defaultMessage: 'Standard languages',
},
searchResults: {
id: 'settings.language.categories.search-result',
defaultMessage: 'Search results',
},
})
export const paymentMethodMessages = defineMessages({
amazon_pay: {
id: 'omorphia.component.purchase_modal.payment_method_type.amazon_pay',

View File

@@ -0,0 +1,8 @@
/**
* Checks if any modifier key (Ctrl, Alt, Meta, or Shift) is held down during an event.
*/
export function isModifierKeyDown(
e: Pick<KeyboardEvent, 'ctrlKey' | 'altKey' | 'metaKey' | 'shiftKey'>,
): boolean {
return e.ctrlKey || e.altKey || e.metaKey || e.shiftKey
}

View File

@@ -1,5 +1,6 @@
export * from './auto-icons'
export * from './common-messages'
export * from './events'
export * from './game-modes'
export * from './notices'
export * from './savable'