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:
Jakub Zych
2026-09-27 17:26:44 +02:00
parent f4e97cccad
commit 445404e394
24 changed files with 1056 additions and 77 deletions

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { AdminRecord, ControllerParams, FormField as FormFieldSchema, RecordMeta } from '../../api/types'
import FormField from './FormField.vue'
import { RELATION_MANAGER } from './registry'
// Two-column form grid (design screen 4): span left -> column 1, right ->
// column 2, full (Winter's default) -> the whole row, auto and row -> the next
@@ -18,7 +19,7 @@ const emit = defineEmits<{ update: [name: string, value: unknown] }>()
function spanClass(field: FormFieldSchema): string {
// The relation manager always takes the whole row (design screen 5).
if (field.type === 'relation-manager') {
if (field.type === RELATION_MANAGER) {
return 'col-span-full'
}
switch (field.span) {

View File

@@ -33,6 +33,9 @@ export interface FieldControlProps {
recordId?: number | null
}
/** The relation manager's field type (design screen 5). */
export const RELATION_MANAGER = 'relation-manager'
const renderers = new Map<string, Component>([
['text', TextField],
['textarea', TextareaField],
@@ -41,17 +44,17 @@ const renderers = new Map<string, Component>([
['switch', SwitchField],
['checkbox', CheckboxField],
['relation', RelationField],
['relation-manager', RelationManager],
[RELATION_MANAGER, RelationManager],
])
/** Types whose control shows the label itself (toggle cards, relation manager). */
const selfLabelled = new Set<string>(['switch', 'checkbox', 'relation-manager'])
const selfLabelled = new Set<string>(['switch', 'checkbox', RELATION_MANAGER])
/**
* Types that need a saved record and hold no form value: never rendered on
* create, never part of the save body (D-05, design screen 5).
*/
const recordBound = new Set<string>(['relation-manager'])
const recordBound = new Set<string>([RELATION_MANAGER])
export function rendererFor(type: string): Component {
return renderers.get(type) ?? UnsupportedField

View File

@@ -1,72 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { ChevronRight } from '@lucide/vue'
import PluginRail from './PluginRail.vue'
import SectionPanel from './SectionPanel.vue'
import Breadcrumbs from './Breadcrumbs.vue'
import UserMenu from './UserMenu.vue'
import Toast from '../ui/Toast.vue'
import { t } from '../../app/i18n'
import { controllerIdFromPath } from '../../app/controllerRoutes'
import { currentUser } from '../../state/useAuth'
import { activeEntry } from '../../state/useNavigation'
import { useSidebar } from '../../state/useSidebar'
// Navigation shell (design: Navigation shell and Top header; D-06, D-10):
// the plugin rail, the active plugin's section panel (hidden when the
// sidebar is collapsed by the viewport or by the admin), and the 64px header
// with breadcrumbs and the user menu. The toast queue is mounted here so
// toasts survive navigation.
const route = useRoute()
const { collapsed, watchViewport } = useSidebar()
const stopViewport = watchViewport()
onBeforeUnmount(stopViewport)
const active = computed(() => activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? '')))
const section = computed(() => {
const id = controllerIdFromPath(route.path)
return active.value?.sideMenu.find((item) => item.controller === id) ?? null
})
const displayName = computed(() => {
const user = currentUser.value
if (!user) {
return ''
}
const name = `${user.first_name} ${user.last_name}`.trim()
return name === '' ? user.login : name
})
const initials = computed(() =>
displayName.value
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join(''),
)
</script>
<template>
<div class="flex min-h-screen bg-bg text-text">
<PluginRail />
<SectionPanel v-if="active" :entry="active" />
<SectionPanel v-if="active && !collapsed" :entry="active" />
<div class="flex min-w-0 flex-1 flex-col">
<header class="flex h-header shrink-0 items-center justify-between border-b border-border bg-surface pr-6 pl-8">
<nav :aria-label="t('backend::lang.nav.breadcrumbs')" class="flex items-center gap-2">
<span v-if="route.name === 'settings' || route.name === 'settings-form'" class="font-semibold">
{{ t('backend::lang.nav.settings') }}
</span>
<template v-else-if="active">
<span :class="section ? 'text-muted' : 'font-semibold'">{{ active.label }}</span>
<template v-if="section">
<ChevronRight :size="14" class="text-muted" aria-hidden="true" />
<span class="font-semibold" aria-current="page">{{ section.label }}</span>
</template>
</template>
</nav>
<div v-if="currentUser" class="flex h-input items-center gap-2.5 rounded-inner px-2">
<span
class="flex size-[34px] items-center justify-center rounded-full bg-accent text-[13px] font-bold text-on-accent"
aria-hidden="true"
>{{ initials }}</span
>
<span class="flex flex-col leading-tight">
<span class="font-semibold">{{ displayName }}</span>
<span v-if="currentUser.role" class="text-[12px] text-muted">{{ currentUser.role.name }}</span>
</span>
</div>
<header class="flex h-header shrink-0 items-center justify-between gap-4 border-b border-border bg-surface pr-6 pl-8">
<Breadcrumbs />
<UserMenu />
</header>
<main class="min-w-0 flex-1 px-8 py-7">
<slot />

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { ChevronRight } from '@lucide/vue'
import { controllerIdFromPath, controllerPath } from '../../app/controllerRoutes'
import { t } from '../../app/i18n'
import { recordCrumb } from '../../state/useBreadcrumbs'
import { activeEntry, firstControllerPath } from '../../state/useNavigation'
import { settingsEntry } from '../../state/useSettings'
// Header breadcrumbs (design: Top header): plugin, controller and record
// labels, e.g. "Płytarium › Albumy › Enigmatic". Parent crumbs are muted
// links; the current crumb uses the text colour at 600 with
// aria-current="page". Separators are 14px chevron-right icons.
interface Crumb {
label: string
to?: string
}
const route = useRoute()
const crumbs = computed<Crumb[]>(() => {
if (route.name === 'settings' || route.name === 'settings-form') {
const out: Crumb[] = [{ label: t('backend::lang.nav.settings'), to: '/settings' }]
const entry = route.name === 'settings-form' ? settingsEntry(String(route.params.code ?? '')) : null
if (entry) {
out.push({ label: entry.label })
}
return out
}
const plugin = activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? ''))
if (!plugin) {
return []
}
const out: Crumb[] = [{ label: plugin.label, to: firstControllerPath(plugin) ?? undefined }]
const id = controllerIdFromPath(route.path)
const section = plugin.sideMenu.find((item) => item.controller === id) ?? null
if (section) {
out.push({ label: section.label, to: controllerPath(section.controller) ?? undefined })
}
if ((route.name === 'record' || route.name === 'create') && recordCrumb.value !== '') {
out.push({ label: recordCrumb.value })
}
return out
})
</script>
<template>
<nav :aria-label="t('backend::lang.nav.breadcrumbs')" class="min-w-0">
<ol class="flex min-w-0 items-center gap-2">
<li v-for="(crumb, index) in crumbs" :key="index" data-crumb class="flex min-w-0 items-center gap-2">
<ChevronRight v-if="index > 0" :size="14" class="shrink-0 text-muted" aria-hidden="true" />
<span
v-if="index === crumbs.length - 1 || !crumb.to"
:aria-current="index === crumbs.length - 1 ? 'page' : undefined"
:class="index === crumbs.length - 1 ? 'font-semibold text-text' : 'text-muted'"
class="truncate"
>{{ crumb.label }}</span
>
<RouterLink
v-else
:to="crumb.to"
class="truncate text-muted no-underline transition-colors duration-150 ease-out hover:text-text"
>{{ crumb.label }}</RouterLink
>
</li>
</ol>
</nav>
</template>

View File

@@ -1,13 +1,26 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { Settings, Sun } from '@lucide/vue'
import { PanelLeftOpen, Settings, Sun } from '@lucide/vue'
import { t } from '../../app/i18n'
import { iconFor } from '../../app/icons'
import { activeEntry, firstControllerPath, railEntries } from '../../state/useNavigation'
import { hasSettings } from '../../state/useSettings'
import { useSidebar } from '../../state/useSidebar'
import SectionFlyout from './SectionFlyout.vue'
// Plugin rail (design: Navigation shell, level 1; D-10). Clicking an item
// opens the plugin's first permitted controller; the active item owns the
// route's vendor and plugin. With the section panel collapsed, hovering or
// focusing an item, or pressing Enter or ArrowDown on it, opens the
// SectionFlyout; it closes on Esc (focus returns to the item) or about
// 200 ms after the pointer or focus leaves both the item and the flyout.
/** Delay before a flyout closes after mouse-leave or focus-out. */
const FLYOUT_CLOSE_DELAY = 200
const route = useRoute()
const { collapsed, narrow, setCollapsed } = useSidebar()
const active = computed(() => activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? '')))
@@ -23,6 +36,107 @@ const items = computed(() =>
current: active.value?.code === entry.code,
})),
)
const openCode = ref<string | null>(null)
const flyout = ref<InstanceType<typeof SectionFlyout> | null>(null)
const openEntry = computed(() => railEntries.value.find((entry) => entry.code === openCode.value) ?? null)
const itemElements = new Map<string, HTMLElement>()
let closeTimer: ReturnType<typeof setTimeout> | null = null
let returningFocus = false
function flyoutId(code: string): string {
return `section-flyout-${code}`
}
function setItemElement(code: string, instance: unknown): void {
const element = (instance as { $el?: unknown } | null)?.$el
if (element instanceof HTMLElement) {
itemElements.set(code, element)
} else {
itemElements.delete(code)
}
}
function cancelClose(): void {
if (closeTimer !== null) {
clearTimeout(closeTimer)
closeTimer = null
}
}
function closeFlyout(): void {
cancelClose()
openCode.value = null
}
function scheduleClose(): void {
cancelClose()
closeTimer = setTimeout(() => {
closeTimer = null
const focused = document.activeElement
const code = openCode.value
// Keep it open while focus sits on its rail item or inside it.
if (code !== null && (itemElements.get(code) === focused || flyout.value?.contains(focused))) {
return
}
openCode.value = null
}, FLYOUT_CLOSE_DELAY)
}
function openFlyout(code: string): void {
if (!collapsed.value) {
return
}
cancelClose()
openCode.value = code
}
function onItemFocus(code: string): void {
if (!returningFocus) {
openFlyout(code)
}
}
async function onItemKeydown(event: KeyboardEvent, code: string): Promise<void> {
if (!collapsed.value) {
return
}
if (event.key === 'Enter' || event.key === 'ArrowDown') {
event.preventDefault()
openFlyout(code)
await nextTick()
await flyout.value?.focusFirst()
} else if (event.key === 'Escape' && openCode.value !== null) {
event.preventDefault()
closeFlyout()
}
}
/** Esc inside the flyout: close it and return focus to its rail item. */
function onFlyoutEscape(): void {
const code = openCode.value
closeFlyout()
if (code === null) {
return
}
returningFocus = true
itemElements.get(code)?.focus()
returningFocus = false
}
function expand(): void {
setCollapsed(false)
void nextTick(() => document.querySelector<HTMLElement>('[data-sidebar-collapse]')?.focus())
}
// Expanding the panel (manually or by widening the window) drops the flyout.
watch(collapsed, (value) => {
if (!value) {
closeFlyout()
}
})
onBeforeUnmount(cancelClose)
</script>
<template>
@@ -36,22 +150,56 @@ const items = computed(() =>
<RouterLink
v-for="item in items"
:key="item.entry.code"
:ref="(instance) => setItemElement(item.entry.code, instance)"
:to="item.to"
:data-rail-item="item.entry.code"
:aria-current="item.current ? 'page' : undefined"
:aria-haspopup="collapsed ? 'menu' : undefined"
:aria-expanded="collapsed ? (openCode === item.entry.code ? 'true' : 'false') : undefined"
:aria-controls="collapsed && openCode === item.entry.code ? flyoutId(item.entry.code) : undefined"
:class="item.current ? 'bg-accent-soft font-bold text-accent' : 'text-side-text hover:bg-side-hover hover:text-white'"
class="flex w-[68px] flex-col items-center gap-1 rounded-inner px-1.5 py-2 text-center text-[11px] leading-[1.2] tracking-[-0.01em] no-underline"
class="flex w-[68px] flex-col items-center gap-1 rounded-inner px-1.5 py-2 text-center text-[11px] leading-[1.2] tracking-[-0.01em] no-underline transition-colors duration-150 ease-out"
@mouseenter="openFlyout(item.entry.code)"
@mouseleave="scheduleClose"
@focus="onItemFocus(item.entry.code)"
@blur="scheduleClose"
@keydown="onItemKeydown($event, item.entry.code)"
>
<component :is="item.icon" :size="20" aria-hidden="true" />
<span>{{ item.entry.label }}</span>
</RouterLink>
<SectionFlyout
v-if="collapsed && openEntry"
:id="flyoutId(openEntry.code)"
ref="flyout"
:entry="openEntry"
@mouseenter="cancelClose"
@mouseleave="scheduleClose"
@focusin="cancelClose"
@focusout="scheduleClose"
@escape="onFlyoutEscape"
@leave="scheduleClose"
@navigate="closeFlyout"
/>
<span class="flex-1" aria-hidden="true" />
<button
v-if="collapsed && !narrow"
type="button"
data-sidebar-expand
:aria-label="t('backend::lang.nav.expand')"
:title="t('backend::lang.nav.expand')"
class="mb-1 flex size-10 items-center justify-center rounded-inner text-side-text transition-colors duration-150 ease-out hover:bg-side-hover hover:text-white"
@click="expand"
>
<PanelLeftOpen :size="20" aria-hidden="true" />
</button>
<RouterLink
v-if="hasSettings"
to="/settings"
data-rail-settings
:aria-current="inSettings ? 'page' : undefined"
:class="inSettings ? 'bg-accent-soft font-bold text-accent' : 'text-side-text hover:bg-side-hover hover:text-white'"
class="flex w-[68px] flex-col items-center gap-1 rounded-inner px-1.5 py-2 text-center text-[11px] leading-[1.2] tracking-[-0.01em] no-underline"
class="flex w-[68px] flex-col items-center gap-1 rounded-inner px-1.5 py-2 text-center text-[11px] leading-[1.2] tracking-[-0.01em] no-underline transition-colors duration-150 ease-out"
>
<Settings :size="20" aria-hidden="true" />
<span>{{ t('backend::lang.nav.settings') }}</span>

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import type { NavigationEntry } from '../../api/types'
import { controllerIdFromPath, controllerPath } from '../../app/controllerRoutes'
import { iconFor } from '../../app/icons'
// Collapsed-rail flyout (design: Navigation shell, Collapsed / tablet; D-10):
// the plugin's side menu in a 230px menu 8px right of the rail, aligned with
// the header area. role="menu" with menuitems; arrow keys move between items,
// Esc asks the rail to close it and return focus to the rail item.
const props = defineProps<{ entry: NavigationEntry; id: string }>()
const emit = defineEmits<{ escape: []; leave: []; navigate: [] }>()
const route = useRoute()
const root = ref<HTMLElement | null>(null)
const currentController = computed(() => controllerIdFromPath(route.path))
const items = computed(() =>
props.entry.sideMenu.flatMap((item) => {
const to = controllerPath(item.controller)
if (!to) {
return []
}
return [{ item, to, icon: iconFor(item.icon), current: currentController.value === item.controller }]
}),
)
function menuItems(): HTMLElement[] {
return [...(root.value?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])]
}
/** Moves focus to the first item (Enter or ArrowDown on the rail item). */
async function focusFirst(): Promise<void> {
await nextTick()
menuItems()[0]?.focus()
}
function contains(element: Element | null): boolean {
return !!element && !!root.value?.contains(element)
}
function onKeydown(event: KeyboardEvent): void {
const list = menuItems()
const index = list.indexOf(document.activeElement as HTMLElement)
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
list[(index + 1) % list.length]?.focus()
break
case 'ArrowUp':
event.preventDefault()
list[(index - 1 + list.length) % list.length]?.focus()
break
case 'Home':
event.preventDefault()
list[0]?.focus()
break
case 'End':
event.preventDefault()
list[list.length - 1]?.focus()
break
case 'Escape':
event.preventDefault()
emit('escape')
break
case 'Tab':
emit('leave')
break
}
}
defineExpose({ focusFirst, contains })
</script>
<template>
<div
:id="id"
ref="root"
role="menu"
data-section-flyout
:aria-label="entry.label"
class="fixed top-3 left-[calc(var(--spacing-rail)+8px)] z-40 flex w-[230px] flex-col gap-0.5 rounded-[14px] border border-border bg-surface p-2 text-text shadow-pop"
@keydown="onKeydown"
>
<span class="px-2.5 pt-1 pb-2 text-[14px] font-bold" aria-hidden="true">{{ entry.label }}</span>
<RouterLink
v-for="link in items"
:key="link.item.code"
:to="link.to"
role="menuitem"
tabindex="-1"
:aria-current="link.current ? 'page' : undefined"
:class="link.current ? 'bg-sel font-semibold text-text' : 'font-medium text-muted hover:bg-hover hover:text-text'"
class="flex h-[38px] items-center gap-3 rounded-tab px-2.5 no-underline transition-colors duration-150 ease-out"
@click="emit('navigate')"
>
<component :is="link.icon" :size="18" aria-hidden="true" />
<span>{{ link.item.label }}</span>
</RouterLink>
</div>
</template>

