feat(web): улучшить компоненты PageHeader и AppShell, добавить TooltipProvider
Docker / build (push) Failing after 18s
Docker / build (push) Failing after 18s
- Обновлены стили компонента PageHeader для более компактного отображения описания. - Внедрён TooltipProvider в AppShell для улучшения взаимодействия с пользователем. - Изменён размер боковой панели на 240px для согласованности с дизайном. - Обновлена документация интерфейса для отражения изменений в компонентах.
This commit is contained in:
@@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user