Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1152bb40f7 | ||
|
|
538daea0f1 |
@@ -0,0 +1,11 @@
|
|||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605101206}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605101204}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605138640}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605138642}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605143758}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605144457}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605145907}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605152669}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605152671}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605222064}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605222067}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ComponentProps, ReactNode } from 'react'
|
import type { ComponentProps, ReactNode } from 'react'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
@@ -35,34 +36,67 @@ export function BadgeTabs({
|
|||||||
listClassName,
|
listClassName,
|
||||||
contentClassName,
|
contentClassName,
|
||||||
}: BadgeTabsProps) {
|
}: BadgeTabsProps) {
|
||||||
|
const tabsRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = tabsRef.current?.querySelector('[data-slot=tabs]')
|
||||||
|
if (!el || !(el instanceof HTMLElement)) return
|
||||||
|
const flexDirection = getComputedStyle(el).flexDirection
|
||||||
|
const listRect = el.querySelector('[data-slot=tabs-list]')?.getBoundingClientRect()
|
||||||
|
const panelRect = el.querySelector('[data-slot=tabs-content]')?.getBoundingClientRect()
|
||||||
|
// #region agent log
|
||||||
|
fetch('http://127.0.0.1:7311/ingest/6b35c3ae-1bcd-4c9c-81eb-f157c9347393', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'c10bcb' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionId: 'c10bcb',
|
||||||
|
runId: 'post-fix',
|
||||||
|
hypothesisId: 'H1',
|
||||||
|
location: 'badge-tabs.tsx:useEffect',
|
||||||
|
message: 'BadgeTabs layout metrics',
|
||||||
|
data: {
|
||||||
|
flexDirection,
|
||||||
|
orientation: el.getAttribute('data-orientation'),
|
||||||
|
listLeft: listRect?.left,
|
||||||
|
panelLeft: panelRect?.left,
|
||||||
|
stacked: flexDirection === 'column',
|
||||||
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}),
|
||||||
|
}).catch(() => {})
|
||||||
|
// #endregion
|
||||||
|
}, [value, defaultValue])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<div ref={tabsRef} className={cn('w-full', className)}>
|
||||||
value={value}
|
<Tabs
|
||||||
defaultValue={defaultValue}
|
value={value}
|
||||||
onValueChange={onValueChange}
|
defaultValue={defaultValue}
|
||||||
className={cn('w-full', className)}
|
onValueChange={onValueChange}
|
||||||
>
|
className="w-full"
|
||||||
<TabsList
|
|
||||||
variant="line"
|
|
||||||
className={cn(
|
|
||||||
'mb-4 w-full justify-start gap-6',
|
|
||||||
listClassName,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{items.map((item) => (
|
<TabsList
|
||||||
<TabsTrigger key={item.value} value={item.value} className="gap-2">
|
variant="line"
|
||||||
{item.icon}
|
className={cn(
|
||||||
{item.label}
|
'mb-4 w-full justify-start gap-6',
|
||||||
{item.count !== undefined ? (
|
listClassName,
|
||||||
<Badge variant={item.badgeVariant ?? 'primary-light'} size="sm">
|
)}
|
||||||
{item.count}
|
>
|
||||||
</Badge>
|
{items.map((item) => (
|
||||||
) : null}
|
<TabsTrigger key={item.value} value={item.value} className="gap-2">
|
||||||
</TabsTrigger>
|
{item.icon}
|
||||||
))}
|
{item.label}
|
||||||
</TabsList>
|
{item.count !== undefined ? (
|
||||||
<div className={cn('w-full min-w-0', contentClassName)}>{children}</div>
|
<Badge variant={item.badgeVariant ?? 'primary-light'} size="sm">
|
||||||
</Tabs>
|
{item.count}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
<div className={cn('w-full min-w-0', contentClassName)}>{children}</div>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const Route = createFileRoute('/_auth')({
|
|||||||
const raw =
|
const raw =
|
||||||
typeof window !== 'undefined' ? window.localStorage.getItem(TOKEN_STORAGE_KEY) : null
|
typeof window !== 'undefined' ? window.localStorage.getItem(TOKEN_STORAGE_KEY) : null
|
||||||
if (!raw || !normalizeApiToken(raw)) {
|
if (!raw || !normalizeApiToken(raw)) {
|
||||||
throw redirect({ to: '/settings' })
|
throw redirect({ to: '/settings', search: { reason: 'token-required' } })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
component: AuthLayout,
|
component: AuthLayout,
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Alert, AlertDescription } from '@evobgp/ui/components/alert'
|
||||||
import { PanelCard } from '@/components/panel-card'
|
import { PanelCard } from '@/components/panel-card'
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
import { Label } from '@evobgp/ui/components/label'
|
import { Label } from '@evobgp/ui/components/label'
|
||||||
@@ -28,6 +29,9 @@ const THEME_SELECT_ITEMS = [
|
|||||||
|
|
||||||
function SettingsComponent() {
|
function SettingsComponent() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const tokenRequired = useRouterState({
|
||||||
|
select: (s) => new URLSearchParams(s.location.search).get('reason') === 'token-required',
|
||||||
|
})
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { data: session, isError: sessionError, error: sessionQueryError } = useQuery({
|
const { data: session, isError: sessionError, error: sessionQueryError } = useQuery({
|
||||||
...authSessionQueryOptions(),
|
...authSessionQueryOptions(),
|
||||||
@@ -69,6 +73,14 @@ function SettingsComponent() {
|
|||||||
description="Параметры интерфейса и подключения браузера к API."
|
description="Параметры интерфейса и подключения браузера к API."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{tokenRequired ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать dev».
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<PanelCard
|
<PanelCard
|
||||||
title="Подключение к API"
|
title="Подключение к API"
|
||||||
description="Токен хранится только в этом браузере (localStorage). Управление ключами tenant — в разделе «Права доступа»."
|
description="Токен хранится только в этом браузере (localStorage). Управление ключами tenant — в разделе «Права доступа»."
|
||||||
|
|||||||
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"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ function Tabs({
|
|||||||
data-slot="tabs"
|
data-slot="tabs"
|
||||||
data-orientation={orientation}
|
data-orientation={orientation}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -24,7 +24,7 @@ function Tabs({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabsListVariants = cva(
|
const tabsListVariants = cva(
|
||||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -58,10 +58,10 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
|||||||
<TabsPrimitive.Tab
|
<TabsPrimitive.Tab
|
||||||
data-slot="tabs-trigger"
|
data-slot="tabs-trigger"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
Reference in New Issue
Block a user