refactor(ui): улучшить структуру компонентов AppSidebar и PageHeader
Build, Test, and Push CFDM Docker Image / test (push) Successful in 3m11s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m9s
Build, Test, and Push CFDM Docker Image / create-release (push) Skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 6s

Обновлены компоненты AppSidebar и PageHeader для улучшения структуры и адаптивности. В AppSidebar добавлен новый элемент SidebarGroupContent для более четкой организации навигации. В PageHeader упрощена разметка и улучшены стили, включая изменения в отображении заголовка и описания. Также обновлены стили в AppShell для интеграции SearchMenu и TooltipProvider, что улучшает взаимодействие с пользователем.
This commit is contained in:
Denozordec
2026-07-17 23:56:29 +07:00
parent 1893778e3a
commit 60e15ca40a
8 changed files with 136 additions and 140 deletions
+28 -30
View File
@@ -8,18 +8,17 @@ import {
SettingsIcon, SettingsIcon,
} from 'lucide-react' } from 'lucide-react'
import { AppSwitcher } from '@/components/app-switcher' import { AppSwitcher } from '@/components/app-switcher'
import { NavUser } from '@/components/nav-user'
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
SidebarFooter, SidebarFooter,
SidebarGroup, SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel, SidebarGroupLabel,
SidebarHeader, SidebarHeader,
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
SidebarRail,
} from '@cfdm/ui/components/sidebar' } from '@cfdm/ui/components/sidebar'
const infrastructureNav = [ const infrastructureNav = [
@@ -65,30 +64,32 @@ function NavSection({
return ( return (
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>{label}</SidebarGroupLabel> <SidebarGroupLabel>{label}</SidebarGroupLabel>
<SidebarMenu> <SidebarGroupContent>
{items.map((item) => ( <SidebarMenu>
<SidebarMenuItem key={item.to}> {items.map((item) => (
<SidebarMenuButton <SidebarMenuItem key={item.to}>
tooltip={item.label} <SidebarMenuButton
isActive={isNavActive( tooltip={item.label}
pathname, isActive={isNavActive(
item.to, pathname,
item.exact, item.to,
item.matchPrefix, item.exact,
)} item.matchPrefix,
render={ )}
<Link render={
to={item.to} <Link
activeOptions={{ exact: item.exact }} to={item.to}
/> activeOptions={{ exact: item.exact }}
} />
> }
<item.icon /> >
<span>{item.label}</span> <item.icon className="size-4" />
</SidebarMenuButton> <span>{item.label}</span>
</SidebarMenuItem> </SidebarMenuButton>
))} </SidebarMenuItem>
</SidebarMenu> ))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
) )
} }
@@ -105,10 +106,7 @@ export function AppSidebar() {
<NavSection label="Инфраструктура" items={infrastructureNav} pathname={pathname} /> <NavSection label="Инфраструктура" items={infrastructureNav} pathname={pathname} />
<NavSection label="Операции" items={operationsNav} pathname={pathname} /> <NavSection label="Операции" items={operationsNav} pathname={pathname} />
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter />
<NavUser />
</SidebarFooter>
<SidebarRail />
</Sidebar> </Sidebar>
) )
} }
+21 -22
View File
@@ -1,31 +1,30 @@
import type { CSSProperties, ReactNode } from 'react' import type { CSSProperties, ReactNode } from 'react'
import { AppSidebar } from '@/components/app-sidebar' import { AppSidebar } from '@/components/app-sidebar'
import { SiteHeader } from '@/components/layout/site-header' import { SiteHeader } from '@/components/layout/site-header'
import { cn } from '@cfdm/ui/lib/utils' import { SearchMenu } from '@/components/layout/search-menu'
import { TooltipProvider } from '@cfdm/ui/components/tooltip'
import { SidebarInset, SidebarProvider } from '@cfdm/ui/components/sidebar' import { SidebarInset, SidebarProvider } from '@cfdm/ui/components/sidebar'
/** Shared ops chrome — etalon EvoBGP. @see docs/ui-design-contract.md */
export function AppShell({ children }: { children: ReactNode }) { export function AppShell({ children }: { children: ReactNode }) {
return ( return (
<SidebarProvider <TooltipProvider delay={0}>
className={cn( <SidebarProvider
'[--sidebar:color-mix(in_oklab,var(--color-sidebar)_60%,transparent)]', style={
'[--sidebar-border:transparent]', {
'[--sidebar-accent:color-mix(in_oklab,var(--color-primary)_14%,transparent)]', '--sidebar-width': '240px',
'[--sidebar-accent-foreground:var(--color-primary)]', } as CSSProperties
)} }
style={ >
{ <AppSidebar />
'--sidebar-width': '240px', <SidebarInset>
} as CSSProperties <SiteHeader />
} <main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
> {children}
<AppSidebar /> </main>
<SidebarInset> </SidebarInset>
<SiteHeader /> <SearchMenu hotkeyOnly />
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5"> </SidebarProvider>
{children} </TooltipProvider>
</main>
</SidebarInset>
</SidebarProvider>
) )
} }
+18 -16
View File
@@ -65,8 +65,8 @@ const NAV_ITEMS = [
}, },
] as const ] as const
/** Command-K search — app-shell-12 SearchMenu, routes only (no demo data). */ /** Command-K search — hotkey dialog (no header chrome trigger). Etalon: EvoBGP CommandPalette hotkeyOnly. */
export function SearchMenu() { export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const searchInputId = useId() const searchInputId = useId()
@@ -105,20 +105,22 @@ export function SearchMenu() {
return ( return (
<> <>
<Button {hotkeyOnly ? null : (
type="button" <Button
variant="ghost" type="button"
size="icon" variant="ghost"
aria-label="Поиск" size="icon"
aria-haspopup="dialog" aria-label="Поиск"
aria-expanded={open} aria-haspopup="dialog"
onClick={() => setOpen(true)} aria-expanded={open}
> onClick={() => setOpen(true)}
<SearchIcon >
className="size-4.5 transition-colors" <SearchIcon
aria-hidden="true" className="size-4.5 transition-colors"
/> aria-hidden="true"
</Button> />
</Button>
)}
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogHeader className="sr-only"> <DialogHeader className="sr-only">
+29 -30
View File
@@ -8,10 +8,10 @@ import {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
} from '@cfdm/ui/components/breadcrumb' } from '@cfdm/ui/components/breadcrumb'
import { Separator } from '@cfdm/ui/components/separator'
import { ModeToggle } from '@/components/mode-toggle' import { ModeToggle } from '@/components/mode-toggle'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover' import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { AppsMenu } from '@/components/layout/apps-menu' import { AppsMenu } from '@/components/layout/apps-menu'
import { SearchMenu } from '@/components/layout/search-menu'
import { SidebarTrigger } from '@cfdm/ui/components/sidebar' import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
export interface RouteBreadcrumbLoaderData { export interface RouteBreadcrumbLoaderData {
@@ -93,6 +93,7 @@ function useDynamicBreadcrumbLabels() {
}, [matches]) }, [matches])
} }
/** Header chrome — etalon EvoBGP (Trigger + Separator + Breadcrumb + Apps/Monitor/Mode). */
export function SiteHeader() { export function SiteHeader() {
const pathname = useRouterState({ select: (s) => s.location.pathname }) const pathname = useRouterState({ select: (s) => s.location.pathname })
const dynamicLabels = useDynamicBreadcrumbLabels() const dynamicLabels = useDynamicBreadcrumbLabels()
@@ -100,36 +101,34 @@ export function SiteHeader() {
return ( return (
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6"> <header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
<div className="flex min-w-0 items-center gap-2"> <SidebarTrigger className="-ml-1" />
<SidebarTrigger className="-ml-1" /> <Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb> <Breadcrumb>
<BreadcrumbList> <BreadcrumbList>
{crumbs.map((crumb, index) => { {crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1 const isLast = index === crumbs.length - 1
return ( return (
<span key={crumb.href} className="contents"> <span key={crumb.href} className="contents">
{index > 0 && ( {index > 0 && (
<BreadcrumbSeparator className="hidden md:block" /> <BreadcrumbSeparator className="hidden md:block" />
)}
<BreadcrumbItem
className={index === 0 && !isLast ? 'hidden md:block' : undefined}
>
{isLast ? (
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
) : (
<BreadcrumbLink render={<Link to={crumb.href} />}>
{crumb.label}
</BreadcrumbLink>
)} )}
<BreadcrumbItem </BreadcrumbItem>
className={index === 0 ? 'hidden md:block' : undefined} </span>
> )
{isLast ? ( })}
<BreadcrumbPage>{crumb.label}</BreadcrumbPage> </BreadcrumbList>
) : ( </Breadcrumb>
<BreadcrumbLink render={<Link to={crumb.href} />}> <div className="ml-auto flex items-center gap-2">
{crumb.label}
</BreadcrumbLink>
)}
</BreadcrumbItem>
</span>
)
})}
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="text-muted-foreground [&_button_svg]:text-muted-foreground [&_button:active_svg]:text-foreground! [&_button:hover>span>svg]:text-foreground! [&_button:hover>svg]:text-foreground! [&_button[aria-expanded=true]_svg]:text-foreground! [&_button[data-popup-open]_svg]:text-foreground! ml-auto flex items-center gap-2">
<SearchMenu />
<AppsMenu /> <AppsMenu />
<SystemMonitorPopover /> <SystemMonitorPopover />
<ModeToggle /> <ModeToggle />
+8 -31
View File
@@ -1,45 +1,22 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { cn } from '@cfdm/ui/lib/utils'
interface PageHeaderProps { interface PageHeaderProps {
title: string title: string
description?: ReactNode description?: ReactNode
actions?: ReactNode actions?: ReactNode
className?: string
} }
/** /** Page-level section header — etalon EvoBGP. */
* Page-level section header (h1) — shared by dashboard, lists, settings. export function PageHeader({ title, description, actions }: PageHeaderProps) {
* Distinct from FrameTitle (text-sm inside Frame panels).
*/
export function PageHeader({
title,
description,
actions,
className,
}: PageHeaderProps) {
return ( return (
<header <div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
className={cn( <div className="flex flex-col gap-1">
'flex flex-wrap items-start justify-between gap-3 px-1', <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
className,
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-px">
<h1 className="text-xl font-semibold tracking-tight md:text-2xl">
{title}
</h1>
{description ? ( {description ? (
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed"> <p className="text-muted-foreground text-sm">{description}</p>
{description}
</p>
) : null} ) : null}
</div> </div>
{actions ? ( {actions ? <div className="flex items-center gap-2">{actions}</div> : null}
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2"> </div>
{actions}
</div>
) : null}
</header>
) )
} }
@@ -1,11 +1,13 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PaletteIcon } from 'lucide-react' import { LogOutIcon, PaletteIcon } from 'lucide-react'
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
import { clearToken } from '@/lib/auth'
import { SettingRow } from '@/components/setting-row' import { SettingRow } from '@/components/setting-row'
import { Switch } from '@cfdm/ui/components/switch' import { Switch } from '@cfdm/ui/components/switch'
import { Button } from '@cfdm/ui/components/button'
import { import {
Frame, Frame,
FrameDescription, FrameDescription,
@@ -29,6 +31,7 @@ export const Route = createFileRoute('/_auth/settings/appearance')({
}) })
function AppearanceSettingsPage() { function AppearanceSettingsPage() {
const navigate = useNavigate()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['app-settings'], queryKey: ['app-settings'],
@@ -48,6 +51,11 @@ function AppearanceSettingsPage() {
const showQuickActions = data?.showQuickActions !== false const showQuickActions = data?.showQuickActions !== false
const handleLogout = () => {
clearToken()
void navigate({ to: '/login' })
}
return ( return (
<Frame dense spacing="sm" className="w-full"> <Frame dense spacing="sm" className="w-full">
<FrameHeader> <FrameHeader>
@@ -62,7 +70,6 @@ function AppearanceSettingsPage() {
<SettingRow <SettingRow
title="Быстрые действия" title="Быстрые действия"
description="KPI-like плитки быстрых переходов под метриками на главной." description="KPI-like плитки быстрых переходов под метриками на главной."
last
> >
<Switch <Switch
checked={showQuickActions} checked={showQuickActions}
@@ -73,6 +80,16 @@ function AppearanceSettingsPage() {
aria-label="Показывать быстрые действия" aria-label="Показывать быстрые действия"
/> />
</SettingRow> </SettingRow>
<SettingRow
title="Сессия"
description="Выйти из учётной записи администратора."
last
>
<Button type="button" variant="outline" size="sm" onClick={handleLogout}>
<LogOutIcon className="size-4" aria-hidden />
Выйти
</Button>
</SettingRow>
</FieldGroup> </FieldGroup>
</FramePanel> </FramePanel>
</Frame> </Frame>
+11 -7
View File
@@ -49,18 +49,22 @@ Gating: DB `show_quick_actions` / `showQuickActions` / `ui_show_quick_actions` (
## Shared App Shell chrome ## Shared App Shell chrome
Эталон: CFDM + ReUI [app-shell-12](https://reui.io/preview/base/app-shell-12) · monitor/switchers [app-shell-7](https://reui.io/preview/base/app-shell-7). Эталон: **EvoBGP** production AppShell + ReUI [app-shell-12](https://reui.io/preview/base/app-shell-12).
При переключении между vps-tracker / CFDM / EvoBGP меняются **только** sidebar nav и `main` content. ClassNames chrome идентичны. При переключении между vps-tracker / CFDM / EvoBGP меняются **только** sidebar nav labels/hrefs и `main` content. Разметка, ширина, фон и hover chrome идентичны.
| Токен / зона | Значение | | Токен / зона | Значение |
|--------------|----------| |--------------|----------|
| `--sidebar-width` | `240px` | | `SIDEBAR_WIDTH` / `--sidebar-width` | `240px` (в `packages/ui` sidebar + Provider style) |
| Sidebar accent | primary **14%** mix + transparent border | | Sidebar / hover colors | theme `--sidebar` / `--sidebar-accent` из `globals.css` — **без** AppShell `color-mix` override |
| Header | `h-12`, `sticky`, `border-b`, `px-4 md:px-6` (без blur / без `h-16`) | | Header | `h-12`, `sticky`, `border-b`, `px-4 md:px-6` |
| Header right | **AppsMenu** → **SystemMonitorPopover** → **ModeToggle** | | Header left | `SidebarTrigger` + `Separator` + Breadcrumb |
| Header right | **AppsMenu** → **SystemMonitorPopover** → **ModeToggle** (без Search в chrome) |
| Sidebar | AppSwitcher → groups (`SidebarGroupContent`) → icons `size-4` → **пустой** `SidebarFooter` |
| `main` | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` | | `main` | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` |
| Sidebar header | `AppSwitcher` (не static brand) | | Search | hotkey ⌘K / Ctrl+K only (не кнопка в header) |
Запрещено в chrome: `SidebarRail`, `NavUser` footer, sync-row footer, Search/Ctrl+K pill в header, issues Badge в header, muted/hover cascade на right-cluster, Provider `color-mix` для `--sidebar*`.
App Switcher ids: `vps-tracker` · `cfdm` · `evobgp`. Override: `VITE_APP_SWITCHER` JSON. App Switcher ids: `vps-tracker` · `cfdm` · `evobgp`. Override: `VITE_APP_SWITCHER` JSON.
+1 -1
View File
@@ -25,7 +25,7 @@ import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state" const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem" const SIDEBAR_WIDTH = "240px"
const SIDEBAR_WIDTH_MOBILE = "18rem" const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem" const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b" const SIDEBAR_KEYBOARD_SHORTCUT = "b"