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,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,
|
||||
}
|
||||
|
||||
16
admin/src/state/useBreadcrumbs.ts
Normal file
16
admin/src/state/useBreadcrumbs.ts
Normal 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 = ''
|
||||
}
|
||||
71
admin/src/state/useSidebar.ts
Normal file
71
admin/src/state/useSidebar.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user