feat(10-04): collapsible panel with flyout, user menu with logout, breadcrumbs and dark mode
- useSidebar: collapsed below 1100px (matchMedia) or by the admin's choice, persisted as a boolean under summer-admin.sidebar; the viewport never overwrites the stored choice - SectionPanel collapse and rail expand buttons; SectionFlyout (role menu) opens on hover, focus, Enter or ArrowDown on a rail item, closes on Esc or about 200 ms after leaving and returns focus to the rail item - Breadcrumbs with plugin, controller and record crumbs; UserMenu (Reka DropdownMenu) with initials, name, role and Wyloguj - useAuth.logout POSTs /auth/logout, clears user, navigation and settings and routes to login even when the call fails - applyColorScheme toggles .dark from prefers-color-scheme (no toggle, A6) - newest toast first, 200 ms fade and scale for dialogs and toasts - one relation-manager type constant in the registry - shell smoke tests; tests default to a desktop, light matchMedia
This commit is contained in:
107
admin/src/components/shell/UserMenu.vue
Normal file
107
admin/src/components/shell/UserMenu.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from 'reka-ui'
|
||||
import { ChevronDown, LogOut } from '@lucide/vue'
|
||||
import { t } from '../../app/i18n'
|
||||
import { currentUser, logout } from '../../state/useAuth'
|
||||
|
||||
// User menu (design: Top header): a 44px button with the initials avatar,
|
||||
// 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 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(() => {
|
||||
const user = currentUser.value
|
||||
if (!user) {
|
||||
return ''
|
||||
}
|
||||
const parts = [user.first_name, user.last_name].map((part) => part.trim()).filter(Boolean)
|
||||
if (parts.length > 0) {
|
||||
return parts
|
||||
.slice(0, 2)
|
||||
.map((part) => part.charAt(0).toUpperCase())
|
||||
.join('')
|
||||
}
|
||||
return user.login.slice(0, 2).toUpperCase()
|
||||
})
|
||||
|
||||
const roleName = computed(() => currentUser.value?.role?.name ?? '')
|
||||
|
||||
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"
|
||||
>
|
||||
<span
|
||||
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">
|
||||
<span
|
||||
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>
|
||||
<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>
|
||||
Reference in New Issue
Block a user