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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
69
admin/src/components/shell/Breadcrumbs.vue
Normal file
69
admin/src/components/shell/Breadcrumbs.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
103
admin/src/components/shell/SectionFlyout.vue
Normal file
103
admin/src/components/shell/SectionFlyout.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user