Merge branch 'claude/elated-pasteur-5c3133': новый дизайн фронтенда VPS-Tracker
Docker / build (push) Failing after 2m15s
Docker / build (push) Failing after 2m15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
a185ffa659
24 files changed
+629
-487
No files matched your search
@@ -18,6 +18,8 @@
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@fontsource-variable/geist-mono": "^5.3.0",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
@@ -11,8 +12,10 @@ import {
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@cfdm/ui/components/sidebar'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
|
||||
|
||||
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
|
||||
import {
|
||||
APP_SWITCHER_ICONS,
|
||||
CURRENT_APP_ID,
|
||||
@@ -88,6 +91,19 @@ export function AppSwitcher() {
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
{isAuthEnabled() ? (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<a href={`${authPortalUrl().replace(/\/$/, '')}/admin/apps`} />}
|
||||
>
|
||||
Настроить на портале
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem nativeButton={false} render={<Link to="/settings/integrations" />}>
|
||||
Интеграции
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
|
||||
@@ -17,7 +17,7 @@ interface CountedLineTabsProps {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */
|
||||
/** Page line tabs with mono counts (design «VPS-Tracker» ptabs; c-tabs-2). */
|
||||
export function CountedLineTabs({
|
||||
tabs,
|
||||
value,
|
||||
@@ -37,7 +37,7 @@ export function CountedLineTabs({
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
{tab.count !== undefined ? (
|
||||
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
|
||||
<span className="text-muted-foreground font-mono text-xs tabular-nums">
|
||||
{tab.count}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Network,
|
||||
ShieldAlert,
|
||||
Globe,
|
||||
ChevronDownIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
@@ -32,7 +33,13 @@ import {
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from '@cfdm/ui/components/sidebar'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@cfdm/ui/components/collapsible'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -41,8 +48,6 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@cfdm/ui/components/breadcrumb'
|
||||
import { Separator } from '@cfdm/ui/components/separator'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { TooltipProvider } from '@cfdm/ui/components/tooltip'
|
||||
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
@@ -50,7 +55,6 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { useState, type CSSProperties, type ReactNode } from 'react'
|
||||
|
||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||
import { SpaceSwitcher } from '@/components/layout/space-switcher'
|
||||
import { NavUser } from '@/components/layout/nav-user'
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
@@ -61,13 +65,16 @@ import {
|
||||
permissionForPath,
|
||||
} from '@/lib/auth'
|
||||
import { SpaceProvider, useSpaceId } from '@/lib/space'
|
||||
import { PageCrumbContext } from '@/lib/page-crumb'
|
||||
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
interface NavItem {
|
||||
to: string
|
||||
label: string
|
||||
icon: typeof LayoutDashboard
|
||||
badge?: number
|
||||
/** Problem dot (design `pdot`): destructive / warning. */
|
||||
dot?: { tone: 'destructive' | 'warning'; label: string }
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
@@ -150,7 +157,10 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
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 [pageCrumb, setPageCrumb] = useState<string | null>(null)
|
||||
// Detail routes (design: «VPS / 203.0.113.21»): section links back, page shows the record title.
|
||||
const isDetail = pageCrumb != null && pathname !== activeItem.to
|
||||
const parentTo = isDetail ? activeItem.to : PARENT_ROUTE[activeItem.to]
|
||||
const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null
|
||||
|
||||
const { data: stats } = useQuery(dashboardStatsQueryOptions())
|
||||
@@ -162,9 +172,21 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
const perm = permissionForPath(item.to)
|
||||
return !perm || can(perm)
|
||||
})
|
||||
.map((item) => {
|
||||
.map((item): NavItem => {
|
||||
if (item.to === '/dashboard' && stats?.issuesCount) {
|
||||
return { ...item, badge: stats.issuesCount }
|
||||
return {
|
||||
...item,
|
||||
dot: { tone: 'destructive', label: `Проблем в инвентаре: ${stats.issuesCount}` },
|
||||
}
|
||||
}
|
||||
if (item.to === '/accounts' && stats?.lowBalanceAccountCount) {
|
||||
return {
|
||||
...item,
|
||||
dot: {
|
||||
tone: 'warning',
|
||||
label: `Низкий баланс: ${stats.lowBalanceAccountCount}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
return item
|
||||
}),
|
||||
@@ -190,38 +212,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{navGroups.map((group) => (
|
||||
<SidebarGroup key={group.label}>
|
||||
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive =
|
||||
pathname === item.to || pathname.startsWith(`${item.to}/`)
|
||||
return (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
render={<Link to={item.to} />}
|
||||
isActive={isActive}
|
||||
tooltip={item.label}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{item.label}</span>
|
||||
{item.badge ? (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="ml-auto size-5 justify-center p-0 text-xs"
|
||||
>
|
||||
{item.badge}
|
||||
</Badge>
|
||||
) : null}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<NavGroupSection key={group.label} group={group} pathname={pathname} />
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
@@ -231,10 +222,6 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<SidebarInset id="main-content">
|
||||
<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="mr-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{parentLabel && parentTo ? (
|
||||
@@ -249,18 +236,19 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
) : null}
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>
|
||||
{ROUTE_LABELS[activeItem.to] ?? ''}
|
||||
{isDetail ? pageCrumb : (ROUTE_LABELS[activeItem.to] ?? '')}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<AppsMenu />
|
||||
<SystemMonitorPopover />
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
<SpaceScopedMain>{children}</SpaceScopedMain>
|
||||
<PageCrumbContext.Provider value={setPageCrumb}>
|
||||
<SpaceScopedMain>{children}</SpaceScopedMain>
|
||||
</PageCrumbContext.Provider>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
<GlobalSearch open={searchOpen} onOpenChange={setSearchOpen} />
|
||||
@@ -270,6 +258,66 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Collapsible nav group (design `grph`): uppercase label + chevron; always open in icon rail. */
|
||||
function NavGroupSection({ group, pathname }: { group: NavGroup; pathname: string }) {
|
||||
const { state, isMobile } = useSidebar()
|
||||
const [open, setOpen] = useState(true)
|
||||
const iconRail = state === 'collapsed' && !isMobile
|
||||
|
||||
return (
|
||||
<Collapsible open={open || iconRail} onOpenChange={setOpen}>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel
|
||||
render={<CollapsibleTrigger tabIndex={iconRail ? -1 : undefined} />}
|
||||
className="hover:text-sidebar-foreground cursor-pointer uppercase tracking-wider transition-colors"
|
||||
>
|
||||
{group.label}
|
||||
<ChevronDownIcon
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'ml-auto transition-transform motion-reduce:transition-none',
|
||||
!open && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
||||
return (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
render={<Link to={item.to} />}
|
||||
isActive={isActive}
|
||||
tooltip={item.label}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{item.label}</span>
|
||||
{item.dot ? (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={item.dot.label}
|
||||
className={cn(
|
||||
'ml-auto size-2 shrink-0 rounded-full',
|
||||
'group-data-[collapsible=icon]:absolute group-data-[collapsible=icon]:top-1 group-data-[collapsible=icon]:right-1',
|
||||
item.dot.tone === 'destructive' ? 'bg-destructive' : 'bg-warning',
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</SidebarGroup>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
/** Remount route content when space changes so snapshot queries pick new X-Space-Id. */
|
||||
function SpaceScopedMain({ children }: { children: ReactNode }) {
|
||||
const { spaceId } = useSpaceId()
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { LayoutGridIcon } from 'lucide-react'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
|
||||
import {
|
||||
APP_SWITCHER_ICONS,
|
||||
CURRENT_APP_ID,
|
||||
} from '@/lib/app-switcher-config'
|
||||
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
|
||||
|
||||
/** Header apps grid — app-shell-12 AppsMenu, wired to auth-portal App Switcher. */
|
||||
export function AppsMenu() {
|
||||
const { config, isLoading } = useAppSwitcherConfig()
|
||||
const portalAppsUrl = `${authPortalUrl().replace(/\/$/, '')}/admin/apps`
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon" aria-label="Приложения" />
|
||||
}
|
||||
>
|
||||
<LayoutGridIcon
|
||||
className="size-4.5 transition-colors"
|
||||
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}
|
||||
nativeButton={false}
|
||||
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>
|
||||
<DropdownMenuSeparator />
|
||||
{isAuthEnabled() ? (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<a href={portalAppsUrl} />}
|
||||
className="justify-center text-sm font-medium"
|
||||
>
|
||||
Настроить на портале
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<Link to="/settings/integrations" />}
|
||||
className="justify-center text-sm font-medium"
|
||||
>
|
||||
Интеграции
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -141,12 +141,12 @@ export function NavUser() {
|
||||
render={
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
|
||||
className="bg-muted/50 data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
<AvatarFallback className="rounded-lg text-xs">
|
||||
<Avatar className="size-8 rounded-full border">
|
||||
<AvatarFallback className="bg-background text-xs">
|
||||
{fallback}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
@@ -166,8 +166,8 @@ export function NavUser() {
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<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">
|
||||
<Avatar className="size-8 rounded-full border">
|
||||
<AvatarFallback className="bg-background text-xs">
|
||||
{fallback}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@@ -3,12 +3,16 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Wallet,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@cfdm/ui/components/item'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@cfdm/ui/components/popover'
|
||||
@@ -214,42 +218,43 @@ export function SystemMonitorPopover() {
|
||||
],
|
||||
)
|
||||
|
||||
const spiking = metrics.some((m) => m.alert) || !apiOk
|
||||
const errorCount = metrics.filter((m) => m.alert && m.tone === 'destructive').length + (apiOk ? 0 : 1)
|
||||
const warningCount = metrics.filter((m) => m.alert && m.tone === 'warning').length
|
||||
const spiking = errorCount + warningCount > 0
|
||||
const statusLabel = spiking ? 'Внимание' : 'Норма'
|
||||
|
||||
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',
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label={`Монитор VPS Tracker: ${statusLabel.toLowerCase()}, ошибок ${errorCount}, предупреждений ${warningCount}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<Activity
|
||||
aria-hidden
|
||||
className={cn(
|
||||
errorCount > 0 ? 'text-destructive' : warningCount > 0 ? 'text-warning' : 'text-muted-foreground',
|
||||
)}
|
||||
/>
|
||||
<span className="hidden sm:inline">{statusLabel}</span>
|
||||
{errorCount > 0 ? (
|
||||
<Badge variant="destructive-light" size="sm" className="tabular-nums">
|
||||
{errorCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
{warningCount > 0 ? (
|
||||
<Badge variant="warning-light" size="sm" className="tabular-nums">
|
||||
{warningCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
{!spiking ? (
|
||||
<Badge variant="success-light" size="sm">
|
||||
OK
|
||||
</Badge>
|
||||
) : null}
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
|
||||
@@ -290,6 +295,13 @@ export function SystemMonitorPopover() {
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<Link
|
||||
to="/sync-journal"
|
||||
className="border-border text-muted-foreground hover:text-foreground flex items-center justify-between border-t px-3 py-2 text-xs transition-colors"
|
||||
>
|
||||
Журнал синка
|
||||
<ChevronRight aria-hidden className="size-3.5" />
|
||||
</Link>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
@@ -6,12 +6,12 @@ interface PageHeaderProps {
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
/** Page-level section header — etalon EvoBGP. */
|
||||
/** Page header — design «VPS-Tracker»: 20px title + muted sub, actions bottom-aligned right. */
|
||||
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
||||
{description ? (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
) : null}
|
||||
|
||||
@@ -3,8 +3,7 @@ export function kpiCols(count: number): string {
|
||||
if (count <= 1) return 'grid-cols-1'
|
||||
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
||||
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
|
||||
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
|
||||
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
|
||||
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @5xl:grid-cols-4'
|
||||
// Design «VPS-Tracker»: at most 4 KPI columns, wrapping to the next row.
|
||||
return 'grid-cols-1 @xl:grid-cols-2 @5xl:grid-cols-4'
|
||||
}
|
||||
@@ -44,7 +44,8 @@ const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||
|
||||
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
|
||||
default: 'text-foreground',
|
||||
warning: 'text-warning',
|
||||
// text-warning on white fails contrast; readable *-foreground in light theme.
|
||||
warning: 'text-warning-foreground dark:text-warning',
|
||||
destructive: 'text-destructive',
|
||||
}
|
||||
|
||||
@@ -63,11 +64,17 @@ function isSelected(item: KpiStatItem): boolean {
|
||||
return Boolean(item.selected ?? item.active)
|
||||
}
|
||||
|
||||
const HINT_BADGE_VARIANT = {
|
||||
default: 'outline',
|
||||
warning: 'warning-light',
|
||||
destructive: 'destructive-light',
|
||||
} as const
|
||||
|
||||
function resolveFooter(item: KpiStatItem): ReactNode {
|
||||
if (item.footer) return item.footer
|
||||
if (typeof item.hint === 'string') {
|
||||
return (
|
||||
<Badge variant="outline" size="sm">
|
||||
<Badge variant={HINT_BADGE_VARIANT[item.variant ?? 'default']} size="sm">
|
||||
{item.hint}
|
||||
</Badge>
|
||||
)
|
||||
@@ -81,7 +88,7 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||
const valueVariant = item.variant ?? 'default'
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
<div className="relative z-10 flex h-full items-center gap-3">
|
||||
{item.icon ? (
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
@@ -92,18 +99,19 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||
</IconTile>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
||||
{footer ? <div className="shrink-0">{footer}</div> : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-2xl leading-none font-bold tabular-nums',
|
||||
VALUE_VARIANT_CLASS[valueVariant],
|
||||
)}
|
||||
>
|
||||
{item.value}
|
||||
{/* Design «VPS-Tracker» KPI: label, then value + hint Badge on one baseline. */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="text-muted-foreground truncate text-sm font-medium">{item.label}</div>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<span
|
||||
className={cn(
|
||||
'text-2xl leading-none font-semibold tracking-tight tabular-nums',
|
||||
VALUE_VARIANT_CLASS[valueVariant],
|
||||
)}
|
||||
>
|
||||
{item.value}
|
||||
</span>
|
||||
{footer ? <span className="shrink-0">{footer}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid'
|
||||
@@ -16,13 +9,15 @@ interface OpsDashboardProps {
|
||||
title?: string
|
||||
description?: string
|
||||
kpiCards: OpsKpiCard[]
|
||||
charts: ReactNode
|
||||
queue: ReactNode
|
||||
/** Alert between the page header and KPI (design order: title → banner → KPI → tabs). */
|
||||
banner?: ReactNode
|
||||
/** Page body after KPI — design «VPS-Tracker»: line tabs (Проблемы / Последние VPS / Аккаунты). */
|
||||
children: ReactNode
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const rootClassName =
|
||||
'text-foreground @container flex w-full flex-col gap-2 md:gap-3'
|
||||
'text-foreground @container flex w-full flex-col gap-4 md:gap-6'
|
||||
|
||||
function OpsDashboardSkeleton() {
|
||||
return (
|
||||
@@ -45,8 +40,8 @@ export function OpsDashboard({
|
||||
title = 'Панель управления',
|
||||
description = 'Обзор VPS, платежей, тарифов и синхронизации',
|
||||
kpiCards,
|
||||
charts,
|
||||
queue,
|
||||
banner,
|
||||
children,
|
||||
isLoading = false,
|
||||
}: OpsDashboardProps) {
|
||||
if (isLoading) {
|
||||
@@ -57,25 +52,13 @@ export function OpsDashboard({
|
||||
<div className={rootClassName}>
|
||||
<PageHeader title={title} description={description} />
|
||||
|
||||
{banner}
|
||||
|
||||
<section aria-label="Ключевые метрики">
|
||||
<KpiStatGrid cards={kpiCards} skeletonCount={4} />
|
||||
</section>
|
||||
|
||||
<section aria-label="Аналитика" className="grid w-full min-w-0 gap-2">
|
||||
{charts}
|
||||
</section>
|
||||
|
||||
<section aria-label="Требуют внимания">
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Требуют внимания</FrameTitle>
|
||||
<FrameDescription>
|
||||
Проблемы оплаты, истекающие VPS и ошибки синхронизации
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>{queue}</FramePanel>
|
||||
</Frame>
|
||||
</section>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,11 +7,9 @@ import {
|
||||
SettingsIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { Separator } from '@cfdm/ui/components/separator'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
||||
|
||||
export interface SettingsTabConfig {
|
||||
id: string
|
||||
@@ -55,69 +53,42 @@ interface SettingsShellProps {
|
||||
tabs?: SettingsTabConfig[]
|
||||
}
|
||||
|
||||
/** Settings shell — preview https://reui.io/preview/base/settings-16 */
|
||||
/** Settings shell — line tabs on top (design «VPS-Tracker»); sections: https://reui.io/preview/base/settings-16 */
|
||||
export function SettingsShell({
|
||||
title = 'Настройки',
|
||||
description = 'Параметры приложения и связи с другими сервисами',
|
||||
tabs = DEFAULT_TABS,
|
||||
}: SettingsShellProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const active =
|
||||
tabs.find((tab) =>
|
||||
tab.exact ? pathname === tab.to || pathname === `${tab.to}/` : pathname.startsWith(tab.to),
|
||||
) ?? tabs[0]
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
|
||||
<div className="flex flex-col gap-3">
|
||||
<PageHeader title={title} description={description} />
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-5',
|
||||
isMobile ? 'flex-col' : 'flex-row items-start',
|
||||
)}
|
||||
>
|
||||
{tabs.length > 1 ? (
|
||||
<nav
|
||||
aria-label="Разделы настроек"
|
||||
className={cn(
|
||||
'flex gap-1',
|
||||
isMobile
|
||||
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
|
||||
: 'w-44 shrink-0 flex-col',
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.exact
|
||||
? pathname === tab.to || pathname === `${tab.to}/`
|
||||
: pathname.startsWith(tab.to)
|
||||
return (
|
||||
<Link
|
||||
key={tab.id}
|
||||
to={tab.to}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||
isMobile && 'shrink-0',
|
||||
!isMobile && 'w-full',
|
||||
isActive
|
||||
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
|
||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader title={title} description={description} />
|
||||
{/* Design «VPS-Tracker»: settings sections as page line tabs. */}
|
||||
{tabs.length > 1 ? (
|
||||
<Tabs value={active?.id}>
|
||||
<TabsList variant="line" aria-label="Разделы настроек" className="gap-5">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
nativeButton={false}
|
||||
render={<Link to={tab.to} />}
|
||||
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<Outlet />
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createContext, useContext, useEffect } from 'react'
|
||||
|
||||
/** Setter for the current page breadcrumb (detail routes: «VPS / 203.0.113.21»). */
|
||||
export const PageCrumbContext = createContext<(title: string | null) => void>(() => {})
|
||||
|
||||
/** Detail pages call this; AppShell shows `section / title` while the page is mounted. */
|
||||
export function usePageCrumb(title: string | null | undefined) {
|
||||
const setTitle = useContext(PageCrumbContext)
|
||||
useEffect(() => {
|
||||
setTitle(title || null)
|
||||
return () => setTitle(null)
|
||||
}, [setTitle, title])
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { RouterProvider } from '@tanstack/react-router'
|
||||
import { Toaster } from '@cfdm/ui/components/sonner'
|
||||
|
||||
import '@cfdm/ui/globals.css'
|
||||
import '@/styles/theme.css'
|
||||
|
||||
import { queryClient } from '@/lib/queryClient'
|
||||
import { createRouter } from '@/lib/router'
|
||||
|
||||
@@ -31,11 +31,11 @@ import { Route as AuthAuditRouteImport } from './routes/_auth/audit'
|
||||
import { Route as AuthAccountsRouteImport } from './routes/_auth/accounts'
|
||||
import { Route as AuthSettingsRouteRouteImport } from './routes/_auth/settings/route'
|
||||
import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/index'
|
||||
import { Route as AuthVpsVpsIdRouteImport } from './routes/_auth/vps.$vpsId'
|
||||
import { Route as AuthVpsVpsIdRouteImport } from './routes/_auth/vps_.$vpsId'
|
||||
import { Route as AuthSettingsSyncRouteImport } from './routes/_auth/settings/sync'
|
||||
import { Route as AuthSettingsNotificationsRouteImport } from './routes/_auth/settings/notifications'
|
||||
import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations'
|
||||
import { Route as AuthProjectsProjectIdRouteImport } from './routes/_auth/projects.$projectId'
|
||||
import { Route as AuthProjectsProjectIdRouteImport } from './routes/_auth/projects_.$projectId'
|
||||
|
||||
const AuthRoute = AuthRouteImport.update({
|
||||
id: '/_auth',
|
||||
@@ -147,9 +147,9 @@ const AuthSettingsIndexRoute = AuthSettingsIndexRouteImport.update({
|
||||
getParentRoute: () => AuthSettingsRouteRoute,
|
||||
} as any)
|
||||
const AuthVpsVpsIdRoute = AuthVpsVpsIdRouteImport.update({
|
||||
id: '/$vpsId',
|
||||
path: '/$vpsId',
|
||||
getParentRoute: () => AuthVpsRoute,
|
||||
id: '/vps_/$vpsId',
|
||||
path: '/vps/$vpsId',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthSettingsSyncRoute = AuthSettingsSyncRouteImport.update({
|
||||
id: '/sync',
|
||||
@@ -169,9 +169,9 @@ const AuthSettingsIntegrationsRoute =
|
||||
getParentRoute: () => AuthSettingsRouteRoute,
|
||||
} as any)
|
||||
const AuthProjectsProjectIdRoute = AuthProjectsProjectIdRouteImport.update({
|
||||
id: '/$projectId',
|
||||
path: '/$projectId',
|
||||
getParentRoute: () => AuthProjectsRoute,
|
||||
id: '/projects_/$projectId',
|
||||
path: '/projects/$projectId',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
@@ -184,7 +184,7 @@ export interface FileRoutesByFullPath {
|
||||
'/dashboard': typeof AuthDashboardRoute
|
||||
'/geo': typeof AuthGeoRoute
|
||||
'/payments': typeof AuthPaymentsRoute
|
||||
'/projects': typeof AuthProjectsRouteWithChildren
|
||||
'/projects': typeof AuthProjectsRoute
|
||||
'/providers': typeof AuthProvidersRoute
|
||||
'/renewals': typeof AuthRenewalsRoute
|
||||
'/reports': typeof AuthReportsRoute
|
||||
@@ -193,7 +193,7 @@ export interface FileRoutesByFullPath {
|
||||
'/sync-journal': typeof AuthSyncJournalRoute
|
||||
'/tariffs': typeof AuthTariffsRoute
|
||||
'/topology': typeof AuthTopologyRoute
|
||||
'/vps': typeof AuthVpsRouteWithChildren
|
||||
'/vps': typeof AuthVpsRoute
|
||||
'/auth/callback': typeof AuthCallbackRoute
|
||||
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
|
||||
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||
@@ -211,7 +211,7 @@ export interface FileRoutesByTo {
|
||||
'/dashboard': typeof AuthDashboardRoute
|
||||
'/geo': typeof AuthGeoRoute
|
||||
'/payments': typeof AuthPaymentsRoute
|
||||
'/projects': typeof AuthProjectsRouteWithChildren
|
||||
'/projects': typeof AuthProjectsRoute
|
||||
'/providers': typeof AuthProvidersRoute
|
||||
'/renewals': typeof AuthRenewalsRoute
|
||||
'/reports': typeof AuthReportsRoute
|
||||
@@ -220,7 +220,7 @@ export interface FileRoutesByTo {
|
||||
'/sync-journal': typeof AuthSyncJournalRoute
|
||||
'/tariffs': typeof AuthTariffsRoute
|
||||
'/topology': typeof AuthTopologyRoute
|
||||
'/vps': typeof AuthVpsRouteWithChildren
|
||||
'/vps': typeof AuthVpsRoute
|
||||
'/auth/callback': typeof AuthCallbackRoute
|
||||
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
|
||||
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||
@@ -241,7 +241,7 @@ export interface FileRoutesById {
|
||||
'/_auth/dashboard': typeof AuthDashboardRoute
|
||||
'/_auth/geo': typeof AuthGeoRoute
|
||||
'/_auth/payments': typeof AuthPaymentsRoute
|
||||
'/_auth/projects': typeof AuthProjectsRouteWithChildren
|
||||
'/_auth/projects': typeof AuthProjectsRoute
|
||||
'/_auth/providers': typeof AuthProvidersRoute
|
||||
'/_auth/renewals': typeof AuthRenewalsRoute
|
||||
'/_auth/reports': typeof AuthReportsRoute
|
||||
@@ -250,13 +250,13 @@ export interface FileRoutesById {
|
||||
'/_auth/sync-journal': typeof AuthSyncJournalRoute
|
||||
'/_auth/tariffs': typeof AuthTariffsRoute
|
||||
'/_auth/topology': typeof AuthTopologyRoute
|
||||
'/_auth/vps': typeof AuthVpsRouteWithChildren
|
||||
'/_auth/vps': typeof AuthVpsRoute
|
||||
'/auth/callback': typeof AuthCallbackRoute
|
||||
'/_auth/projects/$projectId': typeof AuthProjectsProjectIdRoute
|
||||
'/_auth/projects_/$projectId': typeof AuthProjectsProjectIdRoute
|
||||
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||
'/_auth/settings/notifications': typeof AuthSettingsNotificationsRoute
|
||||
'/_auth/settings/sync': typeof AuthSettingsSyncRoute
|
||||
'/_auth/vps/$vpsId': typeof AuthVpsVpsIdRoute
|
||||
'/_auth/vps_/$vpsId': typeof AuthVpsVpsIdRoute
|
||||
'/_auth/settings/': typeof AuthSettingsIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
@@ -338,11 +338,11 @@ export interface FileRouteTypes {
|
||||
| '/_auth/topology'
|
||||
| '/_auth/vps'
|
||||
| '/auth/callback'
|
||||
| '/_auth/projects/$projectId'
|
||||
| '/_auth/projects_/$projectId'
|
||||
| '/_auth/settings/integrations'
|
||||
| '/_auth/settings/notifications'
|
||||
| '/_auth/settings/sync'
|
||||
| '/_auth/vps/$vpsId'
|
||||
| '/_auth/vps_/$vpsId'
|
||||
| '/_auth/settings/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
@@ -508,12 +508,12 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthSettingsIndexRouteImport
|
||||
parentRoute: typeof AuthSettingsRouteRoute
|
||||
}
|
||||
'/_auth/vps/$vpsId': {
|
||||
id: '/_auth/vps/$vpsId'
|
||||
path: '/$vpsId'
|
||||
'/_auth/vps_/$vpsId': {
|
||||
id: '/_auth/vps_/$vpsId'
|
||||
path: '/vps/$vpsId'
|
||||
fullPath: '/vps/$vpsId'
|
||||
preLoaderRoute: typeof AuthVpsVpsIdRouteImport
|
||||
parentRoute: typeof AuthVpsRoute
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/settings/sync': {
|
||||
id: '/_auth/settings/sync'
|
||||
@@ -536,12 +536,12 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
|
||||
parentRoute: typeof AuthSettingsRouteRoute
|
||||
}
|
||||
'/_auth/projects/$projectId': {
|
||||
id: '/_auth/projects/$projectId'
|
||||
path: '/$projectId'
|
||||
'/_auth/projects_/$projectId': {
|
||||
id: '/_auth/projects_/$projectId'
|
||||
path: '/projects/$projectId'
|
||||
fullPath: '/projects/$projectId'
|
||||
preLoaderRoute: typeof AuthProjectsProjectIdRouteImport
|
||||
parentRoute: typeof AuthProjectsRoute
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -563,29 +563,6 @@ const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
|
||||
const AuthSettingsRouteRouteWithChildren =
|
||||
AuthSettingsRouteRoute._addFileChildren(AuthSettingsRouteRouteChildren)
|
||||
|
||||
interface AuthProjectsRouteChildren {
|
||||
AuthProjectsProjectIdRoute: typeof AuthProjectsProjectIdRoute
|
||||
}
|
||||
|
||||
const AuthProjectsRouteChildren: AuthProjectsRouteChildren = {
|
||||
AuthProjectsProjectIdRoute: AuthProjectsProjectIdRoute,
|
||||
}
|
||||
|
||||
const AuthProjectsRouteWithChildren = AuthProjectsRoute._addFileChildren(
|
||||
AuthProjectsRouteChildren,
|
||||
)
|
||||
|
||||
interface AuthVpsRouteChildren {
|
||||
AuthVpsVpsIdRoute: typeof AuthVpsVpsIdRoute
|
||||
}
|
||||
|
||||
const AuthVpsRouteChildren: AuthVpsRouteChildren = {
|
||||
AuthVpsVpsIdRoute: AuthVpsVpsIdRoute,
|
||||
}
|
||||
|
||||
const AuthVpsRouteWithChildren =
|
||||
AuthVpsRoute._addFileChildren(AuthVpsRouteChildren)
|
||||
|
||||
interface AuthRouteChildren {
|
||||
AuthSettingsRouteRoute: typeof AuthSettingsRouteRouteWithChildren
|
||||
AuthAccountsRoute: typeof AuthAccountsRoute
|
||||
@@ -595,7 +572,7 @@ interface AuthRouteChildren {
|
||||
AuthDashboardRoute: typeof AuthDashboardRoute
|
||||
AuthGeoRoute: typeof AuthGeoRoute
|
||||
AuthPaymentsRoute: typeof AuthPaymentsRoute
|
||||
AuthProjectsRoute: typeof AuthProjectsRouteWithChildren
|
||||
AuthProjectsRoute: typeof AuthProjectsRoute
|
||||
AuthProvidersRoute: typeof AuthProvidersRoute
|
||||
AuthRenewalsRoute: typeof AuthRenewalsRoute
|
||||
AuthReportsRoute: typeof AuthReportsRoute
|
||||
@@ -604,7 +581,9 @@ interface AuthRouteChildren {
|
||||
AuthSyncJournalRoute: typeof AuthSyncJournalRoute
|
||||
AuthTariffsRoute: typeof AuthTariffsRoute
|
||||
AuthTopologyRoute: typeof AuthTopologyRoute
|
||||
AuthVpsRoute: typeof AuthVpsRouteWithChildren
|
||||
AuthVpsRoute: typeof AuthVpsRoute
|
||||
AuthProjectsProjectIdRoute: typeof AuthProjectsProjectIdRoute
|
||||
AuthVpsVpsIdRoute: typeof AuthVpsVpsIdRoute
|
||||
}
|
||||
|
||||
const AuthRouteChildren: AuthRouteChildren = {
|
||||
@@ -616,7 +595,7 @@ const AuthRouteChildren: AuthRouteChildren = {
|
||||
AuthDashboardRoute: AuthDashboardRoute,
|
||||
AuthGeoRoute: AuthGeoRoute,
|
||||
AuthPaymentsRoute: AuthPaymentsRoute,
|
||||
AuthProjectsRoute: AuthProjectsRouteWithChildren,
|
||||
AuthProjectsRoute: AuthProjectsRoute,
|
||||
AuthProvidersRoute: AuthProvidersRoute,
|
||||
AuthRenewalsRoute: AuthRenewalsRoute,
|
||||
AuthReportsRoute: AuthReportsRoute,
|
||||
@@ -625,7 +604,9 @@ const AuthRouteChildren: AuthRouteChildren = {
|
||||
AuthSyncJournalRoute: AuthSyncJournalRoute,
|
||||
AuthTariffsRoute: AuthTariffsRoute,
|
||||
AuthTopologyRoute: AuthTopologyRoute,
|
||||
AuthVpsRoute: AuthVpsRouteWithChildren,
|
||||
AuthVpsRoute: AuthVpsRoute,
|
||||
AuthProjectsProjectIdRoute: AuthProjectsProjectIdRoute,
|
||||
AuthVpsVpsIdRoute: AuthVpsVpsIdRoute,
|
||||
}
|
||||
|
||||
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from '@/components/domain/audit-labels'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
|
||||
const auditSearchSchema = z.object({
|
||||
@@ -197,22 +197,15 @@ function AuditPage() {
|
||||
actions={<AuditViewToggle view={view} onViewChange={setView} />}
|
||||
/>
|
||||
|
||||
<Tabs value={actionFilter} onValueChange={setActionFilter}>
|
||||
<TabsList variant="line" aria-label="Фильтр по действию" className="gap-5">
|
||||
{ACTION_FILTERS.map((option) => (
|
||||
<TabsTrigger
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3"
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
<span className="bg-muted text-muted-foreground rounded-md px-1.5 py-0.5 text-[10px] tabular-nums">
|
||||
{actionCounts[option.value]}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<CountedLineTabs
|
||||
tabs={ACTION_FILTERS.map((option) => ({
|
||||
id: option.value,
|
||||
label: option.label,
|
||||
count: actionCounts[option.value],
|
||||
}))}
|
||||
value={actionFilter}
|
||||
onValueChange={setActionFilter}
|
||||
/>
|
||||
|
||||
<QueryState
|
||||
data={data}
|
||||
|
||||
@@ -29,9 +29,9 @@ import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||
import { KpiStatGridSkeleton, TableSkeleton } from '@/components/skeletons'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
||||
import { TabsContent } from '@cfdm/ui/components/tabs'
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
import { computeInventoryHealth } from '@/lib/inventory-health'
|
||||
import { buildAtRiskAccounts, type AtRiskAccount } from '@/lib/account-health'
|
||||
@@ -47,9 +47,6 @@ import { DashboardInventoryAlert } from '@/components/domain/dashboard-inventory
|
||||
|
||||
import type { Vps } from '@/types/entities'
|
||||
|
||||
const DASHBOARD_TAB_TRIGGER_CLASS =
|
||||
'flex-none rounded-none border-0 border-b-2 border-transparent px-3 pb-2.5 pt-2 shadow-none after:hidden data-active:border-foreground data-active:bg-transparent data-active:shadow-none dark:data-active:border-foreground dark:data-active:bg-transparent'
|
||||
|
||||
const DASHBOARD_QUICK_ACTIONS: QuickActionItem[] = [
|
||||
{
|
||||
id: 'vps',
|
||||
@@ -371,111 +368,92 @@ function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<DashboardInventoryAlert issuesCount={issuesCount} onGoToIssues={handleGoToIssues} />
|
||||
|
||||
<OpsDashboard
|
||||
banner={
|
||||
<DashboardInventoryAlert issuesCount={issuesCount} onGoToIssues={handleGoToIssues} />
|
||||
}
|
||||
title="Дашборд"
|
||||
description="Сводка по VPS, балансам и здоровью инвентаря"
|
||||
kpiCards={statsLoading ? [] : kpiCards}
|
||||
isLoading={statsLoading}
|
||||
charts={
|
||||
<ChartsGrid>
|
||||
<DashboardPaymentsChart
|
||||
payments={snap.payments}
|
||||
settings={snap.settings}
|
||||
ratesData={ratesData}
|
||||
className="h-full"
|
||||
/>
|
||||
<DashboardExpensesChart
|
||||
payments={snap.payments}
|
||||
balanceLedger={snap.balanceLedger ?? []}
|
||||
vps={snap.vps}
|
||||
providers={snap.providers}
|
||||
settings={snap.settings}
|
||||
ratesData={ratesData}
|
||||
className="h-full"
|
||||
/>
|
||||
</ChartsGrid>
|
||||
}
|
||||
queue={
|
||||
<div ref={tabsRef}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex w-full flex-col gap-4"
|
||||
>
|
||||
<TabsList variant="line" className="mb-0 h-auto w-fit gap-1 border-b border-border p-0">
|
||||
<TabsTrigger value="issues" className={cn(DASHBOARD_TAB_TRIGGER_CLASS, 'gap-2')}>
|
||||
Проблемы
|
||||
{issues.length > 0 ? (
|
||||
<Badge variant="secondary" size="sm" radius="full">
|
||||
{issues.length}
|
||||
</Badge>
|
||||
) : null}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="recent" className={DASHBOARD_TAB_TRIGGER_CLASS}>
|
||||
Последние VPS
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="risk" className={cn(DASHBOARD_TAB_TRIGGER_CLASS, 'gap-2')}>
|
||||
Аккаунты
|
||||
{atRisk.length > 0 ? (
|
||||
<Badge variant="outline" size="sm" radius="full">
|
||||
{atRisk.length}
|
||||
</Badge>
|
||||
) : null}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="issues" className="mt-0">
|
||||
<ResourcePage
|
||||
title="Здоровье инвентаря"
|
||||
description="Ставка, оплата, синк, баланс аккаунтов"
|
||||
columns={columnDefFromDataGrid(issueColumns)}
|
||||
data={issues}
|
||||
getRowId={(i) => i.key}
|
||||
emptyTitle="Проблем не найдено"
|
||||
emptyDescription="Критичных проблем в инвентаре не обнаружено"
|
||||
pagination={false}
|
||||
>
|
||||
<div ref={tabsRef} className="scroll-mt-16">
|
||||
<CountedLineTabs
|
||||
tabs={[
|
||||
{ id: 'issues', label: 'Проблемы', count: issues.length || undefined },
|
||||
{ id: 'recent', label: 'Последние VPS' },
|
||||
{ id: 'risk', label: 'Аккаунты', count: atRisk.length || undefined },
|
||||
]}
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex w-full flex-col gap-4 md:gap-6"
|
||||
>
|
||||
<TabsContent value="issues" className="mt-0 flex flex-col gap-4 md:gap-6">
|
||||
{snap.settings?.[0]?.showQuickActions !== false ? (
|
||||
<QuickActionGrid
|
||||
actions={DASHBOARD_QUICK_ACTIONS}
|
||||
description="Частые разделы учёта и аналитики"
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="recent" className="mt-0">
|
||||
<ResourcePage
|
||||
title="Последние VPS"
|
||||
description="Активные серверы"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" render={<Link to="/vps" />}>
|
||||
Все VPS
|
||||
</Button>
|
||||
}
|
||||
columns={columnDefFromDataGrid(vpsColumns)}
|
||||
data={activeVps.slice(0, 8)}
|
||||
getRowId={(v) => v.id}
|
||||
pagination={false}
|
||||
onRowClick={(v) => navigate({ to: '/vps', search: { edit: v.id } })}
|
||||
) : null}
|
||||
<ChartsGrid>
|
||||
<DashboardPaymentsChart
|
||||
payments={snap.payments}
|
||||
settings={snap.settings}
|
||||
ratesData={ratesData}
|
||||
className="h-full"
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="risk" className="mt-0">
|
||||
<ResourcePage
|
||||
title="Аккаунты под риском"
|
||||
description="Низкий баланс или устаревший синк BILLmanager"
|
||||
columns={columnDefFromDataGrid(riskColumns)}
|
||||
data={atRisk}
|
||||
getRowId={(r) => r.id}
|
||||
emptyTitle="Рисков нет"
|
||||
emptyDescription="Балансы и синхронизация в норме"
|
||||
pagination={false}
|
||||
<DashboardExpensesChart
|
||||
payments={snap.payments}
|
||||
balanceLedger={snap.balanceLedger ?? []}
|
||||
vps={snap.vps}
|
||||
providers={snap.providers}
|
||||
settings={snap.settings}
|
||||
ratesData={ratesData}
|
||||
className="h-full"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{snap.settings?.[0]?.showQuickActions !== false ? (
|
||||
<QuickActionGrid
|
||||
actions={DASHBOARD_QUICK_ACTIONS}
|
||||
description="Частые разделы учёта и аналитики"
|
||||
/>
|
||||
) : null}
|
||||
</ChartsGrid>
|
||||
<ResourcePage
|
||||
title="Здоровье инвентаря"
|
||||
description="Ставка, оплата, синк, баланс аккаунтов"
|
||||
columns={columnDefFromDataGrid(issueColumns)}
|
||||
data={issues}
|
||||
getRowId={(i) => i.key}
|
||||
emptyTitle="Проблем не найдено"
|
||||
emptyDescription="Критичных проблем в инвентаре не обнаружено"
|
||||
pagination={false}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="recent" className="mt-0">
|
||||
<ResourcePage
|
||||
title="Последние VPS"
|
||||
description="Активные серверы"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" render={<Link to="/vps" />}>
|
||||
Все VPS
|
||||
</Button>
|
||||
}
|
||||
columns={columnDefFromDataGrid(vpsColumns)}
|
||||
data={activeVps.slice(0, 8)}
|
||||
getRowId={(v) => v.id}
|
||||
pagination={false}
|
||||
onRowClick={(v) => navigate({ to: '/vps', search: { edit: v.id } })}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="risk" className="mt-0">
|
||||
<ResourcePage
|
||||
title="Аккаунты под риском"
|
||||
description="Низкий баланс или устаревший синк BILLmanager"
|
||||
columns={columnDefFromDataGrid(riskColumns)}
|
||||
data={atRisk}
|
||||
getRowId={(r) => r.id}
|
||||
emptyTitle="Рисков нет"
|
||||
emptyDescription="Балансы и синхронизация в норме"
|
||||
pagination={false}
|
||||
/>
|
||||
</TabsContent>
|
||||
</CountedLineTabs>
|
||||
</div>
|
||||
</OpsDashboard>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
|
||||
+3
-1
@@ -1,4 +1,5 @@
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import { usePageCrumb } from '@/lib/page-crumb'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
@@ -51,7 +52,7 @@ import {
|
||||
} from '@/lib/project-analytics'
|
||||
import type { Vps } from '@/types/entities'
|
||||
|
||||
export const Route = createFileRoute('/_auth/projects/$projectId')({
|
||||
export const Route = createFileRoute('/_auth/projects_/$projectId')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||
component: ProjectDetailPage,
|
||||
@@ -73,6 +74,7 @@ function ProjectDetailPage() {
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
|
||||
const project = snapshot ? findProject(snapshot, projectId) : undefined
|
||||
usePageCrumb(project?.name)
|
||||
const projectVps = useMemo(
|
||||
() => (snapshot ? projectVpsList(snapshot, projectId) : []),
|
||||
[snapshot, projectId],
|
||||
@@ -683,7 +683,10 @@ function VpsPage() {
|
||||
{tableSections.map((section) => (
|
||||
<ResourcePage
|
||||
key={section.key}
|
||||
title={section.label ?? undefined}
|
||||
title={section.label ?? 'Инвентарь VPS'}
|
||||
description={
|
||||
section.label ? undefined : 'Поиск по IP, DNS, проекту, назначению, ОС'
|
||||
}
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={section.items}
|
||||
getRowId={(v) => v.id}
|
||||
|
||||
+27
-16
@@ -1,4 +1,5 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { usePageCrumb } from '@/lib/page-crumb'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
@@ -48,7 +49,7 @@ import {
|
||||
import type { Payment, Vps } from '@/types/entities'
|
||||
import { VpsDomainsCell } from '@/components/integrations/vps-domains-cell'
|
||||
|
||||
export const Route = createFileRoute('/_auth/vps/$vpsId')({
|
||||
export const Route = createFileRoute('/_auth/vps_/$vpsId')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||
component: VpsDetailPage,
|
||||
@@ -95,6 +96,7 @@ function VpsDetailPage() {
|
||||
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
|
||||
|
||||
const vps = snapshot?.vps.find((v) => v.id === vpsId)
|
||||
usePageCrumb(vps ? vps.ip || vps.dns || 'VPS' : null)
|
||||
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
|
||||
const account = snapshot?.providerAccounts.find((a) => a.id === vps?.providerAccountId)
|
||||
const provider = vps ? providerById.get(vps.providerId) : undefined
|
||||
@@ -153,19 +155,18 @@ function VpsDetailPage() {
|
||||
title={vps ? (vps.ip || vps.dns || 'VPS') : 'VPS'}
|
||||
description={account ? accountSelectLabel(account, providerById) : undefined}
|
||||
actions={
|
||||
vps ? (
|
||||
<Button variant="outline" render={<Link to="/vps" search={{ edit: vpsId }} />}>
|
||||
Редактировать
|
||||
<>
|
||||
<Button variant="outline" render={<Link to="/vps" />}>
|
||||
<ArrowLeftIcon data-icon="inline-start" />
|
||||
К списку VPS
|
||||
</Button>
|
||||
) : undefined
|
||||
{vps ? (
|
||||
<Button render={<Link to="/vps" search={{ edit: vpsId }} />}>Редактировать</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button variant="ghost" size="sm" className="w-fit" render={<Link to="/vps" />}>
|
||||
<ArrowLeftIcon data-icon="inline-start" />
|
||||
К списку VPS
|
||||
</Button>
|
||||
|
||||
<QueryState
|
||||
data={vps}
|
||||
isLoading={isLoading}
|
||||
@@ -189,12 +190,22 @@ function VpsDetailPage() {
|
||||
}
|
||||
>
|
||||
{(row) => (
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Обзор</TabsTrigger>
|
||||
<TabsTrigger value="finance">Финансы</TabsTrigger>
|
||||
<TabsTrigger value="notes">Заметки</TabsTrigger>
|
||||
<TabsTrigger value="sync">Синк</TabsTrigger>
|
||||
<Tabs defaultValue="overview" className="flex flex-col gap-4 md:gap-6">
|
||||
<TabsList variant="line" className="gap-5">
|
||||
{[
|
||||
['overview', 'Обзор'],
|
||||
['finance', 'Финансы'],
|
||||
['notes', 'Заметки'],
|
||||
['sync', 'Синк'],
|
||||
].map(([value, label]) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
value={value}
|
||||
className="text-muted-foreground hover:text-foreground h-auto px-0 pb-3 after:bottom-0"
|
||||
>
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="flex flex-col gap-4">
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* VPS-Tracker theme layer (design canvas «VPS-Tracker», see docs/ui-migration-plan.md).
|
||||
* Overrides tokens only — @cfdm/ui/globals.css stays shadcn CLI output, ReUI components are not restyled.
|
||||
*/
|
||||
@import '@fontsource-variable/geist';
|
||||
@import '@fontsource-variable/geist-mono';
|
||||
|
||||
:root {
|
||||
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: 'Geist Mono Variable', ui-monospace, SFMono-Regular, monospace;
|
||||
|
||||
--background: oklch(0.973 0.004 247.858);
|
||||
--foreground: oklch(0.141 0.006 264);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.006 264);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.006 264);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.958 0.005 247.858);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.958 0.005 247.858);
|
||||
--muted-foreground: oklch(0.52 0.01 264);
|
||||
--accent: oklch(0.942 0.016 264.376);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--border: oklch(0.904 0.006 264);
|
||||
--input: oklch(0.904 0.006 264);
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.444 0.177 26.899);
|
||||
--success: oklch(0.696 0.17 162.48);
|
||||
--success-foreground: oklch(0.378 0.077 168.94);
|
||||
--warning: oklch(0.795 0.184 86.047);
|
||||
--warning-foreground: oklch(0.421 0.095 57.708);
|
||||
--info: oklch(0.606 0.25 292.717);
|
||||
--info-foreground: oklch(0.283 0.141 291.089);
|
||||
--invert: oklch(0.21 0.006 285.885);
|
||||
--invert-foreground: oklch(0.985 0 0);
|
||||
--chart-1: oklch(0.546 0.215 264.376);
|
||||
--chart-2: oklch(0.527 0.154 150.069);
|
||||
--chart-3: oklch(0.625 0.162 41.5);
|
||||
--chart-4: oklch(0.591 0.224 296.4);
|
||||
|
||||
/* Sidebar stays dark in the light theme. */
|
||||
--sidebar: oklch(0.152 0.01 264.376);
|
||||
--sidebar-foreground: oklch(0.92 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.24 0.012 264.376);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 8%);
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
}
|
||||
|
||||
/* Dark-sidebar scope in light theme: inner text/border/muted tokens follow the sidebar. */
|
||||
:root:not(.dark) :is([data-slot='sidebar-inner'], [data-slot='sidebar'][data-mobile='true']) {
|
||||
--foreground: oklch(0.92 0 0);
|
||||
--muted-foreground: oklch(0.648 0.008 264.376);
|
||||
--border: oklch(1 0 0 / 8%);
|
||||
--muted: oklch(0.24 0.012 264.376);
|
||||
--background: oklch(0.133 0.011 264.376);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--card: oklch(0.191 0.011 264.376);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--accent: oklch(0.24 0.012 264.376);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.133 0.011 264.376);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.191 0.011 264.376);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.191 0.011 264.376);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.252 0.01 264.376);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.252 0.01 264.376);
|
||||
--muted-foreground: oklch(0.648 0.008 264.376);
|
||||
--accent: oklch(0.285 0.022 264.376);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--success: oklch(0.696 0.17 162.48);
|
||||
--success-foreground: oklch(0.596 0.145 163.225);
|
||||
--warning: oklch(0.795 0.184 86.047);
|
||||
--warning-foreground: oklch(0.681 0.162 75.834);
|
||||
--info: oklch(0.606 0.25 292.717);
|
||||
--info-foreground: oklch(0.541 0.281 293.009);
|
||||
--invert: oklch(0.37 0.013 285.805);
|
||||
--invert-foreground: oklch(0.985 0 0);
|
||||
--chart-1: oklch(0.637 0.222 264.376);
|
||||
--chart-2: oklch(0.696 0.17 151.328);
|
||||
--chart-3: oklch(0.713 0.176 47.6);
|
||||
--chart-4: oklch(0.714 0.173 303.9);
|
||||
|
||||
--sidebar: oklch(0.191 0.011 264.376);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.262 0.014 264.376);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
}
|
||||
Reference in new issue
Block a user