feat(web): улучшить компоненты PageHeader и AppShell, добавить TooltipProvider
Docker / build (push) Failing after 18s

- Обновлены стили компонента PageHeader для более компактного отображения описания.
- Внедрён TooltipProvider в AppShell для улучшения взаимодействия с пользователем.
- Изменён размер боковой панели на 240px для согласованности с дизайном.
- Обновлена документация интерфейса для отражения изменений в компонентах.
This commit is contained in:
Denozordec
2026-07-17 23:56:26 +07:00
parent 29e096862a
commit d04630a7d1
4 changed files with 110 additions and 119 deletions
+94 -110
View File
@@ -39,6 +39,7 @@ import {
} from '@cfdm/ui/components/breadcrumb' } from '@cfdm/ui/components/breadcrumb'
import { Separator } from '@cfdm/ui/components/separator' import { Separator } from '@cfdm/ui/components/separator'
import { Badge } from '@cfdm/ui/components/badge' import { Badge } from '@cfdm/ui/components/badge'
import { TooltipProvider } from '@cfdm/ui/components/tooltip'
import { Link, useRouterState } from '@tanstack/react-router' import { Link, useRouterState } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
@@ -48,11 +49,8 @@ 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 { AppSwitcher } from '@/components/app-switcher' import { AppSwitcher } from '@/components/app-switcher'
import { GlobalSearch, GlobalSearchTrigger, useGlobalSearchHotkey } from '@/components/global-search' import { GlobalSearch, useGlobalSearchHotkey } from '@/components/global-search'
import { dashboardStatsQueryOptions } from '@/queries/dashboard' import { dashboardStatsQueryOptions } from '@/queries/dashboard'
import { formatRelativeSyncTime } from '@/lib/sync-format'
import { TruncatedText } from '@/components/truncated-text'
import { cn } from '@cfdm/ui/lib/utils'
interface NavItem { interface NavItem {
to: string to: string
@@ -127,11 +125,14 @@ const PARENT_ROUTE: Record<string, string> = {
'/audit': '/settings', '/audit': '/settings',
} }
/** Shared ops chrome — etalon EvoBGP. @see docs/ui-design-contract.md */
export function AppShell({ children }: { children: ReactNode }) { export function AppShell({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname }) const pathname = useRouterState({ select: (s) => s.location.pathname })
const [searchOpen, setSearchOpen] = useState(false) const [searchOpen, setSearchOpen] = useState(false)
useGlobalSearchHotkey(() => setSearchOpen(true)) useGlobalSearchHotkey(() => setSearchOpen(true))
const activeItem = ALL_NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`)) ?? ALL_NAV_ITEMS[0] const activeItem =
ALL_NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`)) ??
ALL_NAV_ITEMS[0]
const parentTo = PARENT_ROUTE[activeItem.to] const parentTo = PARENT_ROUTE[activeItem.to]
const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null
@@ -148,111 +149,94 @@ 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={ >
{ <Sidebar collapsible="icon">
'--sidebar-width': '240px', <SidebarHeader>
} as CSSProperties <AppSwitcher />
} </SidebarHeader>
> <SidebarContent>
<Sidebar collapsible="icon"> {navGroups.map((group) => (
<SidebarHeader> <SidebarGroup key={group.label}>
<AppSwitcher /> <SidebarGroupLabel>{group.label}</SidebarGroupLabel>
</SidebarHeader> <SidebarGroupContent>
<SidebarContent> <SidebarMenu>
{navGroups.map((group) => ( {group.items.map((item) => {
<SidebarGroup key={group.label}> const Icon = item.icon
<SidebarGroupLabel>{group.label}</SidebarGroupLabel> const isActive =
<SidebarGroupContent> pathname === item.to || pathname.startsWith(`${item.to}/`)
<SidebarMenu> return (
{group.items.map((item) => { <SidebarMenuItem key={item.to}>
const Icon = item.icon <SidebarMenuButton
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`) render={<Link to={item.to} />}
return ( isActive={isActive}
<SidebarMenuItem key={item.to}> tooltip={item.label}
<SidebarMenuButton >
render={<Link to={item.to} />} <Icon className="size-4" />
isActive={isActive} <span>{item.label}</span>
tooltip={item.label} {item.badge ? (
> <Badge
<Icon /> variant="destructive"
<span>{item.label}</span> className="ml-auto size-5 justify-center p-0 text-xs"
{item.badge ? ( >
<Badge variant="destructive" className="ml-auto size-5 justify-center p-0 text-xs"> {item.badge}
{item.badge} </Badge>
</Badge> ) : null}
) : null} </SidebarMenuButton>
</SidebarMenuButton> </SidebarMenuItem>
</SidebarMenuItem> )
) })}
})} </SidebarMenu>
</SidebarMenu> </SidebarGroupContent>
</SidebarGroupContent> </SidebarGroup>
</SidebarGroup> ))}
))} </SidebarContent>
</SidebarContent> <SidebarFooter />
<SidebarFooter> </Sidebar>
<SidebarMenu> <SidebarInset>
<SidebarMenuItem> <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">
<SidebarMenuButton render={<Link to="/settings" />} tooltip="Настройки"> <SidebarTrigger className="-ml-1" />
<Settings /> <Separator
<TruncatedText orientation="vertical"
className="text-xs text-muted-foreground" className="mr-2 data-[orientation=vertical]:h-4"
tooltip={`Синк: ${formatRelativeSyncTime(stats?.lastGlobalSyncAt)}`} />
> <Breadcrumb>
Синк: {formatRelativeSyncTime(stats?.lastGlobalSyncAt)} <BreadcrumbList>
</TruncatedText> {parentLabel && parentTo ? (
{stats?.staleSyncAccountCount ? ( <>
<Badge variant="outline" className="ml-auto text-xs"> <BreadcrumbItem className="hidden md:block">
<RefreshCwIcon className="size-3" /> <BreadcrumbLink render={<Link to={parentTo} />}>
{stats.staleSyncAccountCount} {parentLabel}
</Badge> </BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
</>
) : null} ) : null}
</SidebarMenuButton> <BreadcrumbItem>
</SidebarMenuItem> <BreadcrumbPage>
</SidebarMenu> {ROUTE_LABELS[activeItem.to] ?? ''}
</SidebarFooter> </BreadcrumbPage>
</Sidebar> </BreadcrumbItem>
<SidebarInset> </BreadcrumbList>
<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"> </Breadcrumb>
<SidebarTrigger className="-ml-1" /> <div className="ml-auto flex items-center gap-2">
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" /> <AppsMenu />
<Breadcrumb> <SystemMonitorPopover />
<BreadcrumbList> <ModeToggle />
{parentLabel && parentTo ? ( </div>
<> </header>
<BreadcrumbItem className="hidden md:block"> <main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
<BreadcrumbLink render={<Link to={parentTo} />}>{parentLabel}</BreadcrumbLink> {children}
</BreadcrumbItem> </main>
<BreadcrumbSeparator className="hidden md:block" /> </SidebarInset>
</> <GlobalSearch open={searchOpen} onOpenChange={setSearchOpen} />
) : null} </SidebarProvider>
<BreadcrumbItem> </TooltipProvider>
<BreadcrumbPage>{ROUTE_LABELS[activeItem.to] ?? ''}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
<GlobalSearchTrigger onClick={() => setSearchOpen(true)} />
{stats?.issuesCount ? (
<Badge variant="destructive" className="hidden sm:inline-flex">
{stats.issuesCount} проблем
</Badge>
) : null}
<AppsMenu />
<SystemMonitorPopover />
<ModeToggle />
</div>
</header>
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">{children}</main>
</SidebarInset>
<GlobalSearch open={searchOpen} onOpenChange={setSearchOpen} />
</SidebarProvider>
) )
} }
+4 -1
View File
@@ -6,12 +6,15 @@ interface PageHeaderProps {
actions?: ReactNode actions?: ReactNode
} }
/** Page-level section header — etalon EvoBGP. */
export function PageHeader({ title, description, actions }: PageHeaderProps) { export function PageHeader({ title, description, actions }: PageHeaderProps) {
return ( return (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1> <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null} {description ? (
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</div> </div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null} {actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</div> </div>
+11 -7
View File
@@ -49,18 +49,22 @@ Gating: DB preference `showQuickActions` / `show_quick_actions` / `ui_show_quick
## 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
@@ -27,7 +27,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"