You've already forked AstralRinth
forked from xxxOFFxxx/AstralRinth
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:
@@ -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: 'You’re 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
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
"app.settings.tabs.java-installations": {
|
||||
"message": "Java installations"
|
||||
},
|
||||
"app.settings.tabs.language": {
|
||||
"message": "Language"
|
||||
},
|
||||
"app.settings.tabs.privacy": {
|
||||
"message": "Privacy"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
12
packages/app-lib/.sqlx/query-175067f04e775f5469146f3cb77c422c3ab7203409083fd3c9c968b00b46918f.json
generated
Normal file
12
packages/app-lib/.sqlx/query-175067f04e775f5469146f3cb77c422c3ab7203409083fd3c9c968b00b46918f.json
generated
Normal 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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add migration script here
|
||||
ALTER TABLE settings ADD COLUMN locale TEXT NOT NULL DEFAULT 'en-US';
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
243
packages/ui/src/components/settings/LanguageSelector.vue
Normal file
243
packages/ui/src/components/settings/LanguageSelector.vue
Normal 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>
|
||||
@@ -1 +1,2 @@
|
||||
export { default as LanguageSelector } from './LanguageSelector.vue'
|
||||
export { default as ThemeSelector } from './ThemeSelector.vue'
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
8
packages/ui/src/utils/events.ts
Normal file
8
packages/ui/src/utils/events.ts
Normal 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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user