You've already forked AstralRinth
forked from didirus/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:
@@ -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