mirror of
https://github.com/modrinth/code.git
synced 2026-09-03 13:36:48 +00:00
feat: preferences syncing frontend (#7192)
* feat: prefs frontend * fix: DI issue * fix: lint * feat: appearance settings cleanup + fix settings, remove pinia * fix: sync btn when logged out * fix: prepr * fix: sidebar issue * feat: language coverage + cleanup * feat: cleanup lang settings * fix: fmt * fix: CI * fix: ci * fix: storybook * feat: bring back loader/game version sort --------- Co-authored-by: tdgao <mr.trumgao@gmail.com>
This commit is contained in:
@@ -1,5 +1,3 @@
|
||||
export { default as AccountProfileSettings } from './AccountProfileSettings.vue'
|
||||
export { default as AccountSocialSettings } from './AccountSocialSettings.vue'
|
||||
export { default as ServersManageAccessPage } from './hosting/manage/[id]/access/access.vue'
|
||||
export { default as ServerOnboardingPanelPage } from './hosting/manage/[id]/onboarding.vue'
|
||||
export { default as ServersManageBackupsPage } from './hosting/manage/backups.vue'
|
||||
@@ -8,3 +6,4 @@ export { default as ServersManageFilesPage } from './hosting/manage/files.vue'
|
||||
export { default as ServersManagePageIndex } from './hosting/manage/index.vue'
|
||||
export { default as ServersManageOverviewPage } from './hosting/manage/overview.vue'
|
||||
export { default as ServersManageRootLayout } from './hosting/manage/root.vue'
|
||||
export * from './settings'
|
||||
|
||||
+158
-30
@@ -29,13 +29,14 @@
|
||||
{{ formatMessage(messages.friendRequestsTitle) }}
|
||||
</h2>
|
||||
<Chips
|
||||
v-model="friendRequestSource"
|
||||
:items="friendRequestSourceOptions"
|
||||
:model-value="friendPrivacy"
|
||||
:items="friendPrivacyOptions"
|
||||
:format-label="formatInteractionSource"
|
||||
:disabled-items="friendRequestSourceOptions"
|
||||
:disabled-tooltip="formatMessage(messages.comingSoon)"
|
||||
:disabled-items="preferenceControlsDisabled ? friendPrivacyOptions : undefined"
|
||||
:disabled-tooltip="preferenceControlsTooltip"
|
||||
:capitalize="false"
|
||||
:aria-label="formatMessage(messages.friendRequestsTitle)"
|
||||
@update:model-value="setFriendPrivacy"
|
||||
/>
|
||||
<p class="m-0 text-secondary">
|
||||
{{ formatMessage(messages.friendRequestsDescription) }}
|
||||
@@ -47,18 +48,38 @@
|
||||
{{ formatMessage(messages.sharedInstanceInvitesTitle) }}
|
||||
</h2>
|
||||
<Chips
|
||||
v-model="sharedInstanceInviteSource"
|
||||
:items="sharedInstanceInviteSourceOptions"
|
||||
:model-value="sharedInstancesPrivacy"
|
||||
:items="invitePrivacyOptions"
|
||||
:format-label="formatInteractionSource"
|
||||
:disabled-items="sharedInstanceInviteSourceOptions"
|
||||
:disabled-tooltip="formatMessage(messages.comingSoon)"
|
||||
:disabled-items="preferenceControlsDisabled ? invitePrivacyOptions : undefined"
|
||||
:disabled-tooltip="preferenceControlsTooltip"
|
||||
:capitalize="false"
|
||||
:aria-label="formatMessage(messages.sharedInstanceInvitesTitle)"
|
||||
@update:model-value="setSharedInstancesPrivacy"
|
||||
/>
|
||||
<p class="m-0 text-secondary">
|
||||
{{ formatMessage(messages.sharedInstanceInvitesDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.hostingAccessTitle) }}
|
||||
</h2>
|
||||
<Chips
|
||||
:model-value="hostingAccessPrivacy"
|
||||
:items="invitePrivacyOptions"
|
||||
:format-label="formatInteractionSource"
|
||||
:disabled-items="preferenceControlsDisabled ? invitePrivacyOptions : undefined"
|
||||
:disabled-tooltip="preferenceControlsTooltip"
|
||||
:capitalize="false"
|
||||
:aria-label="formatMessage(messages.hostingAccessTitle)"
|
||||
@update:model-value="setHostingAccessPrivacy"
|
||||
/>
|
||||
<p class="m-0 text-secondary">
|
||||
{{ formatMessage(messages.hostingAccessDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-4">
|
||||
@@ -187,7 +208,7 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { LogInIcon, SpinnerIcon, ThinkingRinthbot } from '@modrinth/assets'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import { Button } from '#ui/components/base/buttons'
|
||||
@@ -195,15 +216,20 @@ import Chips from '#ui/components/base/Chips.vue'
|
||||
import EmptyState from '#ui/components/base/EmptyState.vue'
|
||||
import Table, { type TableColumn } from '#ui/components/base/Table.vue'
|
||||
import { defineMessages, useScrollIndicator, useVIntl } from '#ui/composables'
|
||||
import { injectAuth, injectNotificationManager } from '#ui/providers'
|
||||
import { injectAuth, injectNotificationManager, injectUserPreferences } from '#ui/providers'
|
||||
import { commonMessages } from '#ui/utils'
|
||||
|
||||
import { blockedUsersQueryKey } from '../shared/user-profile/providers'
|
||||
import { blockedUsersQueryKey } from '../../shared/user-profile/providers'
|
||||
|
||||
type BlockedUserTableColumn = 'user' | 'actions'
|
||||
type BlockedUser = Labrinth.Users.v2.User & Record<BlockedUserTableColumn, unknown>
|
||||
type FriendRequestSource = 'everyone' | 'mutuals' | 'no-one'
|
||||
type SharedInstanceInviteSource = 'everyone' | 'friends' | 'no-one'
|
||||
type FriendPrivacy = Labrinth.Users.v3.FriendPrivacy
|
||||
type InvitePrivacy = Labrinth.Users.v3.InvitePrivacy
|
||||
type SocialSettingsState = {
|
||||
friendPrivacy: FriendPrivacy
|
||||
sharedInstancesPrivacy: InvitePrivacy
|
||||
hostingAccessPrivacy: InvitePrivacy
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
getBlockedUsers: () => Promise<Labrinth.BlockedUsers.v3.BlockedUserId[]>
|
||||
@@ -213,33 +239,120 @@ const props = defineProps<{
|
||||
|
||||
const auth = injectAuth()
|
||||
const notificationManager = injectNotificationManager()
|
||||
const {
|
||||
preferences,
|
||||
isLoading: preferencesLoading,
|
||||
isUpdating: preferencesUpdating,
|
||||
updatePreferences,
|
||||
} = injectUserPreferences()
|
||||
const queryClient = useQueryClient()
|
||||
const { formatMessage } = useVIntl()
|
||||
const blockedUsersTable = ref<HTMLElement | null>(null)
|
||||
const unblockingUserId = ref<string | null>(null)
|
||||
const friendRequestSource = ref<FriendRequestSource>('everyone')
|
||||
const sharedInstanceInviteSource = ref<SharedInstanceInviteSource>('everyone')
|
||||
const friendRequestSourceOptions: FriendRequestSource[] = ['everyone', 'mutuals', 'no-one']
|
||||
const sharedInstanceInviteSourceOptions: SharedInstanceInviteSource[] = [
|
||||
'everyone',
|
||||
'friends',
|
||||
'no-one',
|
||||
]
|
||||
const saving = ref(false)
|
||||
const preferencesInitialized = ref(false)
|
||||
const friendPrivacy = ref<FriendPrivacy>('everyone')
|
||||
const sharedInstancesPrivacy = ref<InvitePrivacy>('everyone')
|
||||
const hostingAccessPrivacy = ref<InvitePrivacy>('everyone')
|
||||
const friendPrivacyOptions: FriendPrivacy[] = ['everyone', 'mutual', 'none']
|
||||
const invitePrivacyOptions: InvitePrivacy[] = ['everyone', 'friends', 'none']
|
||||
const preferenceControlsDisabled = computed(
|
||||
() => preferencesLoading.value || preferencesUpdating.value || saving.value || !preferences.value,
|
||||
)
|
||||
const preferenceControlsTooltip = computed(() => {
|
||||
if (preferencesLoading.value) return formatMessage(messages.loadingPreferences)
|
||||
if (saving.value || preferencesUpdating.value) return formatMessage(messages.savingPreferences)
|
||||
return formatMessage(messages.preferencesUnavailable)
|
||||
})
|
||||
const { showTopFade, showBottomFade, checkScrollState } = useScrollIndicator(blockedUsersTable)
|
||||
|
||||
function formatInteractionSource(source: FriendRequestSource | SharedInstanceInviteSource): string {
|
||||
const originalState = computed<SocialSettingsState>(() => ({
|
||||
friendPrivacy: preferences.value?.social.friend_privacy ?? 'everyone',
|
||||
sharedInstancesPrivacy: preferences.value?.social.shared_instances_privacy ?? 'everyone',
|
||||
hostingAccessPrivacy: preferences.value?.social.hosting_access_privacy ?? 'everyone',
|
||||
}))
|
||||
const modifiedState = computed<Partial<SocialSettingsState>>(() => ({
|
||||
...(friendPrivacy.value !== originalState.value.friendPrivacy
|
||||
? { friendPrivacy: friendPrivacy.value }
|
||||
: {}),
|
||||
...(sharedInstancesPrivacy.value !== originalState.value.sharedInstancesPrivacy
|
||||
? { sharedInstancesPrivacy: sharedInstancesPrivacy.value }
|
||||
: {}),
|
||||
...(hostingAccessPrivacy.value !== originalState.value.hostingAccessPrivacy
|
||||
? { hostingAccessPrivacy: hostingAccessPrivacy.value }
|
||||
: {}),
|
||||
}))
|
||||
const hasChanges = computed(() => Object.keys(modifiedState.value).length > 0)
|
||||
|
||||
function formatInteractionSource(source: FriendPrivacy | InvitePrivacy): string {
|
||||
switch (source) {
|
||||
case 'everyone':
|
||||
return formatMessage(messages.everyone)
|
||||
case 'mutuals':
|
||||
case 'mutual':
|
||||
return formatMessage(messages.friendsOfFriends)
|
||||
case 'friends':
|
||||
return formatMessage(messages.friends)
|
||||
case 'no-one':
|
||||
case 'none':
|
||||
return formatMessage(messages.noOne)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
preferences,
|
||||
(value) => {
|
||||
if (!value) return
|
||||
if (preferencesInitialized.value && hasChanges.value && !saving.value) return
|
||||
|
||||
friendPrivacy.value = value.social.friend_privacy
|
||||
sharedInstancesPrivacy.value = value.social.shared_instances_privacy
|
||||
hostingAccessPrivacy.value = value.social.hosting_access_privacy
|
||||
preferencesInitialized.value = true
|
||||
},
|
||||
{ immediate: true, flush: 'sync' },
|
||||
)
|
||||
|
||||
function setFriendPrivacy(value: FriendPrivacy | null): void {
|
||||
if (!value) return
|
||||
friendPrivacy.value = value
|
||||
}
|
||||
|
||||
function setSharedInstancesPrivacy(value: InvitePrivacy | null): void {
|
||||
if (!value) return
|
||||
sharedInstancesPrivacy.value = value
|
||||
}
|
||||
|
||||
function setHostingAccessPrivacy(value: InvitePrivacy | null): void {
|
||||
if (!value) return
|
||||
hostingAccessPrivacy.value = value
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
friendPrivacy.value = originalState.value.friendPrivacy
|
||||
sharedInstancesPrivacy.value = originalState.value.sharedInstancesPrivacy
|
||||
hostingAccessPrivacy.value = originalState.value.hostingAccessPrivacy
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!hasChanges.value || saving.value) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await updatePreferences({
|
||||
social: {
|
||||
friend_privacy: friendPrivacy.value,
|
||||
shared_instances_privacy: sharedInstancesPrivacy.value,
|
||||
hosting_access_privacy: hostingAccessPrivacy.value,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ hasChanges, originalState, modifiedState, saving, reset, save })
|
||||
|
||||
const columns = computed<TableColumn<BlockedUserTableColumn>[]>(() => [
|
||||
{
|
||||
key: 'user',
|
||||
@@ -342,12 +455,19 @@ const messages = defineMessages({
|
||||
},
|
||||
sharedInstanceInvitesTitle: {
|
||||
id: 'settings.social.shared-instance-invites.title',
|
||||
defaultMessage: 'Invitations',
|
||||
defaultMessage: 'Shared instance invites',
|
||||
},
|
||||
sharedInstanceInvitesDescription: {
|
||||
id: 'settings.social.shared-instance-invites.description',
|
||||
defaultMessage:
|
||||
'Control who can send you invites to shared instances and Modrinth Hosting panels.',
|
||||
defaultMessage: 'Control who can send you invites to shared instances.',
|
||||
},
|
||||
hostingAccessTitle: {
|
||||
id: 'settings.social.hosting-access.title',
|
||||
defaultMessage: 'Hosting access invites',
|
||||
},
|
||||
hostingAccessDescription: {
|
||||
id: 'settings.social.hosting-access.description',
|
||||
defaultMessage: 'Control who can invite you to manage a Modrinth Hosting server.',
|
||||
},
|
||||
everyone: {
|
||||
id: 'settings.social.interaction-source.everyone',
|
||||
@@ -365,9 +485,17 @@ const messages = defineMessages({
|
||||
id: 'settings.social.interaction-source.no-one',
|
||||
defaultMessage: 'No one',
|
||||
},
|
||||
comingSoon: {
|
||||
id: 'settings.social.interaction-source.coming-soon',
|
||||
defaultMessage: 'Coming soon!',
|
||||
savingPreferences: {
|
||||
id: 'settings.social.interaction-source.saving',
|
||||
defaultMessage: 'Saving your preferences…',
|
||||
},
|
||||
loadingPreferences: {
|
||||
id: 'settings.social.interaction-source.loading',
|
||||
defaultMessage: 'Loading your preferences…',
|
||||
},
|
||||
preferencesUnavailable: {
|
||||
id: 'settings.social.interaction-source.unavailable',
|
||||
defaultMessage: 'Your preferences are currently unavailable.',
|
||||
},
|
||||
blockedUsersTitle: {
|
||||
id: 'settings.social.blocked-users.title',
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as AccountProfileSettings } from './AccountProfileSettings.vue'
|
||||
export { default as AccountSocialSettings } from './AccountSocialSettings.vue'
|
||||
export { default as LanguageSettings } from './language-settings/index.vue'
|
||||
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="m-0 text-xl font-semibold text-contrast">
|
||||
{{ formatMessage(commonSettingsMessages.language) }}
|
||||
</h2>
|
||||
|
||||
<Admonition type="warning" class="mb-4 mt-2">
|
||||
{{ formatMessage(languageSelectorMessages.languageWarning, { platform }) }}
|
||||
</Admonition>
|
||||
|
||||
<p class="m-0 mb-4 text-secondary">
|
||||
<IntlFormatted
|
||||
:message-id="languageSelectorMessages.languagesDescription"
|
||||
:values="{ platform }"
|
||||
>
|
||||
<template #~crowdin-link="{ children }">
|
||||
<AutoLink to="https://translate.modrinth.com" class="text-link">
|
||||
<component :is="() => children" />
|
||||
</AutoLink>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
|
||||
<LanguageSettingsSelector
|
||||
:product="product"
|
||||
:current-locale="current.locale"
|
||||
:locales="LOCALES"
|
||||
:on-locale-change="onLocaleChange"
|
||||
:is-changing="saving"
|
||||
:coverage-by-locale="languageCoverage[product]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import { Admonition, AutoLink, IntlFormatted } from '#ui/components/base'
|
||||
import { LOCALES, useVIntl } from '#ui/composables'
|
||||
import { injectI18n, injectUserPreferences } from '#ui/providers'
|
||||
import { commonSettingsMessages, languageSelectorMessages, useSavable } from '#ui/utils'
|
||||
|
||||
import { languageCoverage } from './language-settings-coverage.generated'
|
||||
import LanguageSettingsSelector from './language-settings-selector.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
product: 'app' | 'website'
|
||||
persistLocale?: (locale: string) => void | Promise<void>
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { locale, setLocale } = injectI18n()
|
||||
const { preferences, updatePreferences } = injectUserPreferences()
|
||||
const platform = computed(() =>
|
||||
formatMessage(
|
||||
props.product === 'app'
|
||||
? languageSelectorMessages.platformApp
|
||||
: languageSelectorMessages.platformSite,
|
||||
),
|
||||
)
|
||||
const persistedLocale = ref(locale.value)
|
||||
let localeChangeQueue = Promise.resolve()
|
||||
|
||||
const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
|
||||
() => ({ locale: persistedLocale.value }),
|
||||
async () => {
|
||||
await updatePreferences({ localization: { locale: current.value.locale } })
|
||||
await queueLocaleChange(current.value.locale, true)
|
||||
await props.persistLocale?.(current.value.locale)
|
||||
persistedLocale.value = current.value.locale
|
||||
},
|
||||
)
|
||||
|
||||
function queueLocaleChange(newLocale: string, persist = false): Promise<void> {
|
||||
const request = localeChangeQueue.then(() => Promise.resolve(setLocale(newLocale, { persist })))
|
||||
localeChangeQueue = request.catch(() => undefined)
|
||||
return request
|
||||
}
|
||||
|
||||
function onLocaleChange(newLocale: string): void {
|
||||
current.value.locale = newLocale
|
||||
void queueLocaleChange(newLocale).catch(() => undefined)
|
||||
}
|
||||
|
||||
function resetLanguageSettings(): void {
|
||||
reset()
|
||||
void queueLocaleChange(current.value.locale).catch(() => undefined)
|
||||
}
|
||||
|
||||
async function saveLanguageSettings(): Promise<void> {
|
||||
try {
|
||||
await save()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
preferences,
|
||||
(value) => {
|
||||
if (!value || hasChanges.value) return
|
||||
persistedLocale.value = value.localization.locale
|
||||
},
|
||||
{ immediate: true, flush: 'sync' },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (hasChanges.value || locale.value !== persistedLocale.value) {
|
||||
void queueLocaleChange(persistedLocale.value).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
originalState: saved,
|
||||
modifiedState: changes,
|
||||
hasChanges,
|
||||
saving,
|
||||
reset: resetLanguageSettings,
|
||||
save: saveLanguageSettings,
|
||||
})
|
||||
</script>
|
||||
packages/ui/src/layouts/wrapped/settings/language-settings/language-settings-coverage.generated.d.ts
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
import type { LanguageCoverageByProduct } from './language-settings-coverage'
|
||||
|
||||
export declare const languageCoverage: LanguageCoverageByProduct
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
export type LanguageProduct = 'app' | 'website'
|
||||
|
||||
export interface LanguageCoverageStats {
|
||||
percentage: number
|
||||
interfaceCoverage: number
|
||||
translationCoverage: number
|
||||
translatedMessages: number
|
||||
totalMessages: number
|
||||
unlocalizedStrings: number
|
||||
}
|
||||
|
||||
export type LanguageCoverageByProduct = Record<
|
||||
LanguageProduct,
|
||||
Record<string, LanguageCoverageStats>
|
||||
>
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
<script setup lang="ts">
|
||||
import { SearchIcon } from '@modrinth/assets'
|
||||
import Fuse from 'fuse.js/dist/fuse.basic'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import Button from '#ui/components/base/buttons/Button.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import {
|
||||
buildLocaleMessages,
|
||||
defineMessages,
|
||||
type LocaleDefinition,
|
||||
useVIntl,
|
||||
} from '#ui/composables/i18n'
|
||||
import { metaLocaleModules } from '#ui/locales.ts'
|
||||
import { isModifierKeyDown } from '#ui/utils/events'
|
||||
|
||||
import type { LanguageCoverageStats } from './language-settings-coverage'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps<{
|
||||
product: 'app' | 'website'
|
||||
currentLocale: string
|
||||
locales: LocaleDefinition[]
|
||||
onLocaleChange: (locale: string) => void | Promise<void>
|
||||
isChanging?: boolean
|
||||
coverageByLocale?: Record<string, LanguageCoverageStats>
|
||||
}>()
|
||||
|
||||
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',
|
||||
},
|
||||
coverageLabel: {
|
||||
id: 'settings.language.coverage.label',
|
||||
defaultMessage: '{percentage}% supported',
|
||||
},
|
||||
appCoverageTooltip: {
|
||||
id: 'settings.language.coverage.app-tooltip',
|
||||
defaultMessage: 'About {percentage}% of the Modrinth App is available in this language.',
|
||||
},
|
||||
websiteCoverageTooltip: {
|
||||
id: 'settings.language.coverage.website-tooltip',
|
||||
defaultMessage: 'About {percentage}% of the website is available in this language.',
|
||||
},
|
||||
})
|
||||
|
||||
const localeMetas = buildLocaleMessages(metaLocaleModules)
|
||||
|
||||
type Category = 'default' | 'searchResult'
|
||||
|
||||
type LocaleInfo = {
|
||||
category: Category
|
||||
tag: string
|
||||
displayName: string
|
||||
browserDisplayName: string
|
||||
flagUrl?: string
|
||||
searchTerms?: string[]
|
||||
coverage?: LanguageCoverageStats
|
||||
}
|
||||
|
||||
const localeFlagRegions: Record<string, string> = {
|
||||
'es-419': 'mx',
|
||||
'sr-CS': 'rs',
|
||||
}
|
||||
|
||||
const $browserLocales = ref([props.currentLocale])
|
||||
|
||||
onMounted(() => {
|
||||
$browserLocales.value = navigator.languages.length
|
||||
? [...navigator.languages]
|
||||
: [navigator.language]
|
||||
})
|
||||
|
||||
const $browserDisplayNames = computed(() => {
|
||||
try {
|
||||
return new Intl.DisplayNames($browserLocales.value, { type: 'language' })
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
|
||||
function getBrowserDisplayName(tag: string, fallback: string): string {
|
||||
try {
|
||||
return $browserDisplayNames.value?.of(tag) ?? fallback
|
||||
} catch {
|
||||
try {
|
||||
return $browserDisplayNames.value?.of(tag.split('-')[0]) ?? fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getFlagUrl(tag: string): string | undefined {
|
||||
const region = localeFlagRegions[tag] ?? tag.split('-').at(-1)
|
||||
if (!region || !/^[a-z]{2}$/i.test(region)) return undefined
|
||||
|
||||
return `https://flagcdn.com/${region.toLowerCase()}.svg`
|
||||
}
|
||||
|
||||
const $locales = computed(() => {
|
||||
const result: LocaleInfo[] = []
|
||||
|
||||
for (const loc of props.locales) {
|
||||
const tag = loc.code
|
||||
const meta = localeMetas[tag] ?? null
|
||||
const displayName = meta?.displayName ?? loc.name
|
||||
const translatedName = formatMessage(loc.translatedName)
|
||||
const browserDisplayName = getBrowserDisplayName(tag, translatedName)
|
||||
const searchTerms = meta?.searchTerms === '-' ? undefined : meta?.searchTerms?.split('\n')
|
||||
|
||||
result.push({
|
||||
tag,
|
||||
category: 'default',
|
||||
displayName,
|
||||
browserDisplayName,
|
||||
flagUrl: getFlagUrl(tag),
|
||||
searchTerms,
|
||||
coverage: props.coverageByLocale?.[tag],
|
||||
})
|
||||
}
|
||||
|
||||
return result.sort((a, b) => (b.coverage?.percentage ?? -1) - (a.coverage?.percentage ?? -1))
|
||||
})
|
||||
|
||||
const $query = ref('')
|
||||
|
||||
const isQueryEmpty = () => $query.value.trim().length === 0
|
||||
|
||||
const fuse = computed(
|
||||
() =>
|
||||
new Fuse<LocaleInfo>($locales.value, {
|
||||
keys: ['tag', 'displayName', 'browserDisplayName', 'searchTerms'],
|
||||
threshold: 0.4,
|
||||
distance: 100,
|
||||
}),
|
||||
)
|
||||
|
||||
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.value.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
|
||||
})
|
||||
|
||||
function changeLocale(value: string) {
|
||||
if ($activeLocale.value === value) return
|
||||
|
||||
const result = props.onLocaleChange(value)
|
||||
if (!result) return
|
||||
|
||||
$changingTo.value = value
|
||||
void result.then(
|
||||
() => {
|
||||
$changingTo.value = undefined
|
||||
},
|
||||
() => {
|
||||
$changingTo.value = undefined
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const $languagesList = ref<HTMLDivElement | undefined>()
|
||||
|
||||
function onSearchKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== 'Enter' || isModifierKeyDown(e)) return
|
||||
|
||||
const focusableTarget = $languagesList.value?.querySelector(
|
||||
'button:not(:disabled), [tabindex]:not([tabindex="-1"])',
|
||||
) as HTMLElement | undefined
|
||||
|
||||
focusableTarget?.focus()
|
||||
}
|
||||
|
||||
function onItemClick(e: MouseEvent, loc: LocaleInfo) {
|
||||
if (isModifierKeyDown(e) || isChangingLocale()) return
|
||||
|
||||
changeLocale(loc.tag)
|
||||
}
|
||||
|
||||
function showBrowserDisplayName(loc: LocaleInfo): boolean {
|
||||
return (
|
||||
loc.browserDisplayName.localeCompare(loc.displayName, undefined, { sensitivity: 'base' }) !== 0
|
||||
)
|
||||
}
|
||||
|
||||
function getItemLabel(loc: LocaleInfo) {
|
||||
const coverageLabel = loc.coverage
|
||||
? `. ${formatMessage(messages.coverageLabel, { percentage: loc.coverage.percentage })}`
|
||||
: ''
|
||||
const browserDisplayName = showBrowserDisplayName(loc) ? `. ${loc.browserDisplayName}` : ''
|
||||
return `${loc.displayName}${browserDisplayName}${coverageLabel}`
|
||||
}
|
||||
|
||||
function getCoverageTooltip(coverage: LanguageCoverageStats): string {
|
||||
const message =
|
||||
props.product === 'app' ? messages.appCoverageTooltip : messages.websiteCoverageTooltip
|
||||
|
||||
return formatMessage(message, {
|
||||
percentage: coverage.percentage,
|
||||
})
|
||||
}
|
||||
|
||||
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="-mb-4">
|
||||
<StyledInput
|
||||
id="language-search"
|
||||
v-model="$query"
|
||||
:icon="SearchIcon"
|
||||
name="language"
|
||||
type="search"
|
||||
:placeholder="formatMessage(messages.searchFieldPlaceholder)"
|
||||
:disabled="isChangingLocale()"
|
||||
wrapper-class="w-full"
|
||||
@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-semibold text-contrast">
|
||||
{{ 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">
|
||||
<Button
|
||||
:type="$activeLocale === loc.tag ? 'colored' : 'base'"
|
||||
:color="$activeLocale === loc.tag ? 'green' : undefined"
|
||||
:aria-pressed="$activeLocale === loc.tag"
|
||||
:disabled="isChangingLocale() && $changingTo !== loc.tag"
|
||||
:aria-label="getItemLabel(loc)"
|
||||
class="w-full !justify-start !gap-2 !text-left sm:!h-10"
|
||||
:class="
|
||||
$activeLocale === loc.tag
|
||||
? '!bg-[var(--color-button-bg-selected)] !text-[var(--color-button-text-selected)]'
|
||||
: ''
|
||||
"
|
||||
@click="(e) => onItemClick(e, loc)"
|
||||
>
|
||||
<img
|
||||
v-if="loc.flagUrl"
|
||||
:src="loc.flagUrl"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-6 shrink-0 rounded-sm object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<span class="flex min-w-0 flex-1 items-baseline gap-2 overflow-hidden">
|
||||
<span class="truncate text-sm sm:text-base">{{ loc.displayName }}</span>
|
||||
<span
|
||||
v-if="showBrowserDisplayName(loc)"
|
||||
class="truncate text-xs font-normal text-secondary sm:text-sm"
|
||||
>
|
||||
{{ loc.browserDisplayName }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="loc.coverage"
|
||||
v-tooltip="getCoverageTooltip(loc.coverage)"
|
||||
class="ml-auto shrink-0 text-xs font-normal text-secondary sm:text-sm"
|
||||
>
|
||||
{{ loc.coverage.percentage }}%
|
||||
</span>
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user