You've already forked AstralRinth
forked from didirus/AstralRinth
* New translations from Crowdin (main) * feat: warning + slap beta tag * fix: intl * fix: intl --------- Co-authored-by: IMB11 <contact@cal.engineer>
538 lines
13 KiB
Vue
538 lines
13 KiB
Vue
<script setup lang="ts">
|
|
import { IssuesIcon, RadioButtonCheckedIcon, RadioButtonIcon } from '@modrinth/assets'
|
|
import { Admonition, commonSettingsMessages } from '@modrinth/ui'
|
|
import { IntlFormatted } from '@vintl/vintl/components'
|
|
import Fuse from 'fuse.js/dist/fuse.basic'
|
|
|
|
import { isModifierKeyDown } from '~/helpers/events.ts'
|
|
|
|
const vintl = useVIntl()
|
|
const { formatMessage } = vintl
|
|
|
|
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.',
|
|
},
|
|
searchFieldDescription: {
|
|
id: 'settings.language.languages.search-field.description',
|
|
defaultMessage: 'Submit to focus the first search result',
|
|
},
|
|
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.',
|
|
},
|
|
loadFailed: {
|
|
id: 'settings.language.languages.load-failed',
|
|
defaultMessage: 'Cannot load this language. Try again in a bit.',
|
|
},
|
|
languageLabelApplying: {
|
|
id: 'settings.language.languages.language-label-applying',
|
|
defaultMessage: '{label}. Applying...',
|
|
},
|
|
languageLabelError: {
|
|
id: 'settings.language.languages.language-label-error',
|
|
defaultMessage: '{label}. Error',
|
|
},
|
|
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 categoryNames = defineMessages({
|
|
auto: {
|
|
id: 'settings.language.categories.auto',
|
|
defaultMessage: 'Automatic',
|
|
},
|
|
default: {
|
|
id: 'settings.language.categories.default',
|
|
defaultMessage: 'Standard languages',
|
|
},
|
|
fun: {
|
|
id: 'settings.language.categories.fun',
|
|
defaultMessage: 'Fun languages',
|
|
},
|
|
experimental: {
|
|
id: 'settings.language.categories.experimental',
|
|
defaultMessage: 'Experimental languages',
|
|
},
|
|
searchResult: {
|
|
id: 'settings.language.categories.search-result',
|
|
defaultMessage: 'Search results',
|
|
},
|
|
})
|
|
|
|
type Category = keyof typeof categoryNames
|
|
|
|
const categoryOrder: Category[] = ['auto', 'default', 'fun', 'experimental']
|
|
|
|
function normalizeCategoryName(name?: string): keyof typeof categoryNames {
|
|
switch (name) {
|
|
case 'auto':
|
|
case 'fun':
|
|
case 'experimental':
|
|
return name
|
|
default:
|
|
return 'default'
|
|
}
|
|
}
|
|
|
|
type LocaleBase = {
|
|
category: Category
|
|
tag: string
|
|
searchTerms?: string[]
|
|
}
|
|
|
|
type AutomaticLocale = LocaleBase & {
|
|
auto: true
|
|
}
|
|
|
|
type CommonLocale = LocaleBase & {
|
|
auto?: never
|
|
displayName: string
|
|
defaultName: string
|
|
translatedName: string
|
|
}
|
|
|
|
type Locale = AutomaticLocale | CommonLocale
|
|
|
|
const $defaultNames = useDisplayNames(() => vintl.defaultLocale)
|
|
|
|
const $translatedNames = useDisplayNames(() => vintl.locale)
|
|
|
|
const $locales = computed(() => {
|
|
const locales: Locale[] = []
|
|
|
|
locales.push({
|
|
auto: true,
|
|
tag: 'auto',
|
|
category: 'auto',
|
|
searchTerms: [
|
|
'automatic',
|
|
'Sync with the system language',
|
|
formatMessage(messages.automaticLocale),
|
|
],
|
|
})
|
|
|
|
for (const locale of vintl.availableLocales) {
|
|
let displayName = locale.meta?.displayName
|
|
|
|
if (displayName == null) {
|
|
displayName = createDisplayNames(locale.tag).of(locale.tag) ?? locale.tag
|
|
}
|
|
|
|
let defaultName = vintl.defaultResources['languages.json']?.[locale.tag]
|
|
|
|
if (defaultName == null) {
|
|
defaultName = $defaultNames.value.of(locale.tag) ?? locale.tag
|
|
}
|
|
|
|
let translatedName = vintl.resources['languages.json']?.[locale.tag]
|
|
|
|
if (translatedName == null) {
|
|
translatedName = $translatedNames.value.of(locale.tag) ?? locale.tag
|
|
}
|
|
|
|
let searchTerms = locale.meta?.searchTerms
|
|
if (searchTerms === '-') searchTerms = undefined
|
|
|
|
locales.push({
|
|
tag: locale.tag,
|
|
category: normalizeCategoryName(locale.meta?.category),
|
|
displayName,
|
|
defaultName,
|
|
translatedName,
|
|
searchTerms: searchTerms?.split('\n'),
|
|
})
|
|
}
|
|
|
|
return locales
|
|
})
|
|
|
|
const $query = ref('')
|
|
|
|
const isQueryEmpty = () => $query.value.trim().length === 0
|
|
|
|
const fuse = new Fuse<Locale>([], {
|
|
keys: ['tag', 'displayName', 'translatedName', 'englishName', 'searchTerms'],
|
|
threshold: 0.4,
|
|
distance: 100,
|
|
})
|
|
|
|
watchSyncEffect(() => fuse.setCollection($locales.value))
|
|
|
|
const $categories = computed(() => {
|
|
const categories = new Map<Category, Locale[]>()
|
|
|
|
for (const category of categoryOrder) categories.set(category, [])
|
|
|
|
for (const locale of $locales.value) {
|
|
let categoryLocales = categories.get(locale.category)
|
|
|
|
if (categoryLocales == null) {
|
|
categoryLocales = []
|
|
categories.set(locale.category, categoryLocales)
|
|
}
|
|
|
|
categoryLocales.push(locale)
|
|
}
|
|
|
|
for (const categoryKey of [...categories.keys()]) {
|
|
if (categories.get(categoryKey)?.length === 0) {
|
|
categories.delete(categoryKey)
|
|
}
|
|
}
|
|
|
|
return categories
|
|
})
|
|
|
|
const $searchResults = computed(() => {
|
|
return new Map<Category, Locale[]>([
|
|
['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 $failedLocale = ref<string>()
|
|
|
|
const $activeLocale = computed(() => {
|
|
if ($changingTo.value != null) return $changingTo.value
|
|
return vintl.automatic ? 'auto' : vintl.locale
|
|
})
|
|
|
|
async function changeLocale(value: string) {
|
|
if ($activeLocale.value === value) return
|
|
|
|
$changingTo.value = value
|
|
|
|
try {
|
|
await vintl.changeLocale(value)
|
|
$failedLocale.value = undefined
|
|
} catch {
|
|
$failedLocale.value = 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, locale: Locale) {
|
|
switch (e.key) {
|
|
case 'Enter':
|
|
case ' ':
|
|
break
|
|
default:
|
|
return
|
|
}
|
|
|
|
if (isModifierKeyDown(e) || isChanging()) return
|
|
|
|
changeLocale(locale.tag)
|
|
}
|
|
|
|
function onItemClick(e: MouseEvent, locale: Locale) {
|
|
if (isModifierKeyDown(e) || isChanging()) return
|
|
|
|
changeLocale(locale.tag)
|
|
}
|
|
|
|
function getItemLabel(locale: Locale) {
|
|
const label = locale.auto
|
|
? formatMessage(messages.automaticLocale)
|
|
: `${locale.translatedName}. ${locale.displayName}`
|
|
|
|
if ($changingTo.value === locale.tag) {
|
|
return formatMessage(messages.languageLabelApplying, { label })
|
|
}
|
|
|
|
if ($failedLocale.value === locale.tag) {
|
|
return formatMessage(messages.languageLabelError, { label })
|
|
}
|
|
|
|
return label
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<section class="universal-card">
|
|
<h2 class="text-2xl">{{ formatMessage(commonSettingsMessages.language) }}</h2>
|
|
|
|
<Admonition type="warning">
|
|
{{ formatMessage(messages.languageWarning) }}
|
|
</Admonition>
|
|
|
|
<div class="card-description mt-4">
|
|
<IntlFormatted :message-id="messages.languagesDescription">
|
|
<template #crowdin-link="{ children }">
|
|
<a href="https://translate.modrinth.com">
|
|
<component :is="() => children" />
|
|
</a>
|
|
</template>
|
|
</IntlFormatted>
|
|
</div>
|
|
|
|
<div class="search-container">
|
|
<input
|
|
id="language-search"
|
|
v-model="$query"
|
|
name="language"
|
|
type="search"
|
|
:placeholder="formatMessage(messages.searchFieldPlaceholder)"
|
|
class="language-search"
|
|
aria-describedby="language-search-description"
|
|
:disabled="isChanging()"
|
|
@keydown="onSearchKeydown"
|
|
/>
|
|
|
|
<div id="language-search-description" class="visually-hidden">
|
|
{{ formatMessage(messages.searchFieldDescription) }}
|
|
</div>
|
|
|
|
<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, locales] in $displayCategories" :key="category">
|
|
<strong class="category-name">
|
|
{{ formatMessage(categoryNames[category]) }}
|
|
</strong>
|
|
|
|
<div
|
|
v-if="category === 'searchResult' && locales.length === 0"
|
|
class="no-results"
|
|
tabindex="0"
|
|
>
|
|
{{ formatMessage(messages.noResults) }}
|
|
</div>
|
|
|
|
<template v-for="locale in locales" :key="locale.tag">
|
|
<div
|
|
role="button"
|
|
:aria-pressed="$activeLocale === locale.tag"
|
|
:class="{
|
|
'language-item': true,
|
|
pending: $changingTo == locale.tag,
|
|
errored: $failedLocale == locale.tag,
|
|
}"
|
|
:aria-describedby="
|
|
$failedLocale == locale.tag ? `language__${locale.tag}__fail` : undefined
|
|
"
|
|
:aria-disabled="isChanging() && $changingTo !== locale.tag"
|
|
:tabindex="0"
|
|
:aria-label="getItemLabel(locale)"
|
|
@click="(e) => onItemClick(e, locale)"
|
|
@keydown="(e) => onItemKeydown(e, locale)"
|
|
>
|
|
<RadioButtonCheckedIcon v-if="$activeLocale === locale.tag" class="radio" />
|
|
<RadioButtonIcon v-else class="radio" />
|
|
|
|
<div class="language-names">
|
|
<div class="language-name">
|
|
{{ locale.auto ? formatMessage(messages.automaticLocale) : locale.displayName }}
|
|
</div>
|
|
|
|
<div v-if="!locale.auto" class="language-translated-name">
|
|
{{ locale.translatedName }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="$failedLocale === locale.tag"
|
|
:id="`language__${locale.tag}__fail`"
|
|
class="language-load-error"
|
|
>
|
|
<IssuesIcon />
|
|
{{ formatMessage(messages.loadFailed) }}
|
|
</div>
|
|
</template>
|
|
</template>
|
|
</div>
|
|
</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);
|
|
}
|
|
|
|
&.errored {
|
|
border-color: var(--color-red);
|
|
|
|
&:hover {
|
|
border-color: var(--color-red);
|
|
}
|
|
}
|
|
|
|
&.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;
|
|
}
|
|
}
|
|
|
|
.language-load-error {
|
|
color: var(--color-red);
|
|
font-size: var(--font-size-sm);
|
|
margin-left: 0.3rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
}
|
|
|
|
.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));
|
|
|
|
a {
|
|
color: var(--color-link);
|
|
|
|
&:hover {
|
|
color: var(--color-link-hover);
|
|
}
|
|
|
|
&:active {
|
|
color: var(--color-link-active);
|
|
}
|
|
}
|
|
}
|
|
|
|
.category-name {
|
|
margin-top: var(--spacing-card-md);
|
|
}
|
|
</style>
|