feat(quick-261016-0fm): language switcher in the user menu

- loadStrings sends the chosen language as ?locale= and keeps
  meta.locales as availableLocales
- the user menu lists the admin locales, each named in its own language;
  choosing one saves it through PUT /account/preferences, caches it and
  reloads the page
- boot loads the strings in the cached language; me() reloads them when
  the stored preference differs
This commit is contained in:
Jakub Zych
2026-10-16 11:35:08 +02:00
parent f67e8915b8
commit 5244a37b00
5 changed files with 145 additions and 15 deletions

View File

@@ -4,20 +4,27 @@ import { useRouter } from 'vue-router'
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuItemIndicator,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRoot,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'reka-ui'
import { ChevronDown, LogOut, UserRound } from '@lucide/vue'
import { t } from '../../app/i18n'
import { Check, ChevronDown, LogOut, UserRound } from '@lucide/vue'
import { availableLocales, currentLocale, t } from '../../app/i18n'
import { currentUser, logout, profileInitials } from '../../state/useAuth'
import { localePreference, setLocale } from '../../state/usePreferences'
// User menu (design: Top header): a 44px button with the avatar image (the
// initials when the admin has none), the name above the role from /auth/me
// (both hidden at tablet width) and a chevron; the 280px menu (Reka
// DropdownMenu, role menu) shows the name and e-mail, a My account item that
// opens /account and a Wyloguj item. Logging out always ends on the login
// opens /account, a Language radio group (when the server offers more than
// one admin locale; choosing one saves it and reloads the page) and a
// Wyloguj item. Logging out always ends on the login
// screen, even when the logout call fails (T-10-23).
const router = useRouter()
@@ -38,6 +45,30 @@ const avatarSrc = computed(() => currentUser.value?.avatar?.thumb_url || current
const roleName = computed(() => currentUser.value?.role?.name ?? '')
/** The checked language: the stored preference, else the bundle locale. */
const selectedLocale = computed(() => localePreference.value || currentLocale.value)
/** A language named in itself (polski, English), else its upper-cased code. */
function languageName(code: string): string {
try {
const name = new Intl.DisplayNames([code], { type: 'language' }).of(code)
if (name && name !== code) {
return name.charAt(0).toLocaleUpperCase(code) + name.slice(1)
}
} catch {
// An invalid tag falls through to the code.
}
return code.toUpperCase()
}
const languages = computed(() => availableLocales.value.map((code) => ({ code, name: languageName(code) })))
function onLocale(value: unknown): void {
if (typeof value === 'string' && value !== '') {
void setLocale(value)
}
}
async function onAccount(): Promise<void> {
await router.push({ name: 'account' }).catch(() => undefined)
}
@@ -111,6 +142,29 @@ async function onLogout(): Promise<void> {
<UserRound :size="18" aria-hidden="true" />
<span>{{ t('backend::lang.myaccount.menu_label') }}</span>
</DropdownMenuItem>
<template v-if="languages.length > 1">
<DropdownMenuSeparator class="my-1 h-px bg-border" />
<DropdownMenuLabel class="px-3 pt-1 pb-1 text-[12px] font-semibold text-muted" data-language-label>{{
t('backend::lang.nav.language')
}}</DropdownMenuLabel>
<DropdownMenuRadioGroup :model-value="selectedLocale" @update:model-value="onLocale">
<DropdownMenuRadioItem
v-for="language in languages"
:key="language.code"
:value="language.code"
:data-locale-option="language.code"
:lang="language.code"
class="flex h-10 cursor-pointer items-center gap-2.5 rounded-control px-3 font-medium outline-none transition-colors duration-150 ease-out data-[highlighted]:bg-hover"
>
<span class="flex size-[18px] items-center justify-center">
<DropdownMenuItemIndicator>
<Check :size="18" aria-hidden="true" />
</DropdownMenuItemIndicator>
</span>
<span>{{ language.name }}</span>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</template>
<DropdownMenuSeparator class="my-1 h-px bg-border" />
<DropdownMenuItem
data-action="logout"