fix(ui): выровнять chrome Frame и убрать Card-оболочки
Docker images / prepare-release (push) Successful in 4s
Docker images / backend-image (push) Failing after 2m36s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 55s
Docker images / publish-release (push) Skipped

Подключить App Switcher, NavUser, OpsPanel и AlertDialog вместо Card-shell.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-17 15:36:56 +07:00
co-authored by Cursor
parent d2de8d3188
commit 5f29cfaf0f
47 changed files with 1972 additions and 860 deletions
+4 -66
View File
@@ -1,18 +1,16 @@
"use client"
import * as React from "react"
import { useTheme } from "@/components/theme-provider"
import { NavMain, type NavGroup } from "@/components/nav-main"
import { SearchIcon } from "lucide-react"
import { AppSwitcher } from "@/components/app-switcher"
import { NavUser } from "@/components/nav-user"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarRail,
} from "@/components/ui/sidebar"
import {
RouterIcon,
LayoutDashboardIcon,
ActivityIcon,
MapIcon,
@@ -34,8 +32,6 @@ import {
CableIcon,
ScanLineIcon,
BellIcon,
SunIcon,
MoonIcon,
ShieldCheckIcon,
BoxIcon,
BadgeCheckIcon,
@@ -47,25 +43,6 @@ import {
mockSidebarBadgesByUrl,
type SidebarCountsDto,
} from "@/lib/sidebar-badges"
import { formatAppVersionLabel, getAppVersion } from "@/lib/app-version"
// ─── Command palette trigger button ──────────────────────────────────────────
function CommandPaletteButton() {
function trigger() {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true }))
}
return (
<button
onClick={trigger}
className="group-data-[collapsible=icon]:hidden mx-2 mb-1 flex items-center gap-2 px-2.5 py-1.5 rounded-md border border-sidebar-border/60 bg-sidebar-accent/30 hover:bg-sidebar-accent text-sidebar-foreground/50 hover:text-sidebar-foreground text-xs transition-colors w-[calc(100%-1rem)]"
>
<SearchIcon className="size-3 shrink-0" />
<span className="flex-1 text-left">Поиск</span>
<span className="font-mono text-[10px] border border-sidebar-border/60 rounded px-1 py-0.5">K</span>
</button>
)
}
type NavItemBase = { title: string; url: string; icon: React.ReactNode }
@@ -127,7 +104,6 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number }
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { resolvedTheme, setTheme } = useTheme()
const { mode, backendUrl } = useDataSource()
const evo = useEvoBGP()
const [mounted, setMounted] = React.useState(false)
@@ -216,55 +192,17 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
}))
}, [mounted, mode, liveCounts, mockBadges, evo.enabled, evo.snapshot])
const isDark = (resolvedTheme ?? "dark") === "dark"
const appVersionLabel = formatAppVersionLabel(getAppVersion())
return (
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<div className="flex items-center gap-2.5 px-2 py-1.5 overflow-hidden">
<div className="flex aspect-square size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<RouterIcon className="size-4" />
</div>
<div className="flex flex-col leading-tight group-data-[collapsible=icon]:hidden">
<span className="font-semibold text-sidebar-foreground text-sm tracking-tight">MikrotikManager</span>
<span className="text-[10px] font-mono text-sidebar-foreground/40">{appVersionLabel}</span>
</div>
</div>
<CommandPaletteButton />
<AppSwitcher />
</SidebarHeader>
<SidebarContent>
<NavMain groups={navGroups} />
</SidebarContent>
<SidebarFooter>
<div className="flex items-center gap-2 px-2 py-2 group-data-[collapsible=icon]:justify-center">
<div className="size-7 rounded-full bg-gradient-to-br from-blue-500 to-violet-600 flex items-center justify-center text-white text-[10px] font-semibold shrink-0">
АК
</div>
<div className="flex flex-col leading-tight min-w-0 flex-1 group-data-[collapsible=icon]:hidden">
<span className="text-[13px] font-medium text-sidebar-foreground truncate">а.коротаев</span>
<span className="text-[11px] text-sidebar-foreground/50 truncate">admin@routerlists.io</span>
</div>
{mounted ? (
<button
type="button"
onClick={() => setTheme(isDark ? "light" : "dark")}
title={isDark ? "Светлая тема" : "Тёмная тема"}
className="shrink-0 size-6 flex items-center justify-center rounded-md text-sidebar-foreground/50 hover:text-sidebar-foreground hover:bg-sidebar-accent transition-colors group-data-[collapsible=icon]:hidden"
>
{isDark
? <SunIcon className="size-3.5" />
: <MoonIcon className="size-3.5" />}
</button>
) : (
<span
className="shrink-0 size-6 group-data-[collapsible=icon]:hidden"
aria-hidden
/>
)}
</div>
<NavUser />
</SidebarFooter>
<SidebarRail />
</Sidebar>
)
}
+97
View File
@@ -0,0 +1,97 @@
"use client"
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
import {
APP_SWITCHER_ICONS,
CURRENT_APP_ID,
getCurrentApp,
} from "@/lib/app-switcher-config"
import { useAppSwitcherConfig } from "@/hooks/use-app-switcher"
/** Sidebar brand switcher — app-shell-12. @see https://reui.io/preview/base/app-shell-12 */
export function AppSwitcher() {
const { isMobile } = useSidebar()
const { config, isLoading } = useAppSwitcherConfig()
const current = getCurrentApp(config)
const CurrentIcon = APP_SWITCHER_ICONS[current.icon] ?? APP_SWITCHER_ICONS.server
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
}
>
<div
className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground"
aria-hidden
>
<CurrentIcon className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{current.name}</span>
{current.subtitle ? (
<span className="truncate text-xs text-muted-foreground">
{current.subtitle}
</span>
) : null}
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="start"
sideOffset={4}
>
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{isLoading ? "Загрузка…" : config.menuLabel}
</div>
{config.apps.map((app) => {
const Icon = APP_SWITCHER_ICONS[app.icon] ?? APP_SWITCHER_ICONS.server
const isCurrent = app.id === CURRENT_APP_ID
if (isCurrent) {
return (
<DropdownMenuItem key={app.id} disabled>
<Icon />
{app.name}
<CheckIcon className="ml-auto size-4" />
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem
key={app.id}
render={<a href={app.url} />}
>
<Icon />
{app.name}
{app.shortcut ? (
<DropdownMenuShortcut>{app.shortcut}</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
+96
View File
@@ -0,0 +1,96 @@
"use client"
import { LayoutGridIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
APP_SWITCHER_ICONS,
CURRENT_APP_ID,
authPortalBaseUrl,
} from "@/lib/app-switcher-config"
import { useAppSwitcherConfig } from "@/hooks/use-app-switcher"
/** Header apps grid — app-shell-12. @see https://reui.io/preview/base/app-shell-12 */
export function AppsMenu() {
const { config, isLoading } = useAppSwitcherConfig()
const portalBase = authPortalBaseUrl()
const portalAppsUrl = portalBase ? `${portalBase}/admin/apps` : null
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" aria-label="Приложения" />
}
>
<LayoutGridIcon className="size-4.5" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
sideOffset={8}
className="w-72"
>
<DropdownMenuGroup>
<DropdownMenuLabel>
{isLoading ? "Загрузка…" : config.menuLabel}
</DropdownMenuLabel>
<div className="grid grid-cols-3 gap-1 p-1">
{config.apps.map((app) => {
const Icon = APP_SWITCHER_ICONS[app.icon] ?? APP_SWITCHER_ICONS.server
const isCurrent = app.id === CURRENT_APP_ID
if (isCurrent) {
return (
<DropdownMenuItem
key={app.id}
disabled
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden="true" />
</span>
<span className="text-xs font-medium">{app.name}</span>
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem
key={app.id}
render={<a href={app.url} />}
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden="true" />
</span>
<span className="text-xs font-medium">{app.name}</span>
</DropdownMenuItem>
)
})}
</div>
{portalAppsUrl ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
render={<a href={portalAppsUrl} />}
className="justify-center text-sm font-medium"
>
Настроить на портале
</DropdownMenuItem>
</>
) : null}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
+9 -15
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react"
import { Flag } from "@/components/flag"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { OpsPanel } from "@/components/ops-panel"
import { StatusBadge } from "@/components/status-badge"
import { cn } from "@/lib/utils"
import type { InternetPathViewModel } from "@/lib/dashboard-internet-path"
@@ -226,15 +226,10 @@ export function InternetPathMapCard({ model }: { model: InternetPathViewModel |
}
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<CardTitle className="text-base">Internet path map</CardTitle>
<p className="text-sm text-muted-foreground mt-0.5 truncate">
Основной и текущий путь трафика HomeRouter Internet
</p>
</div>
<OpsPanel
title="Internet path map"
description="Основной и текущий путь трафика HomeRouter → Internet"
headerRight={
<StatusBadge
status={
model?.pathState === "healthy"
@@ -244,9 +239,9 @@ export function InternetPathMapCard({ model }: { model: InternetPathViewModel |
: "offline"
}
/>
</div>
</CardHeader>
<CardContent className="pt-0">
}
contentClassName="px-5 pb-4"
>
{!model && (
<div className="h-[240px] rounded-md border border-dashed border-border grid place-items-center text-sm text-muted-foreground">
Недостаточно данных для построения маршрута
@@ -417,7 +412,6 @@ export function InternetPathMapCard({ model }: { model: InternetPathViewModel |
)}
</div>
)}
</CardContent>
</Card>
</OpsPanel>
)
}
+7 -4
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react"
import { Card } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { DATA_PAGE_CARD_CLASS } from "@/components/data-grids/shared/data-grid-layout"
import { cn } from "@/lib/utils"
@@ -8,11 +8,14 @@ interface DataPageCardProps {
className?: string
}
/** Ops list shell — ReUI Frame, not shadcn Card. Preview: https://reui.io/docs/components/base/frame */
function DataPageCard({ children, className }: DataPageCardProps) {
return (
<Card className={cn(DATA_PAGE_CARD_CLASS, className)}>
{children}
</Card>
<Frame dense className={cn("w-full", className)}>
<FramePanel className={cn(DATA_PAGE_CARD_CLASS, "p-0")}>
{children}
</FramePanel>
</Frame>
)
}
+18 -7
View File
@@ -6,25 +6,36 @@ import {
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
import { IconStack } from "@/components/reui/icon-stack"
import { InboxIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import type { ReactNode } from "react"
interface EmptyStateProps {
icon?: React.ReactNode
icon?: ReactNode
title: string
description?: string
action?: React.ReactNode
action?: ReactNode
className?: string
}
/**
* ReUI Empty + IconStack — Frame-friendly empty-state-14/12.
* Preview: https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12
*/
function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
return (
<Empty className={cn("border-0 py-16", className)}>
<EmptyHeader>
{icon && <EmptyMedia variant="icon">{icon}</EmptyMedia>}
<Empty className={cn("border-0 bg-transparent py-16", className)}>
<EmptyHeader className="gap-5">
<EmptyMedia className="mb-0">
<IconStack aria-hidden="true" className="h-14 w-12">
{icon ?? <InboxIcon strokeWidth={1.9} aria-hidden="true" className="size-5" />}
</IconStack>
</EmptyMedia>
<EmptyTitle>{title}</EmptyTitle>
{description && <EmptyDescription>{description}</EmptyDescription>}
{description ? <EmptyDescription>{description}</EmptyDescription> : null}
</EmptyHeader>
{action && <EmptyContent>{action}</EmptyContent>}
{action ? <EmptyContent>{action}</EmptyContent> : null}
</Empty>
)
}
+112 -61
View File
@@ -1,10 +1,20 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useTheme } from "@/components/theme-provider"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
ChevronsUpDownIcon,
MonitorIcon,
MoonIcon,
PaletteIcon,
SettingsIcon,
SunIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
@@ -20,91 +30,132 @@ import {
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
import {
ChevronsUpDownIcon,
SparklesIcon,
BadgeCheckIcon,
CreditCardIcon,
BellIcon,
LogOutIcon,
} from "lucide-react"
export function NavUser({
user,
}: {
user: {
name: string
email: string
avatar: string
}
}) {
/** Sidebar footer account menu — app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */
const THEMES = [
{
value: "light" as const,
label: "Светлая",
icon: <SunIcon className="size-3.5" aria-hidden />,
},
{
value: "dark" as const,
label: "Тёмная",
icon: <MoonIcon className="size-3.5" aria-hidden />,
},
{
value: "system" as const,
label: "Системная",
icon: <MonitorIcon className="size-3.5" aria-hidden />,
},
]
function ThemeSegmentedToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const currentTheme = mounted ? (theme ?? "system") : "system"
return (
<div
role="radiogroup"
aria-label="Тема"
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{THEMES.map(({ value, label, icon }) => {
const isActive = currentTheme === value
return (
<Button
key={value}
type="button"
role="radio"
aria-checked={isActive}
aria-label={label}
variant="ghost"
size="icon-xs"
onClick={() => setTheme(value)}
className={cn(
"rounded-full",
isActive
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{icon}
</Button>
)
})}
</div>
)
}
export function NavUser() {
const { isMobile } = useSidebar()
const name = "Оператор"
const email = "локальный доступ"
const fallback = "ОП"
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
<SidebarMenuButton
size="lg"
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
/>
}
>
<Avatar>
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback>CN</AvatarFallback>
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">{fallback}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">{email}</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-56 rounded-lg"
className="w-(--anchor-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar>
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback>CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
</div>
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">{fallback}</AvatarFallback>
</Avatar>
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">{email}</span>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<SparklesIcon />
Upgrade to Pro
<DropdownMenuItem render={<Link href="/settings" />}>
<SettingsIcon aria-hidden />
Настройки
</DropdownMenuItem>
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<PaletteIcon aria-hidden />
Тема
<div className="ml-auto">
<ThemeSegmentedToggle />
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<BadgeCheckIcon />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCardIcon />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<BellIcon />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOutIcon />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
+50
View File
@@ -0,0 +1,50 @@
import type { ReactNode } from "react"
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import { cn } from "@/lib/utils"
/**
* Ops section shell — ReUI Frame (settings-3 / chart-15 DNA).
* Preview: https://reui.io/preview/base/settings-3 · https://reui.io/docs/components/base/frame
*/
export function OpsPanel({
title,
description,
headerRight,
children,
className,
contentClassName,
id,
}: {
title?: ReactNode
description?: ReactNode
headerRight?: ReactNode
children: ReactNode
className?: string
contentClassName?: string
id?: string
}) {
return (
<Frame dense id={id} className={cn("w-full", className)}>
<FramePanel className="flex flex-col gap-0 p-0">
{title || description || headerRight ? (
<FrameHeader className="gap-1 border-b px-5 py-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
{title ? <FrameTitle className="text-base">{title}</FrameTitle> : null}
{description ? <FrameDescription>{description}</FrameDescription> : null}
</div>
{headerRight ? <div className="shrink-0">{headerRight}</div> : null}
</div>
</FrameHeader>
) : null}
<div className={contentClassName}>{children}</div>
</FramePanel>
</Frame>
)
}
+14 -4
View File
@@ -1,3 +1,6 @@
"use client"
import type { ReactNode } from "react"
import { Separator } from "@/components/ui/separator"
import { SidebarTrigger } from "@/components/ui/sidebar"
import {
@@ -8,6 +11,8 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import { AppsMenu } from "@/components/apps-menu"
import { SystemMonitorPopover } from "@/components/system-monitor-popover"
interface Crumb {
label: string
@@ -16,14 +21,15 @@ interface Crumb {
interface PageHeaderProps {
crumbs: Crumb[]
actions?: React.ReactNode
actions?: ReactNode
}
/** Header chrome — AppsMenu + SystemMonitor; theme in NavUser. @see https://reui.io/preview/base/app-shell-12 */
export function PageHeader({ crumbs, actions }: PageHeaderProps) {
return (
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4 bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<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">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="h-4" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb>
<BreadcrumbList>
{crumbs.map((crumb, i) => (
@@ -42,7 +48,11 @@ export function PageHeader({ crumbs, actions }: PageHeaderProps) {
))}
</BreadcrumbList>
</Breadcrumb>
{actions && <div className="ml-auto flex items-center gap-2">{actions}</div>}
<div className="ml-auto flex items-center gap-2">
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
<AppsMenu />
<SystemMonitorPopover />
</div>
</header>
)
}
+5 -5
View File
@@ -1,5 +1,5 @@
import { PageHeader } from "@/components/page-header"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import type { LucideIcon } from "lucide-react"
interface PlaceholderPageProps {
@@ -19,15 +19,15 @@ export function PlaceholderPage({ section, title, description, Icon }: Placehold
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<p className="text-sm text-muted-foreground mt-1">{description}</p>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-20 gap-3 text-center">
<Frame dense className="w-full">
<FramePanel className="flex flex-col items-center justify-center py-20 gap-3 text-center">
<Icon className="size-10 text-muted-foreground/30" />
<h3 className="text-base font-medium">Скоро</h3>
<p className="text-sm text-muted-foreground max-w-xs">
Этот раздел будет подключён следующим. Слой данных и эндпоинты бэкенда уже готовы.
</p>
</CardContent>
</Card>
</FramePanel>
</Frame>
</div>
</div>
</div>
+90
View File
@@ -0,0 +1,90 @@
import { cn } from "@/lib/utils"
type IconStackProps = React.ComponentProps<"div">
function IconStack({ className, children, style, ...props }: IconStackProps) {
return (
<div
data-slot="icon-stack"
className={cn(
"text-foreground **:data-[slot=icon-stack-layer]:fill-background relative h-20 w-18",
className
)}
style={
{
"--icon-stack-content-x": "71%",
"--icon-stack-content-y": "58%",
...style,
} as React.CSSProperties
}
{...props}
>
<svg
aria-hidden="true"
viewBox="0 0 72 81"
fill="none"
className="h-full w-full overflow-visible"
>
<ellipse
cx="36"
cy="76"
rx="30"
ry="7"
fill="currentColor"
fillOpacity="0.055"
className="blur-[4px]"
/>
<IconStackLayer opacity="0.4" />
<IconStackLayer opacity="0.6" x={13.65} y={6.04} />
<IconStackLayer opacity="0.8" x={27.32} y={12.08} active />
</svg>
{children ? (
<div
data-slot="icon-stack-content"
className="text-muted-foreground pointer-events-none absolute top-[var(--icon-stack-content-y)] left-[var(--icon-stack-content-x)] flex -translate-x-1/2 -translate-y-1/2 scale-x-90 -skew-y-26 items-center justify-center"
>
{children}
</div>
) : null}
</div>
)
}
function IconStackLayer({
active = false,
opacity,
x = 0,
y = 0,
}: {
active?: boolean
opacity: string
x?: number
y?: number
}) {
return (
<g opacity={opacity} transform={`translate(${x} ${y})`}>
<path
data-slot="icon-stack-layer"
d="M42.2538 2.046C41.4408 1.6325 40.3965 1.6677 39.2612 2.2424L7.9616 18.1934C5.3895 19.5039 3.301 23.1064 3.301 26.2322V64.3226C3.301 66.0677 3.9458 67.2943 4.962 67.8199L1.8363 66.229C0.8201 65.7104 0.1753 64.4771 0.1753 62.732V24.6412C0.1753 21.5085 2.2638 17.913 4.8359 16.6024L36.1355 0.6515C37.2778 0.0698 38.322 0.0416 39.128 0.4551L42.2538 2.046Z"
stroke="currentColor"
strokeOpacity={active ? "0.3" : "0.2"}
strokeWidth="0.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
data-slot="icon-stack-layer"
d="M42.2545 2.0456C43.2707 2.5643 43.9155 3.7979 43.9155 5.543V43.6337C43.9155 46.7665 41.827 50.3616 39.2549 51.6722L7.9554 67.6235C6.813 68.2052 5.7687 68.2331 4.9628 67.8196C3.9465 67.301 3.3018 66.0673 3.3018 64.3222V26.2318C3.3018 23.0991 5.3903 19.5036 7.9624 18.193L39.2619 2.2421C40.4043 1.6604 41.4486 1.6321 42.2545 2.0456Z"
stroke="currentColor"
strokeOpacity={active ? "0.3" : "0.2"}
strokeWidth="0.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
)
}
export { IconStack, type IconStackProps }
+125
View File
@@ -0,0 +1,125 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
/**
* CSS variable architecture:
*
* The root owns four variables so every part of the tile stays in proportion
* and stays overridable from a single `className`:
*
* --icon-tile-size tile width/height
* --icon-tile-icon-size glyph size applied to child svgs
* --icon-tile-radius corner radius (also drives the nested inner card)
* --icon-tile-inset gap between the outer ring and the inner card
*
* The `frame` and `soft` variants paint their inner card with an `::after`
* pseudo element instead of a wrapper node. `isolate` makes the root a stacking
* context, so the negative z-index pseudo paints above the root background but
* below the in-flow icon - no extra DOM, and `render` composition keeps working.
*
* Tone: `soft` and `solid` derive every fill and border from `currentColor`, so
* a single text color class (e.g. `text-success`) retints the whole tile. They
* default to `text-primary`; override it to recolor without touching internals.
*/
const iconTileVariants = cva(
[
"relative inline-flex shrink-0 items-center justify-center align-middle",
"size-(--icon-tile-size) rounded-(--icon-tile-radius)",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-(--icon-tile-icon-size)",
],
{
variants: {
variant: {
/** Plain bordered surface. The quiet default for list rows and toolbars. */
outline: "border border-border bg-background dark:bg-input/32",
/** Raised muted fill with a background-colored ring. Reads as a physical chip. */
elevated:
"border-2 border-background bg-muted text-accent-foreground shadow-[0_1px_3px_0_rgb(0_0_0/0.14)] dark:border",
/**
* Tinted double container: an opacity-filled outer ring with no border
* around a bordered inner card, all derived from `currentColor`. The
* quiet, colorful sibling of `frame`. Retint with a text color class.
*/
soft: [
"isolate p-(--icon-tile-inset) text-primary bg-current/10",
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
"after:border after:border-current/20 after:bg-current/5",
],
/** Filled tone with a contrasting glyph. Retint with `bg-*` + a text color. */
solid: "bg-primary text-primary-foreground",
/** Double container - a muted ring around an inset card, matching Frame. */
frame: [
"isolate border border-border bg-muted/50 p-(--icon-tile-inset)",
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
"after:border after:border-border after:bg-card after:shadow-xs",
],
},
size: {
xs: "[--icon-tile-size:--spacing(6)] [--icon-tile-icon-size:--spacing(3.5)] [--icon-tile-inset:--spacing(0.5)]",
sm: "[--icon-tile-size:--spacing(8)] [--icon-tile-icon-size:--spacing(4)] [--icon-tile-inset:--spacing(0.5)]",
default:
"[--icon-tile-size:--spacing(10)] [--icon-tile-icon-size:--spacing(4.5)] [--icon-tile-inset:--spacing(0.75)]",
lg: "[--icon-tile-size:--spacing(12)] [--icon-tile-icon-size:--spacing(5.5)] [--icon-tile-inset:--spacing(0.75)]",
xl: "[--icon-tile-size:--spacing(14)] [--icon-tile-icon-size:--spacing(7)] [--icon-tile-inset:--spacing(1)]",
},
/**
* `default`: active style radius. `full`: circular.
* The plain value is the fallback outside a style scope; the `style-*`
* tokens win by specificity inside one, and survive the registry
* transform as the single resolved value per generated style.
*
* Each style token is clamped to a fraction of the tile. A flat radius
* is a circle once it reaches half the box, so the soft styles used to
* render `xs` (24px) and `sm` (32px) as plain circles and swallow the
* `full` variant's meaning. Clamping keeps one corner ratio at every
* size instead, so a tile still reads as the same shape when it scales.
* Luma and Rhea take the gentler quarter ratio; Maia stays at a third.
*/
radius: {
default:
"[--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))] [--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))]",
full: "[--icon-tile-radius:calc(infinity*1px)]",
},
},
defaultVariants: {
variant: "outline",
size: "default",
radius: "default",
},
}
)
interface IconTileProps extends useRender.ComponentProps<"span"> {
variant?: VariantProps<typeof iconTileVariants>["variant"]
size?: VariantProps<typeof iconTileVariants>["size"]
radius?: VariantProps<typeof iconTileVariants>["radius"]
}
function IconTile({
className,
variant = "outline",
size = "default",
radius = "default",
render,
...props
}: IconTileProps) {
const defaultProps = {
"data-slot": "icon-tile",
"data-variant": variant,
"data-size": size,
className: cn(iconTileVariants({ variant, size, radius, className })),
}
return useRender({
defaultTagName: "span",
render,
props: mergeProps<"span">(defaultProps, props),
})
}
export { IconTile, iconTileVariants, type IconTileProps }
+252
View File
@@ -0,0 +1,252 @@
"use client"
import { useEffect, useMemo, useState, type ReactNode } from "react"
import { Activity, Filter, HeartPulse, Server } from "lucide-react"
import { Badge } from "@/components/reui/badge"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source"
import { filters, pingProbes, servers } from "@/lib/data"
import type { SidebarCountsDto } from "@/lib/sidebar-badges"
type MonitorMetric = {
id: string
label: string
value: string
unit: string
percent: number
icon: ReactNode
tone: "success" | "warning" | "destructive" | "info"
alert: boolean
}
type HealthDto = {
status?: string
}
function toneColor(tone: MonitorMetric["tone"]) {
switch (tone) {
case "success":
return "var(--color-success)"
case "warning":
return "var(--color-warning)"
case "destructive":
return "var(--color-destructive)"
default:
return "var(--color-info)"
}
}
function MetricBar({ percent, color }: { percent: number; color: string }) {
return (
<div className="bg-muted h-1 w-full overflow-hidden rounded-full">
<div
className="h-full rounded-full"
style={{
width: `${Math.min(100, Math.max(0, percent))}%`,
backgroundColor: color,
}}
/>
</div>
)
}
function MetricCell({ metric }: { metric: MonitorMetric }) {
const color = toneColor(metric.tone)
return (
<div className="flex flex-col gap-2 p-3">
<div className="flex items-center justify-between gap-1">
<div className="flex min-w-0 items-center gap-1.5">
<div
className="flex size-5 shrink-0 items-center justify-center rounded-md"
style={{ backgroundColor: `${color}18`, color }}
>
{metric.icon}
</div>
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
</div>
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
{metric.value}
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
</span>
</div>
<MetricBar percent={metric.percent} color={color} />
</div>
)
}
/** Live system monitor popover — app-shell-7. @see https://reui.io/preview/base/app-shell-7 */
export function SystemMonitorPopover() {
const { mode, backendUrl } = useDataSource()
const [healthOk, setHealthOk] = useState<boolean | null>(null)
const [counts, setCounts] = useState<SidebarCountsDto | null>(null)
useEffect(() => {
if (mode !== "live") {
setHealthOk(true)
setCounts({
servers: servers.length,
filterRules: filters.length,
uptimeProbes: pingProbes.length,
uptimeSpeedProbes: 0,
monitoringItems: pingProbes.length,
recursiveRoutes: 0,
})
return
}
let cancelled = false
const load = async () => {
const base = backendUrl.replace(/\/$/, "")
try {
const [hRes, cRes] = await Promise.all([
fetch(`${base}/health`),
fetch(`${base}/api/sidebar-counts`),
])
if (cancelled) return
if (hRes.ok) {
const json = (await hRes.json()) as HealthDto
setHealthOk(json.status === "ok")
} else {
setHealthOk(false)
}
if (cRes.ok) {
setCounts((await cRes.json()) as SidebarCountsDto)
} else {
setCounts(null)
}
} catch {
if (!cancelled) {
setHealthOk(false)
setCounts(null)
}
}
}
void load()
const id = window.setInterval(load, 30_000)
return () => {
cancelled = true
window.clearInterval(id)
}
}, [mode, backendUrl])
const serversCount = counts?.servers ?? 0
const filtersCount = counts?.filterRules ?? 0
const monitoringCount = counts?.monitoringItems ?? 0
const apiOk = healthOk === true
const metrics = useMemo<MonitorMetric[]>(
() => [
{
id: "api",
label: "API",
value: healthOk == null ? "…" : apiOk ? "OK" : "—",
unit: "",
percent: apiOk ? 100 : 20,
icon: <Activity className="size-3" aria-hidden />,
tone: apiOk ? "success" : "destructive",
alert: healthOk === false,
},
{
id: "servers",
label: "Серверы",
value: String(serversCount),
unit: "шт.",
percent: Math.min(100, Math.max(12, serversCount * 8)),
icon: <Server className="size-3" aria-hidden />,
tone: serversCount > 0 ? "success" : "warning",
alert: false,
},
{
id: "filters",
label: "Фильтры",
value: String(filtersCount),
unit: "шт.",
percent: Math.min(100, Math.max(12, filtersCount * 5)),
icon: <Filter className="size-3" aria-hidden />,
tone: "info",
alert: false,
},
{
id: "uptime",
label: "Мониторинг",
value: String(monitoringCount),
unit: "шт.",
percent: Math.min(100, Math.max(12, monitoringCount * 8)),
icon: <HeartPulse className="size-3" aria-hidden />,
tone: monitoringCount > 0 ? "success" : "warning",
alert: false,
},
],
[apiOk, filtersCount, healthOk, monitoringCount, serversCount],
)
const spiking = metrics.some((m) => m.alert)
return (
<Popover>
<PopoverTrigger
render={
<button
type="button"
aria-label="Монитор системы"
className={cn(
"relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none",
"border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring",
)}
/>
}
>
<span className="relative flex size-3.5 items-center justify-center">
<Activity
aria-hidden
className={cn(
"size-3.5 transition-colors",
spiking ? "text-destructive" : "text-muted-foreground",
)}
/>
{spiking ? (
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
) : null}
</span>
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
<Badge
variant={spiking ? "destructive-light" : "success-light"}
size="xs"
className="h-4 px-1.5 text-[10px]"
>
{spiking ? "Внимание" : "Норма"}
</Badge>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
<span className="text-foreground text-xs font-medium">Монитор MikrotikManager</span>
<span className="text-muted-foreground text-[11px] tabular-nums">
{new Date().toLocaleTimeString("ru-RU")}
</span>
</div>
<div className="grid grid-cols-2">
{metrics.map((metric, i) => (
<div
key={metric.id}
className={cn(
i % 2 === 1 && "border-border border-l",
i >= 2 && "border-border border-t",
)}
>
<MetricCell metric={metric} />
</div>
))}
</div>
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
Источник:{" "}
<span className="text-foreground font-medium">
{mode === "live" ? "API" : "мок"}
</span>
</div>
</PopoverContent>
</Popover>
)
}
+187
View File
@@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
+2 -2
View File
@@ -70,8 +70,8 @@ function SheetContent({
/>
}
>
<XIcon />
<span className="sr-only">Close</span>
<XIcon aria-hidden="true" />
<span className="sr-only">Закрыть</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
+11 -8
View File
@@ -27,7 +27,7 @@ import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
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_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
@@ -189,8 +189,8 @@ function Sidebar({
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
<SheetTitle>Боковое меню</SheetTitle>
<SheetDescription>Навигация по приложению</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
@@ -247,7 +247,7 @@ function SidebarTrigger({
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
const { toggleSidebar, open, openMobile, isMobile } = useSidebar()
return (
<Button
@@ -255,6 +255,9 @@ function SidebarTrigger({
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
type="button"
aria-label="Боковое меню"
aria-expanded={isMobile ? openMobile : open}
className={cn(className)}
onClick={(event) => {
onClick?.(event)
@@ -262,8 +265,8 @@ function SidebarTrigger({
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
<PanelLeftIcon aria-hidden="true" />
<span className="sr-only">Боковое меню</span>
</Button>
)
}
@@ -275,10 +278,10 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
aria-label="Боковое меню"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
title="Боковое меню"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",