feat(web): enhance UI components and improve documentation
Build, Test, and Push CFDM Docker Image / test (push) Failing after 37s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped

- Updated frontend UI patterns documentation to reflect new components and their usage.
- Enhanced CertKpiCards for better KPI visualization and streamlined data handling.
- Refactored OpsDashboard and ResourcePage for improved layout consistency and loading states.
- Introduced CatalogBoardToggle for better navigation between views in the Groups and Services pages.
- Improved Tabs component styles for better responsiveness and accessibility.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-17 00:59:07 +07:00
co-authored by Cursor
parent 777a263f26
commit 6ceb7ff9c7
20 changed files with 1372 additions and 381 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
export { KpiStatGrid, type KpiStatCard, type OpsKpiCard } from './kpi-stat-grid'
export { OpsDashboard, OpsDashboardHintLink } from './ops-dashboard'
export { OpsDashboard } from './ops-dashboard'
export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
@@ -1,20 +1,16 @@
import type { ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import { Frame, FramePanel } from '@/components/reui/frame'
import { cn } from '@cfdm/ui/lib/utils'
import { Item, ItemMedia } from '@cfdm/ui/components/item'
import { Skeleton } from '@cfdm/ui/components/skeleton'
export interface KpiStatCard {
id: string
typeLabel: string
title: string
metricLabel: string
label: string
value: string | number
icon: ReactNode
iconBg?: string
hint?: ReactNode
detail?: ReactNode
badge?: ReactNode
hint?: string
to?: string
search?: Record<string, unknown>
onSelect?: () => void
selected?: boolean
}
@@ -31,75 +27,70 @@ interface KpiStatGridProps {
skeletonCount?: number
}
const ICON_TILE_CLASS =
'border-background flex size-10.5 items-center justify-center border-2 p-0 [background-image:radial-gradient(48.05%_48.05%_at_50%_5.95%,rgba(255,255,255,0.4)_0%,rgba(255,255,255,0)_100%)] shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5 [&_svg]:text-white'
function KpiStatCardBody({ card }: { card: KpiStatCard }) {
return (
<div className="relative z-10 flex flex-col gap-3">
<span className="text-muted-foreground text-sm">{card.label}</span>
<span className="text-foreground text-xl leading-tight font-semibold tabular-nums">
{card.value}
</span>
{card.hint ? (
<span className="text-muted-foreground text-xs leading-snug">{card.hint}</span>
) : null}
</div>
)
}
function KpiStatCardItem({ card }: { card: KpiStatCard }) {
const interactive = Boolean(card.onSelect)
const Panel = interactive ? 'button' : 'div'
const panelClass = cn(
'relative isolate flex h-full flex-col gap-3',
card.selected && 'ring-primary/30 bg-muted/30 ring-1',
)
if (card.to) {
return (
<Link
to={card.to}
search={card.search}
className="hover:bg-muted/40 focus-visible:ring-ring rounded-[calc(var(--frame-radius)-2px)] transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<FramePanel className={panelClass}>
<KpiStatCardBody card={card} />
</FramePanel>
</Link>
)
}
if (card.onSelect) {
return (
<button
type="button"
onClick={card.onSelect}
className="hover:bg-muted/40 focus-visible:ring-ring rounded-[calc(var(--frame-radius)-2px)] text-start transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<FramePanel className={panelClass}>
<KpiStatCardBody card={card} />
</FramePanel>
</button>
)
}
return (
<Panel
type={interactive ? 'button' : undefined}
onClick={card.onSelect}
className={cn(
'text-start',
interactive &&
'hover:bg-muted/40 focus-visible:ring-ring rounded-[calc(var(--frame-radius)-2px)] transition-colors focus-visible:ring-2 focus-visible:outline-none',
card.selected && 'bg-muted/30 ring-primary/30 ring-1',
)}
>
<FramePanel className="flex h-full flex-col items-start gap-4">
<div className="flex w-full items-center gap-2.5">
<Item className={cn('p-0', ICON_TILE_CLASS, card.iconBg ?? 'bg-chart-1')}>
<ItemMedia variant="icon" className="size-auto">
{card.icon}
</ItemMedia>
</Item>
<p className="text-muted-foreground min-w-0 flex-1 text-xs leading-tight">
{card.typeLabel}
</p>
</div>
<div className="space-y-0.5">
<div className="text-foreground text-2xl leading-none font-bold tabular-nums">
{card.value}
</div>
<div className="text-sm leading-tight font-medium">{card.title}</div>
<div className="text-muted-foreground text-xs leading-snug">{card.metricLabel}</div>
</div>
{card.badge || card.hint ? (
<div className="mt-auto flex flex-wrap items-center gap-2">
{card.badge}
{card.hint}
</div>
) : null}
{card.detail ? (
<p className="text-muted-foreground text-xs leading-snug">{card.detail}</p>
) : null}
</FramePanel>
</Panel>
<FramePanel className={panelClass}>
<KpiStatCardBody card={card} />
</FramePanel>
)
}
function KpiStatGridSkeleton({ count }: { count: number }) {
return (
<Frame className="@container w-full">
<div className="grid gap-1 @2xl:grid-cols-2 @5xl:grid-cols-4">
<div className="grid gap-1 @2xl:grid-cols-2 @4xl:grid-cols-4">
{Array.from({ length: count }).map((_, index) => (
<FramePanel key={index} className="flex flex-col gap-4">
<div className="flex items-center gap-2.5">
<Skeleton className="size-10.5 rounded-lg" />
<Skeleton className="h-3 w-20" />
</div>
<div className="space-y-2">
<Skeleton className="h-8 w-16" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-32" />
</div>
<Skeleton className="h-5 w-28" />
<FramePanel key={index} className="flex flex-col gap-3">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-7 w-14" />
<Skeleton className="h-3 w-28" />
</FramePanel>
))}
</div>
@@ -134,7 +125,7 @@ export function KpiStatGrid({
return (
<Frame className={cn('@container w-full', className)}>
<div className="grid gap-1 @2xl:grid-cols-2 @5xl:grid-cols-4">
<div className="grid gap-1 @2xl:grid-cols-2 @4xl:grid-cols-4">
{cards.map((card) => (
<KpiStatCardItem key={card.id} card={card} />
))}
@@ -1,5 +1,4 @@
import { useEffect, useRef, type ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import {
Frame,
FrameDescription,
@@ -7,6 +6,7 @@ import {
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { debugAgentLog } from '@/lib/debug-agent-log'
import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid'
@@ -18,6 +18,24 @@ interface OpsDashboardProps {
kpiCards: OpsKpiCard[]
charts: ReactNode
queue: ReactNode
isLoading?: boolean
}
function OpsDashboardSkeleton() {
return (
<div className="text-foreground @container mx-auto flex w-full max-w-7xl flex-col gap-2 md:gap-3">
<header className="px-1">
<Skeleton className="h-7 w-56" />
<Skeleton className="mt-2 h-4 w-80 max-w-full" />
</header>
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
<div className="grid gap-2 @3xl:grid-cols-2">
<Skeleton className="h-64 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" />
</div>
<Skeleton className="h-48 w-full rounded-xl" />
</div>
)
}
export function OpsDashboard({
@@ -26,10 +44,12 @@ export function OpsDashboard({
kpiCards,
charts,
queue,
isLoading = false,
}: OpsDashboardProps) {
const rootRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (isLoading) return
const el = rootRef.current
if (!el) return
debugAgentLog(
@@ -42,12 +62,16 @@ export function OpsDashboard({
},
'A',
)
}, [])
}, [isLoading])
if (isLoading) {
return <OpsDashboardSkeleton />
}
return (
<div
ref={rootRef}
className="text-foreground @container mx-auto flex w-full max-w-7xl flex-col gap-4 md:gap-6"
className="text-foreground @container mx-auto flex w-full max-w-7xl flex-col gap-2 md:gap-3"
>
<header className="px-1">
<div className="flex flex-col gap-px">
@@ -64,7 +88,7 @@ export function OpsDashboard({
<section
aria-label="Аналитика"
className="grid min-w-0 auto-rows-fr items-start gap-4 @3xl:grid-cols-2"
className="grid min-w-0 auto-rows-fr items-start gap-2 @3xl:grid-cols-2"
>
{charts}
</section>
@@ -83,23 +107,3 @@ export function OpsDashboard({
</div>
)
}
export function OpsDashboardHintLink({
to,
search,
children,
}: {
to: string
search?: Record<string, unknown>
children: ReactNode
}) {
return (
<Link
to={to}
search={search}
className="text-primary text-sm font-medium hover:underline"
>
{children}
</Link>
)
}
@@ -34,7 +34,6 @@ import { Separator } from '@cfdm/ui/components/separator'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
import { cn } from '@cfdm/ui/lib/utils'
import { EmptyState } from '@/components/empty-state'
import { applyFiltersToData } from './filter-utils'
import { debugAgentLog } from '@/lib/debug-agent-log'
@@ -290,24 +289,15 @@ export function ResourcePage<T extends object>({
<FramePanel className="p-0 shadow-none!">
{tabs && tabs.length > 0 ? (
<>
{/* Line tabs как @reui/data-grid-filtering-2: muted count + underline */}
{/* Line tabs: c-tabs-2 + filtering-2 (muted count + underline) */}
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
<Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList
variant="line"
className="h-auto gap-5 bg-transparent p-0"
>
<TabsList variant="line" className="gap-5">
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className={cn(
'h-auto flex-none gap-2 rounded-none bg-transparent px-0 pb-3 text-sm shadow-none',
'text-muted-foreground hover:text-foreground',
'data-active:bg-transparent data-active:text-foreground data-active:shadow-none',
'dark:data-active:border-transparent dark:data-active:bg-transparent',
'after:bottom-0 after:h-0.5',
)}
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3"
>
<span>{tab.label}</span>
<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">