- 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
181 lines
7.1 KiB
Vue
181 lines
7.1 KiB
Vue
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import {
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuItemIndicator,
|
|
DropdownMenuLabel,
|
|
DropdownMenuPortal,
|
|
DropdownMenuRadioGroup,
|
|
DropdownMenuRadioItem,
|
|
DropdownMenuRoot,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from 'reka-ui'
|
|
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, 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()
|
|
|
|
const displayName = computed(() => {
|
|
const user = currentUser.value
|
|
if (!user) {
|
|
return ''
|
|
}
|
|
const name = `${user.first_name} ${user.last_name}`.trim()
|
|
return name === '' ? user.login : name
|
|
})
|
|
|
|
/** First and last name initials, else the login's first letters. */
|
|
const initials = computed(() => profileInitials(currentUser.value))
|
|
|
|
/** The avatar thumbnail, else the image; '' shows the initials. */
|
|
const avatarSrc = computed(() => currentUser.value?.avatar?.thumb_url || currentUser.value?.avatar?.url || '')
|
|
|
|
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)
|
|
}
|
|
|
|
async function onLogout(): Promise<void> {
|
|
await logout(router)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<!-- The menu stays mounted when the user clears (logout); the shell itself
|
|
unmounts on the login route. -->
|
|
<DropdownMenuRoot :modal="false">
|
|
<DropdownMenuTrigger
|
|
data-user-menu
|
|
:disabled="!currentUser"
|
|
:aria-label="`${t('backend::lang.nav.user_menu')}: ${displayName}`"
|
|
class="flex h-input items-center gap-2.5 rounded-inner px-2 text-left transition-colors duration-150 ease-out hover:bg-hover"
|
|
>
|
|
<img
|
|
v-if="avatarSrc"
|
|
:src="avatarSrc"
|
|
alt=""
|
|
data-user-avatar
|
|
class="size-[34px] shrink-0 rounded-full object-cover"
|
|
/>
|
|
<span
|
|
v-else
|
|
class="flex size-[34px] shrink-0 items-center justify-center rounded-full bg-accent text-[13px] font-bold text-on-accent"
|
|
aria-hidden="true"
|
|
data-user-initials
|
|
>{{ initials }}</span
|
|
>
|
|
<span class="flex flex-col leading-tight max-[1099px]:hidden" data-user-identity>
|
|
<span class="font-semibold">{{ displayName }}</span>
|
|
<span v-if="roleName" class="text-[12px] text-muted" data-user-role>{{ roleName }}</span>
|
|
</span>
|
|
<ChevronDown :size="16" class="text-muted" aria-hidden="true" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuPortal>
|
|
<DropdownMenuContent
|
|
data-user-menu-content
|
|
align="end"
|
|
:side-offset="8"
|
|
class="z-50 flex w-[280px] flex-col rounded-[14px] border border-border bg-surface p-2 text-text shadow-menu"
|
|
>
|
|
<div class="flex items-center gap-3 px-2 py-2">
|
|
<img
|
|
v-if="avatarSrc"
|
|
:src="avatarSrc"
|
|
alt=""
|
|
data-user-avatar
|
|
class="size-10 shrink-0 rounded-full object-cover"
|
|
/>
|
|
<span
|
|
v-else
|
|
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-accent text-[14px] font-bold text-on-accent"
|
|
aria-hidden="true"
|
|
>{{ initials }}</span
|
|
>
|
|
<span class="flex min-w-0 flex-col leading-tight">
|
|
<span class="truncate font-bold">{{ displayName }}</span>
|
|
<span class="truncate text-[13px] text-muted">{{ currentUser?.email }}</span>
|
|
</span>
|
|
</div>
|
|
<DropdownMenuItem
|
|
data-action="account"
|
|
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"
|
|
@select="onAccount"
|
|
>
|
|
<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"
|
|
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"
|
|
@select="onLogout"
|
|
>
|
|
<LogOut :size="18" aria-hidden="true" />
|
|
<span>{{ t('backend::lang.auth.logout') }}</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenuPortal>
|
|
</DropdownMenuRoot>
|
|
</template>
|