Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
538daea0f1 |
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
Reference in New Issue
Block a user