View File

@@ -1,10 +1,16 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, nextTick } from 'vue'
import { PanelLeftClose } from '@lucide/vue'
import { RouterLink, useRoute } from 'vue-router'
import type { NavigationEntry } from '../../api/types'
import { controllerIdFromPath, controllerPath } from '../../app/controllerRoutes'
import { iconFor } from '../../app/icons'
import { t } from '../../app/i18n'
import { setCollapsed } from '../../state/useSidebar'
// Section panel (design: Navigation shell, level 2): the active plugin's
// permitted side menu, 224px, with the plugin label and a "Zwiń menu" button
// in its header. Collapsing hides it and leaves only the rail (D-06).
const props = defineProps<{ entry: NavigationEntry }>()
const route = useRoute()
@@ -20,6 +26,11 @@ const items = computed(() =>
return [{ item, to, icon: iconFor(item.icon), current: currentController.value === item.controller }]
}),
)
function collapse(): void {
setCollapsed(true)
void nextTick(() => document.querySelector<HTMLElement>('[data-sidebar-expand]')?.focus())
}
</script>
<template>
@@ -27,8 +38,18 @@ const items = computed(() =>
:aria-label="t('backend::lang.nav.sections')"
class="flex w-panel shrink-0 flex-col gap-0.5 border-r border-border bg-surface px-3 py-3.5"
>
<div class="mb-3.5 flex h-10 items-center px-2">
<span class="text-[16px] font-bold">{{ entry.label }}</span>
<div class="mb-3.5 flex h-10 items-center justify-between gap-2 pl-2">
<span class="truncate text-[16px] font-bold">{{ entry.label }}</span>
<button
type="button"
data-sidebar-collapse
:aria-label="t('backend::lang.nav.collapse')"
:title="t('backend::lang.nav.collapse')"
class="flex size-[30px] shrink-0 items-center justify-center rounded-tab text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text"
@click="collapse"
>
<PanelLeftClose :size="18" aria-hidden="true" />
</button>
</div>
<RouterLink
v-for="link in items"
@@ -36,7 +57,7 @@ const items = computed(() =>
:to="link.to"
:aria-current="link.current ? 'page' : undefined"
:class="link.current ? 'bg-sel font-semibold text-text' : 'font-medium text-muted hover:bg-hover hover:text-text'"
class="flex h-nav items-center gap-3 rounded-control px-3 no-underline"
class="flex h-nav items-center gap-3 rounded-control px-3 no-underline transition-colors duration-150 ease-out"
>
<component :is="link.icon" :size="18" aria-hidden="true" />
<span>{{ link.item.label }}</span>

View 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>

View File

@@ -2,11 +2,20 @@
import { Check, CircleAlert, X } from '@lucide/vue'
import { t } from '../../app/i18n'
import { dismissToast, toasts } from '../../state/useToasts'
// Toast stack (design: Interactions, Save and Relation manager): one global
// queue, most recent first, success as status and danger as alert. Toasts
// fade and scale in over 200 ms (no decorative animation) and dismiss after
// about 4 s or on the close button.
</script>
<template>
<!-- Bottom centre, 88px up so it clears the sticky form footer. -->
<div class="pointer-events-none fixed inset-x-0 bottom-[88px] z-50 flex flex-col items-center gap-2 px-4">
<TransitionGroup
tag="div"
name="toast"
class="pointer-events-none fixed inset-x-0 bottom-[88px] z-50 flex flex-col items-center gap-2 px-4"
>
<div
v-for="toast in toasts"
:key="toast.id"
@@ -26,11 +35,11 @@ import { dismissToast, toasts } from '../../state/useToasts'
<button
type="button"
:aria-label="t('backend::lang.form.close')"
class="flex size-8 items-center justify-center rounded-pager text-[#c3cbda] hover:bg-side-hover hover:text-white"
class="flex size-8 items-center justify-center rounded-pager text-[#c3cbda] transition-colors duration-150 ease-out hover:bg-side-hover hover:text-white"
@click="dismissToast(toast.id)"
>
<X :size="16" aria-hidden="true" />
</button>
</div>
</div>
</TransitionGroup>
</template>