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 { 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 { type MetricCard } from "./data"
|
||||||
import { PanelCorners } from "./panel-heading"
|
import { PanelCorners } from "./panel-heading"
|
||||||
import { toneStyles } from "./tone-styles"
|
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 { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
import {
|
import { DashboardQuickLinkCard } from '@/components/dashboard/dashboard-quick-link-card'
|
||||||
Frame,
|
|
||||||
FrameHeader,
|
|
||||||
FramePanel,
|
|
||||||
} from '@/components/reui/frame'
|
|
||||||
|
|
||||||
type QuickLink = {
|
type QuickLink = {
|
||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
@@ -14,6 +9,7 @@ type QuickLink = {
|
|||||||
description: string
|
description: string
|
||||||
to: string
|
to: string
|
||||||
search?: Record<string, string>
|
search?: Record<string, string>
|
||||||
|
iconClass: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const LINKS: QuickLink[] = [
|
const LINKS: QuickLink[] = [
|
||||||
@@ -22,12 +18,14 @@ const LINKS: QuickLink[] = [
|
|||||||
label: 'Создать модуль',
|
label: 'Создать модуль',
|
||||||
description: 'Новый модуль маршрутизации и источники префиксов.',
|
description: 'Новый модуль маршрутизации и источники префиксов.',
|
||||||
to: '/modules/new',
|
to: '/modules/new',
|
||||||
|
iconClass: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Tags aria-hidden />,
|
icon: <Tags aria-hidden />,
|
||||||
label: 'BGP-сообщества',
|
label: 'BGP-сообщества',
|
||||||
description: 'Справочник communities для политик экспорта.',
|
description: 'Справочник communities для политик экспорта.',
|
||||||
to: '/directories',
|
to: '/directories',
|
||||||
|
iconClass: 'bg-info text-info-foreground [&_svg]:text-info-foreground',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Network aria-hidden />,
|
icon: <Network aria-hidden />,
|
||||||
@@ -35,6 +33,7 @@ const LINKS: QuickLink[] = [
|
|||||||
description: 'Обзор пиров, спикеров и live-сессий BGP.',
|
description: 'Обзор пиров, спикеров и live-сессий BGP.',
|
||||||
to: '/network',
|
to: '/network',
|
||||||
search: { tab: 'overview' },
|
search: { tab: 'overview' },
|
||||||
|
iconClass: 'bg-success text-success-foreground [&_svg]:text-success-foreground',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Share2 aria-hidden />,
|
icon: <Share2 aria-hidden />,
|
||||||
@@ -42,6 +41,7 @@ const LINKS: QuickLink[] = [
|
|||||||
description: 'Настройка BGP-соседа и шаблонов сессии.',
|
description: 'Настройка BGP-соседа и шаблонов сессии.',
|
||||||
to: '/network',
|
to: '/network',
|
||||||
search: { tab: 'peers' },
|
search: { tab: 'peers' },
|
||||||
|
iconClass: 'bg-warning text-warning-foreground [&_svg]:text-warning-foreground',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Play aria-hidden />,
|
icon: <Play aria-hidden />,
|
||||||
@@ -49,6 +49,7 @@ const LINKS: QuickLink[] = [
|
|||||||
description: 'Ревизии конфигурации и применение на нодах.',
|
description: 'Ревизии конфигурации и применение на нодах.',
|
||||||
to: '/operations',
|
to: '/operations',
|
||||||
search: { tab: 'revisions' },
|
search: { tab: 'revisions' },
|
||||||
|
iconClass: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Gauge aria-hidden />,
|
icon: <Gauge aria-hidden />,
|
||||||
@@ -56,32 +57,18 @@ const LINKS: QuickLink[] = [
|
|||||||
description: 'Состояние системы, BIRD и PostgreSQL.',
|
description: 'Состояние системы, BIRD и PostgreSQL.',
|
||||||
to: '/monitoring',
|
to: '/monitoring',
|
||||||
search: { tab: 'system' },
|
search: { tab: 'system' },
|
||||||
|
iconClass: 'bg-destructive text-destructive-foreground [&_svg]:text-destructive-foreground',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function DashboardQuickLinks() {
|
export function DashboardQuickLinks() {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="@container w-full">
|
||||||
{LINKS.map((link) => (
|
<div className="grid gap-3 sm:grid-cols-2 @2xl:grid-cols-3">
|
||||||
<Frame key={link.label} spacing="sm">
|
{LINKS.map((link) => (
|
||||||
<FrameHeader className="px-1! py-1!">
|
<DashboardQuickLinkCard key={link.label} {...link} />
|
||||||
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
|
))}
|
||||||
{link.icon}
|
</div>
|
||||||
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,13 +143,13 @@ function DashboardComponent() {
|
|||||||
</DashboardFramePanel>
|
</DashboardFramePanel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<DashboardFramePanel
|
||||||
<div className="space-y-0.5">
|
title="Быстрые действия"
|
||||||
<h2 className="text-sm font-semibold">Быстрые действия</h2>
|
description="Частые переходы к настройке и деплою"
|
||||||
<p className="text-muted-foreground text-sm">Частые переходы к настройке и деплою</p>
|
contentClassName="p-4"
|
||||||
</div>
|
>
|
||||||
<DashboardQuickLinks />
|
<DashboardQuickLinks />
|
||||||
</section>
|
</DashboardFramePanel>
|
||||||
</div>
|
</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 { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
|||||||
Reference in New Issue
Block a user