ui: copy node-graph + progress components from scificn-ui (adds @radix-ui/react-progress)
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "space-wanderer",
|
"name": "space-wanderer",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -136,6 +137,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
|
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.16", "", { "dependencies": { "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg=="],
|
||||||
|
|
||||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ=="],
|
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ=="],
|
||||||
|
|
||||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="],
|
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="],
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type NodeGraphVariant = 'DEFAULT' | 'ACTIVE' | 'WARNING' | 'CRITICAL'
|
||||||
|
export type NodeStatus = 'ACTIVE' | 'OFFLINE' | 'WARNING' | 'CRITICAL' | 'NEUTRAL'
|
||||||
|
|
||||||
|
export interface NodeGraphNode {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
x: number // 0–100 coordinate space
|
||||||
|
y: number
|
||||||
|
status?: NodeStatus
|
||||||
|
sublabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeGraphEdge {
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
label?: string
|
||||||
|
animated?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeGraphProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
nodes: NodeGraphNode[]
|
||||||
|
edges: NodeGraphEdge[]
|
||||||
|
variant?: NodeGraphVariant
|
||||||
|
title?: string
|
||||||
|
height?: number
|
||||||
|
directed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANT_COLORS: Record<NodeGraphVariant, string> = {
|
||||||
|
DEFAULT: 'var(--text-secondary)',
|
||||||
|
ACTIVE: 'var(--color-green)',
|
||||||
|
WARNING: 'var(--color-amber)',
|
||||||
|
CRITICAL: 'var(--color-red)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<NodeStatus, string> = {
|
||||||
|
ACTIVE: 'var(--color-green)',
|
||||||
|
OFFLINE: 'var(--border)',
|
||||||
|
WARNING: 'var(--color-amber)',
|
||||||
|
CRITICAL: 'var(--color-red)',
|
||||||
|
NEUTRAL: 'var(--text-muted)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_W = 100
|
||||||
|
const NODE_H = 36
|
||||||
|
|
||||||
|
const NodeGraph = React.forwardRef<HTMLDivElement, NodeGraphProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
variant = 'ACTIVE',
|
||||||
|
title,
|
||||||
|
height = 320,
|
||||||
|
directed = false,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
const [svgW, setSvgW] = React.useState(600)
|
||||||
|
const [hovered, setHovered] = React.useState<string | null>(null)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
const ro = new ResizeObserver(entries => {
|
||||||
|
setSvgW(entries[0].contentRect.width)
|
||||||
|
})
|
||||||
|
ro.observe(containerRef.current)
|
||||||
|
return () => ro.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const color = VARIANT_COLORS[variant]
|
||||||
|
const markerId = `arrow-${variant}`
|
||||||
|
|
||||||
|
function nodePx(node: NodeGraphNode) {
|
||||||
|
return (node.x / 100) * svgW
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodePy(node: NodeGraphNode) {
|
||||||
|
return (node.y / 100) * height
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeEndpoints(fromNode: NodeGraphNode, toNode: NodeGraphNode) {
|
||||||
|
const fx = nodePx(fromNode)
|
||||||
|
const fy = nodePy(fromNode)
|
||||||
|
const tx = nodePx(toNode)
|
||||||
|
const ty = nodePy(toNode)
|
||||||
|
const dx = tx - fx
|
||||||
|
const dy = ty - fy
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
if (dist < 1) return { x1: fx, y1: fy, x2: tx, y2: ty }
|
||||||
|
|
||||||
|
const ux = dx / dist
|
||||||
|
const uy = dy / dist
|
||||||
|
const hw = NODE_W / 2
|
||||||
|
const hh = NODE_H / 2
|
||||||
|
|
||||||
|
const tW = Math.abs(ux) > 0.001 ? hw / Math.abs(ux) : Infinity
|
||||||
|
const tH = Math.abs(uy) > 0.001 ? hh / Math.abs(uy) : Infinity
|
||||||
|
const t = Math.min(tW, tH)
|
||||||
|
|
||||||
|
return {
|
||||||
|
x1: fx + ux * t,
|
||||||
|
y1: fy + uy * t,
|
||||||
|
x2: tx - ux * t,
|
||||||
|
y2: ty - uy * t,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeMap = new Map(nodes.map(n => [n.id, n]))
|
||||||
|
const hasAnimatedEdge = edges.some(e => e.animated)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(className)}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0.4rem 0.75rem',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface-raised)',
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={containerRef} style={{ width: '100%', height: `${height}px` }}>
|
||||||
|
<svg width={svgW} height={height} style={{ display: 'block' }}>
|
||||||
|
<defs>
|
||||||
|
{directed && (
|
||||||
|
<marker
|
||||||
|
id={markerId}
|
||||||
|
markerWidth="6"
|
||||||
|
markerHeight="6"
|
||||||
|
refX="6"
|
||||||
|
refY="3"
|
||||||
|
orient="auto"
|
||||||
|
>
|
||||||
|
<path d="M 0 0 L 6 3 L 0 6 Z" fill={color} />
|
||||||
|
</marker>
|
||||||
|
)}
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{hasAnimatedEdge && (
|
||||||
|
<style>{`
|
||||||
|
@keyframes nodeGraphDash { to { stroke-dashoffset: -8; } }
|
||||||
|
`}</style>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edges */}
|
||||||
|
{edges.map((edge, ei) => {
|
||||||
|
const fromNode = nodeMap.get(edge.from)
|
||||||
|
const toNode = nodeMap.get(edge.to)
|
||||||
|
if (!fromNode || !toNode) return null
|
||||||
|
|
||||||
|
const { x1, y1, x2, y2 } = edgeEndpoints(fromNode, toNode)
|
||||||
|
const mx = (x1 + x2) / 2
|
||||||
|
const my = (y1 + y2) / 2
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={ei}>
|
||||||
|
<line
|
||||||
|
x1={x1.toFixed(1)}
|
||||||
|
y1={y1.toFixed(1)}
|
||||||
|
x2={x2.toFixed(1)}
|
||||||
|
y2={y2.toFixed(1)}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={1}
|
||||||
|
opacity={0.5}
|
||||||
|
strokeDasharray={edge.animated ? '4 4' : undefined}
|
||||||
|
markerEnd={directed ? `url(#${markerId})` : undefined}
|
||||||
|
style={edge.animated
|
||||||
|
? { animation: 'nodeGraphDash 0.5s linear infinite' }
|
||||||
|
: undefined}
|
||||||
|
/>
|
||||||
|
{edge.label && (
|
||||||
|
<text
|
||||||
|
x={mx.toFixed(1)}
|
||||||
|
y={my.toFixed(1)}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
fontSize={8}
|
||||||
|
fill="var(--text-muted)"
|
||||||
|
fontFamily="var(--font-mono)"
|
||||||
|
>
|
||||||
|
{edge.label}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Nodes */}
|
||||||
|
{nodes.map(node => {
|
||||||
|
const cx = nodePx(node)
|
||||||
|
const cy = nodePy(node)
|
||||||
|
const isHovered = hovered === node.id
|
||||||
|
const statusColor = node.status ? STATUS_COLOR[node.status] : color
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={node.id}
|
||||||
|
onMouseEnter={() => setHovered(node.id)}
|
||||||
|
onMouseLeave={() => setHovered(null)}
|
||||||
|
style={{ cursor: 'default' }}
|
||||||
|
>
|
||||||
|
{/* Background */}
|
||||||
|
<rect
|
||||||
|
x={(cx - NODE_W / 2).toFixed(1)}
|
||||||
|
y={(cy - NODE_H / 2).toFixed(1)}
|
||||||
|
width={NODE_W}
|
||||||
|
height={NODE_H}
|
||||||
|
fill="var(--surface-raised)"
|
||||||
|
/>
|
||||||
|
{/* Hover tint */}
|
||||||
|
{isHovered && (
|
||||||
|
<rect
|
||||||
|
x={(cx - NODE_W / 2).toFixed(1)}
|
||||||
|
y={(cy - NODE_H / 2).toFixed(1)}
|
||||||
|
width={NODE_W}
|
||||||
|
height={NODE_H}
|
||||||
|
fill={statusColor}
|
||||||
|
fillOpacity={0.12}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Border */}
|
||||||
|
<rect
|
||||||
|
x={(cx - NODE_W / 2).toFixed(1)}
|
||||||
|
y={(cy - NODE_H / 2).toFixed(1)}
|
||||||
|
width={NODE_W}
|
||||||
|
height={NODE_H}
|
||||||
|
fill="none"
|
||||||
|
stroke={statusColor}
|
||||||
|
strokeWidth={isHovered ? 1.5 : 1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<text
|
||||||
|
x={cx.toFixed(1)}
|
||||||
|
y={(node.sublabel ? cy - 5 : cy).toFixed(1)}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
fontSize={10}
|
||||||
|
fill={statusColor}
|
||||||
|
fontFamily="var(--font-mono)"
|
||||||
|
fontWeight="600"
|
||||||
|
>
|
||||||
|
{node.label.toUpperCase()}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Sublabel */}
|
||||||
|
{node.sublabel && (
|
||||||
|
<text
|
||||||
|
x={cx.toFixed(1)}
|
||||||
|
y={(cy + 8).toFixed(1)}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
fontSize={8}
|
||||||
|
fill="var(--text-muted)"
|
||||||
|
fontFamily="var(--font-mono)"
|
||||||
|
>
|
||||||
|
{node.sublabel}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
NodeGraph.displayName = 'NodeGraph'
|
||||||
|
|
||||||
|
export { NodeGraph }
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as ProgressPrimitive from '@radix-ui/react-progress'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface ProgressProps
|
||||||
|
extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
|
||||||
|
showValue?: boolean
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Progress = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||||
|
ProgressProps
|
||||||
|
>(({ className, value = 0, showValue = true, label, ...props }, ref) => {
|
||||||
|
const pct = Math.min(Math.max(value ?? 0, 0), 100)
|
||||||
|
const filled = Math.round((pct / 100) * 20)
|
||||||
|
const empty = 20 - filled
|
||||||
|
const barText = '='.repeat(filled) + ' '.repeat(empty)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', width: '100%' }}>
|
||||||
|
{label && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn('relative w-full overflow-hidden rounded-none', className)}
|
||||||
|
value={value}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Bracket-style visual */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-green)',
|
||||||
|
textShadow: 'var(--text-glow-green)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>[</span>
|
||||||
|
<span style={{ letterSpacing: '-0.05em', whiteSpace: 'pre' }}>{barText}</span>
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>]</span>
|
||||||
|
{showValue && (
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: '0.65rem', marginLeft: '0.25rem' }}>
|
||||||
|
{Math.round(pct)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Accessible indicator (visually hidden, only for Radix a11y) */}
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: '1px',
|
||||||
|
height: '1px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
clip: 'rect(0,0,0,0)',
|
||||||
|
transform: `translateX(-${100 - pct}%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user