Compare commits

...
1 Commits
Author SHA1 Message Date
Denozordec 538daea0f1 refactor: update dashboard components for improved layout and functionality
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m7s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m21s
Refactored the DashboardQuickLinks component to utilize the new DashboardQuickLinkCard for better organization and presentation. Enhanced the DashboardComponent to integrate quick links within a DashboardFramePanel, improving the overall user experience. Updated the metric-tile component import path for consistency. Removed unnecessary imports from the separator component to streamline the codebase.
2026-07-09 18:49:55 +07:00
12 changed files with 401 additions and 37 deletions
@@ -0,0 +1,99 @@
"use client"
import { useEffect, useRef } from "react"
import { cn } from "@evobgp/ui/lib/utils"
// Deterministic per-dot value so the field reads as noise, not a flat grid.
function grain(i: number, j: number) {
const n = Math.sin(i * 127.1 + j * 311.7) * 43758.5453
return n - Math.floor(n)
}
/**
* Static dot field adapted from auth-3's WaveDots backdrop with the twinkle
* animation removed: a dense grid of dots at a fixed per-dot brightness,
* painted once and again on resize or theme change. The dot color resolves
* from the canvas `color` token so it stays neutral in light and dark.
* customize: GAP (density), DOT (size), PEAK (max brightness).
*/
export function CardDotField({ className }: { className?: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const GAP = 3 // dot spacing in px (tight grid, still distinct dots)
const DOT = 1.5 // dot side in px
const BASE = 0.05 // dim end of a dot
const PEAK = 0.32 // bright end of a dot (kept subtle for a card surface)
const draw = () => {
const rect = canvas.getBoundingClientRect()
if (!rect.width || !rect.height) return
// Resetting width clears the canvas and restores the identity transform,
// so the color probe below reads a raw device pixel.
const dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = Math.round(rect.width * dpr)
canvas.height = Math.round(rect.height * dpr)
// Resolve the token to concrete sRGB by painting + reading it back, so
// oklch never casts a color and dark mode recolors on theme change.
ctx.fillStyle = getComputedStyle(canvas).color || "rgb(115,115,115)"
ctx.fillRect(0, 0, 1, 1)
const px = ctx.getImageData(0, 0, 1, 1).data
const color = `rgb(${px[0]}, ${px[1]}, ${px[2]})`
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, rect.width, rect.height)
ctx.fillStyle = color
const cols = Math.ceil(rect.width / GAP) + 1
const rows = Math.ceil(rect.height / GAP) + 1
for (let i = 0; i < cols; i++) {
const x = i * GAP
for (let j = 0; j < rows; j++) {
const q = grain(i, j)
const amp = 0.7 + 0.6 * grain(j * 2 + 1, i * 2 + 1)
let a = (BASE + (PEAK - BASE) * q * q) * amp
if (a > 1) a = 1
ctx.globalAlpha = a
ctx.fillRect(x, j * GAP, DOT, DOT)
}
}
ctx.globalAlpha = 1
}
draw()
const resizeObserver = new ResizeObserver(() => draw())
resizeObserver.observe(canvas)
// Repaint when the theme class toggles so the resolved color stays correct.
const themeObserver = new MutationObserver(() => draw())
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "style"],
})
return () => {
resizeObserver.disconnect()
themeObserver.disconnect()
}
}, [])
return (
<canvas
ref={canvasRef}
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 h-full w-full",
className
)}
/>
)
}
@@ -0,0 +1,17 @@
import { Frame } from "@/components/reui/frame"
import { CardItem } from "./card-item"
import { CARDS } from "./data"
export function CardGrid() {
return (
<Frame className="@container w-full">
{/* Grid */}
<div className="grid gap-1 @2xl:grid-cols-2">
{CARDS.map((card) => (
<CardItem key={card.title} card={card} />
))}
</div>
</Frame>
)
}
@@ -0,0 +1,44 @@
import { FramePanel } from "@/components/reui/frame"
import { cn } from "@evobgp/ui/lib/utils"
import { Item, ItemMedia } from "@evobgp/ui/components/item"
import { CardDotField } from "./card-dot-field"
import { ICard } from "./data"
import { ChevronRightIcon } from "lucide-react"
export function CardItem({ card }: { card: ICard }) {
return (
<FramePanel className="isolate">
{/* Card */}
<CardDotField className="text-muted-foreground [mask-image:linear-gradient(to_bottom_left,black,transparent_60%)]" />
<div className="relative z-10 space-y-7.5">
<Item
className={cn(
"p-0",
"border-background flex size-11 items-center justify-center border-2 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5 [&_svg]:text-white",
card.iconBg
)}
>
<ItemMedia variant="icon" className="size-auto">
{card.icon}
</ItemMedia>
</Item>
<div className="flex flex-col gap-3">
<span className="block text-sm leading-tight font-medium">
{card.title}
</span>
<p className="text-muted-foreground text-xs leading-relaxed">
{card.description}
</p>
<a
href="#"
className="text-primay inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{card.link}
<ChevronRightIcon aria-hidden="true" className="size-2.5 shrink-0" />
</a>
</div>
</div>
</FramePanel>
)
}
@@ -0,0 +1,53 @@
import { type ReactNode } from "react"
import { ShoppingBagIcon, TrendingUp, BarChart3Icon, Settings2Icon } from "lucide-react"
export interface ICard {
title: string
description: string
link: string
icon: ReactNode
iconBg: string
}
export const CARDS: ICard[] = [
{
title: "Recent Orders Overview",
description:
"Track and review all recent purchases, updates, and status changes in one place.",
link: "View Orders",
icon: (
<ShoppingBagIcon aria-hidden="true" />
),
iconBg: "bg-green-600",
},
{
title: "Active Opportunities Pipeline",
description:
"Monitor ongoing deals, check potential revenue, and update opportunity stages.",
link: "Open Pipeline",
icon: (
<TrendingUp aria-hidden="true" />
),
iconBg: "bg-indigo-600",
},
{
title: "Performance & Sales Reports",
description:
"Analyze weekly and monthly reports to gain deeper insights into performance trends.",
link: "View Reports",
icon: (
<BarChart3Icon aria-hidden="true" />
),
iconBg: "bg-sky-600",
},
{
title: "Integration Settings & Sync",
description:
"Manage connections with third-party tools and ensure data stays in sync.",
link: "Manage Integrations",
icon: (
<Settings2Icon aria-hidden="true" />
),
iconBg: "bg-orange-600",
},
]
@@ -0,0 +1,9 @@
import { CardGrid } from "./components/card-grid"
export function Page() {
return (
<div className="flex min-h-svh w-full max-w-4xl items-center justify-center p-8">
<CardGrid />
</div>
)
}
@@ -2,7 +2,7 @@ import { Badge } from "@/components/reui/badge"
import { Card, CardContent } from "@evobgp/ui/components/card"
import { CardDotField } from "./card-dot-field"
import { CardDotField } from "@/components/dashboard/card-dot-field"
import { type MetricCard } from "./data"
import { PanelCorners } from "./panel-heading"
import { toneStyles } from "./tone-styles"
@@ -0,0 +1,87 @@
'use client'
import { useEffect, useRef } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
function grain(i: number, j: number) {
const n = Math.sin(i * 127.1 + j * 311.7) * 43758.5453
return n - Math.floor(n)
}
/**
* Static dot field adapted from REUI card-17: theme-aware canvas dots,
* repainted on resize and theme change.
*/
export function CardDotField({ className }: { className?: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const GAP = 3
const DOT = 1.5
const BASE = 0.05
const PEAK = 0.32
const draw = () => {
const rect = canvas.getBoundingClientRect()
if (!rect.width || !rect.height) return
const dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = Math.round(rect.width * dpr)
canvas.height = Math.round(rect.height * dpr)
ctx.fillStyle = getComputedStyle(canvas).color || 'rgb(115,115,115)'
ctx.fillRect(0, 0, 1, 1)
const px = ctx.getImageData(0, 0, 1, 1).data
const color = `rgb(${px[0]}, ${px[1]}, ${px[2]})`
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, rect.width, rect.height)
ctx.fillStyle = color
const cols = Math.ceil(rect.width / GAP) + 1
const rows = Math.ceil(rect.height / GAP) + 1
for (let i = 0; i < cols; i++) {
const x = i * GAP
for (let j = 0; j < rows; j++) {
const q = grain(i, j)
const amp = 0.7 + 0.6 * grain(j * 2 + 1, i * 2 + 1)
let a = (BASE + (PEAK - BASE) * q * q) * amp
if (a > 1) a = 1
ctx.globalAlpha = a
ctx.fillRect(x, j * GAP, DOT, DOT)
}
}
ctx.globalAlpha = 1
}
draw()
const resizeObserver = new ResizeObserver(() => draw())
resizeObserver.observe(canvas)
const themeObserver = new MutationObserver(() => draw())
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'style'],
})
return () => {
resizeObserver.disconnect()
themeObserver.disconnect()
}
}, [])
return (
<canvas
ref={canvasRef}
aria-hidden="true"
className={cn('pointer-events-none absolute inset-0 h-full w-full', className)}
/>
)
}
@@ -0,0 +1,70 @@
import { Link } from '@tanstack/react-router'
import { ChevronRight } from 'lucide-react'
import type { ReactNode } from 'react'
import { Frame, FramePanel } from '@/components/reui/frame'
import { CardDotField } from '@/components/dashboard/card-dot-field'
import { cn } from '@evobgp/ui/lib/utils'
import { Item, ItemMedia } from '@evobgp/ui/components/item'
export type QuickLinkCardProps = {
icon: ReactNode
label: string
description: string
to: string
search?: Record<string, string>
iconClass: string
}
export function DashboardQuickLinkCard({
icon,
label,
description,
to,
search,
iconClass,
}: QuickLinkCardProps) {
return (
<Frame className="h-full">
<Link
to={to}
search={search}
className="group block h-full rounded-[inherit] focus-visible:outline-none"
aria-label={`${label}: ${description}`}
>
<FramePanel
className={cn(
'relative isolate h-full overflow-hidden transition-colors',
'hover:border-foreground/20',
'group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-background',
)}
>
<CardDotField className="text-muted-foreground [mask-image:linear-gradient(to_bottom_left,black,transparent_60%)]" />
<div className="relative z-10 flex h-full flex-col gap-7.5">
<Item
className={cn(
'border-background flex size-11 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5',
iconClass,
)}
>
<ItemMedia variant="icon" className="size-auto">
{icon}
</ItemMedia>
</Item>
<div className="mt-auto flex flex-col gap-3">
<span className="text-foreground block text-sm leading-tight font-medium">{label}</span>
<p className="text-muted-foreground text-xs leading-relaxed">{description}</p>
<span className="text-primary inline-flex items-center gap-1 text-xs font-medium underline-offset-2 group-hover:underline">
Перейти
<ChevronRight
aria-hidden
className="size-2.5 shrink-0 transition-transform group-hover:translate-x-0.5"
/>
</span>
</div>
</div>
</FramePanel>
</Link>
</Frame>
)
}
@@ -1,12 +1,7 @@
import { Link } from '@tanstack/react-router'
import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
import type { ReactNode } from 'react'
import {
Frame,
FrameHeader,
FramePanel,
} from '@/components/reui/frame'
import { DashboardQuickLinkCard } from '@/components/dashboard/dashboard-quick-link-card'
type QuickLink = {
icon: ReactNode
@@ -14,6 +9,7 @@ type QuickLink = {
description: string
to: string
search?: Record<string, string>
iconClass: string
}
const LINKS: QuickLink[] = [
@@ -22,12 +18,14 @@ const LINKS: QuickLink[] = [
label: 'Создать модуль',
description: 'Новый модуль маршрутизации и источники префиксов.',
to: '/modules/new',
iconClass: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground',
},
{
icon: <Tags aria-hidden />,
label: 'BGP-сообщества',
description: 'Справочник communities для политик экспорта.',
to: '/directories',
iconClass: 'bg-info text-info-foreground [&_svg]:text-info-foreground',
},
{
icon: <Network aria-hidden />,
@@ -35,6 +33,7 @@ const LINKS: QuickLink[] = [
description: 'Обзор пиров, спикеров и live-сессий BGP.',
to: '/network',
search: { tab: 'overview' },
iconClass: 'bg-success text-success-foreground [&_svg]:text-success-foreground',
},
{
icon: <Share2 aria-hidden />,
@@ -42,6 +41,7 @@ const LINKS: QuickLink[] = [
description: 'Настройка BGP-соседа и шаблонов сессии.',
to: '/network',
search: { tab: 'peers' },
iconClass: 'bg-warning text-warning-foreground [&_svg]:text-warning-foreground',
},
{
icon: <Play aria-hidden />,
@@ -49,6 +49,7 @@ const LINKS: QuickLink[] = [
description: 'Ревизии конфигурации и применение на нодах.',
to: '/operations',
search: { tab: 'revisions' },
iconClass: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground',
},
{
icon: <Gauge aria-hidden />,
@@ -56,32 +57,18 @@ const LINKS: QuickLink[] = [
description: 'Состояние системы, BIRD и PostgreSQL.',
to: '/monitoring',
search: { tab: 'system' },
iconClass: 'bg-destructive text-destructive-foreground [&_svg]:text-destructive-foreground',
},
]
export function DashboardQuickLinks() {
return (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{LINKS.map((link) => (
<Frame key={link.label} spacing="sm">
<FrameHeader className="px-1! py-1!">
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
{link.icon}
<span className="text-foreground text-sm font-medium">{link.label}</span>
</div>
</FrameHeader>
<FramePanel className="space-y-3.5">
<p className="text-muted-foreground text-xs leading-relaxed">{link.description}</p>
<Link
to={link.to}
search={link.search}
className="text-primary inline-flex items-center gap-1 text-xs font-medium underline-offset-2 hover:underline"
>
Перейти
</Link>
</FramePanel>
</Frame>
))}
<div className="@container w-full">
<div className="grid gap-3 sm:grid-cols-2 @2xl:grid-cols-3">
{LINKS.map((link) => (
<DashboardQuickLinkCard key={link.label} {...link} />
))}
</div>
</div>
)
}
+6 -6
View File
@@ -143,13 +143,13 @@ function DashboardComponent() {
</DashboardFramePanel>
</div>
<section className="space-y-3">
<div className="space-y-0.5">
<h2 className="text-sm font-semibold">Быстрые действия</h2>
<p className="text-muted-foreground text-sm">Частые переходы к настройке и деплою</p>
</div>
<DashboardFramePanel
title="Быстрые действия"
description="Частые переходы к настройке и деплою"
contentClassName="p-4"
>
<DashboardQuickLinks />
</section>
</DashboardFramePanel>
</div>
)
}
File diff suppressed because one or more lines are too long
-2
View File
@@ -1,5 +1,3 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@evobgp/ui/lib/utils"