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

25
admin/src/app/theme.ts Normal file
View File

@@ -0,0 +1,25 @@
// Colour scheme (D-06, flagged assumption A6): the admin follows the system
// preference only, with no toggle. The .dark class on the document element
// swaps the design's dark tokens in main.css; the sidebar stays dark in both
// modes and gains its #222b40 right border in dark mode.
export const DARK_QUERY = '(prefers-color-scheme: dark)'
/**
* Applies the current preference and follows changes. Returns the
* unsubscribe function.
*/
export function applyColorScheme(win: Window = window): () => void {
const root = win.document.documentElement
if (typeof win.matchMedia !== 'function') {
root.classList.remove('dark')
return () => undefined
}
const query = win.matchMedia(DARK_QUERY)
const apply = (dark: boolean): void => {
root.classList.toggle('dark', dark)
}
apply(query.matches)
const onChange = (event: MediaQueryListEvent): void => apply(event.matches)
query.addEventListener('change', onChange)
return () => query.removeEventListener('change', onChange)
}

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>

View File

@@ -3,12 +3,15 @@ import App from './App.vue'
import { createAdminRouter } from './app/router'
import { onUnauthorized } from './api/client'
import { loadStrings } from './app/i18n'
import { applyColorScheme } from './app/theme'
import { clearUser, me } from './state/useAuth'
import { loadNavigation } from './state/useNavigation'
import { loadSettings } from './state/useSettings'
import './styles/main.css'
async function boot(): Promise<void> {
// Dark mode follows the system preference before anything renders (A6).
applyColorScheme()
// Boot order (research Pattern 7): the public strings bundle first, so the
// login screen is translated, then the session, then navigation.
await loadStrings().catch(() => undefined)

View File

@@ -1,8 +1,11 @@
// Admin session state. The JWT travels in an HttpOnly cookie (D-19): the SPA
// keeps only the profile and the access lifetime, never a token.
import { readonly, ref } from 'vue'
import type { Router } from 'vue-router'
import { api, onRefreshed, refreshSession } from '../api/client'
import type { AdminProfile } from '../api/types'
import { setNavigation } from './useNavigation'
import { setSettings } from './useSettings'
const user = ref<AdminProfile | null>(null)
const expiresIn = ref<number | null>(null)
@@ -57,11 +60,33 @@ export function clearUser(): void {
expiresIn.value = null
}
/**
* Ends the session: POST /auth/logout (the server blacklists the token and
* expires the HttpOnly cookie), then clears the in-memory user, navigation
* and settings and routes to the login screen. The local state is cleared
* and the login screen shown even when the call fails (T-10-23).
*/
export async function logout(router?: Pick<Router, 'replace'>): Promise<void> {
try {
await api.POST('/auth/logout')
} catch {
// Network failure: the session still ends on this device.
} finally {
clearUser()
setNavigation([])
setSettings([])
}
if (router) {
await router.replace({ name: 'login' }).catch(() => undefined)
}
}
export function useAuth() {
return {
user: currentUser,
expiresIn: readonly(expiresIn),
login,
logout,
me,
clearUser,
}

View File

@@ -0,0 +1,16 @@
// The record crumb of the header breadcrumbs (design: Top header). The form
// view owns the record's title; it publishes it here while it is mounted so
// the shell can show "Plugin › Controller › Record".
import { readonly, ref } from 'vue'
const record = ref('')
export const recordCrumb = readonly(record)
export function setRecordCrumb(label: string): void {
record.value = label
}
export function clearRecordCrumb(): void {
record.value = ''
}

View File

@@ -0,0 +1,71 @@
// Sidebar collapsed state (design: Navigation shell, Collapsed / tablet;
// D-06, D-10). The section panel hides below about 1100px or when the admin
// collapses it. The viewport only forces the collapse and never overwrites
// the admin's own choice, which persists in localStorage as a boolean under
// SIDEBAR_KEY. Nothing else is ever written to browser storage (T-10-22).
import { computed, readonly, ref } from 'vue'
export const SIDEBAR_KEY = 'summer-admin.sidebar'
export const NARROW_QUERY = '(max-width: 1099px)'
const manual = ref(false)
const narrow = ref(false)
/** The panel is hidden: forced by the viewport or chosen by the admin. */
export const sidebarCollapsed = computed(() => narrow.value || manual.value)
function storage(): Storage | null {
try {
return typeof window === 'undefined' ? null : window.localStorage
} catch {
return null
}
}
function readPreference(): boolean {
try {
return storage()?.getItem(SIDEBAR_KEY) === 'true'
} catch {
return false
}
}
/** Records the admin's choice; only 'true' or 'false' is stored. */
export function setCollapsed(value: boolean): void {
manual.value = value
try {
storage()?.setItem(SIDEBAR_KEY, value ? 'true' : 'false')
} catch {
// Storage may be full or disabled; the choice still holds for the session.
}
}
/**
* Reads the stored preference and follows the viewport breakpoint. Returns
* the unsubscribe function; the shell calls it when it unmounts.
*/
export function watchViewport(): () => void {
manual.value = readPreference()
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
narrow.value = false
return () => undefined
}
const query = window.matchMedia(NARROW_QUERY)
narrow.value = query.matches
const onChange = (event: MediaQueryListEvent): void => {
narrow.value = event.matches
}
query.addEventListener('change', onChange)
return () => query.removeEventListener('change', onChange)
}
export function useSidebar() {
return {
collapsed: sidebarCollapsed,
/** The viewport is below the breakpoint; expanding is not possible. */
narrow: readonly(narrow),
manual: readonly(manual),
setCollapsed,
watchViewport,
}
}

View File

@@ -1,6 +1,7 @@
// Global toast queue (design: State Management). Toasts outlive route
// changes because AppShell mounts the renderer, so "Zapisz i zamknij" can
// show its toast on the list it returns to.
// show its toast on the list it returns to. One queue; the most recent toast
// comes first.
import { readonly, ref } from 'vue'
export type ToastTone = 'success' | 'danger'
@@ -22,7 +23,7 @@ export const toasts = readonly(items)
export function showToast(text: string, tone: ToastTone = 'success'): number {
const id = nextId++
items.value = [...items.value, { id, text, tone }]
items.value = [{ id, text, tone }, ...items.value]
timers.set(
id,
setTimeout(() => dismissToast(id), TOAST_TIMEOUT),

View File

@@ -104,6 +104,7 @@
}
.dark {
color-scheme: dark;
--c-bg: #111726;
--c-surface: #182033;
--c-subtle: #1f283d;
@@ -164,3 +165,56 @@
color: var(--c-placeholder);
}
}
/* Motion (design: Interactions, Transitions): subtle only. Hover and
background changes use the 150 ms ease-out utilities on each control;
dialogs and their backdrop fade in and scale from 0.98 over 200 ms; toasts
fade in the same way. Closing is immediate, so Reka's presence never waits
on an exit animation. No decorative animation. */
@keyframes summer-fade-in {
from {
opacity: 0;
}
}
@keyframes summer-dialog-in {
from {
opacity: 0;
scale: 0.98;
}
}
@layer components {
/* Backdrops of the picker (data-dialog-overlay) and of ConfirmDialog. */
.bg-overlay[data-state="open"] {
animation: summer-fade-in 200ms ease-out;
}
[data-dialog-content][data-state="open"],
[role="alertdialog"][data-state="open"] {
animation: summer-dialog-in 200ms ease-out;
}
.toast-enter-active {
transition:
opacity 200ms ease-out,
scale 200ms ease-out;
}
.toast-enter-from {
opacity: 0;
scale: 0.98;
}
}
@media (prefers-reduced-motion: reduce) {
.bg-overlay,
[data-dialog-content],
[role="alertdialog"] {
animation: none !important;
}
.toast-enter-active {
transition: none;
}
}

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watchEffect } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Trash2 } from '@lucide/vue'
import { api } from '../api/client'
@@ -28,6 +28,7 @@ import {
import Button from '../components/ui/Button.vue'
import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
import { useConfirm } from '../components/ui/confirm'
import { clearRecordCrumb, setRecordCrumb } from '../state/useBreadcrumbs'
import { showToast } from '../state/useToasts'
// Schema-driven create/update form (D-05, D-09, D-10, D-18; design screen
@@ -299,6 +300,10 @@ function onBeforeUnload(event: BeforeUnloadEvent): void {
onMounted(() => window.addEventListener('beforeunload', onBeforeUnload))
onBeforeUnmount(() => window.removeEventListener('beforeunload', onBeforeUnload))
// The header's last breadcrumb is this record's title (design: Top header).
watchEffect(() => setRecordCrumb(loading.value ? '' : title.value))
onBeforeUnmount(clearRecordCrumb)
void load()
</script>