feat: add React Flow UI foundation components for network diagrams
BaseNode (structured node shell with header/content/footer slots), BaseHandle (styled connection handle), LabeledHandle (handle with port label), NodeStatusIndicator (status border effect), NodeTooltip (hover details via NodeToolbar). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
20
frontend/src/components/network/ui/base-handle.tsx
Normal file
20
frontend/src/components/network/ui/base-handle.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ComponentProps } from 'react'
|
||||||
|
import { Handle, type HandleProps } from '@xyflow/react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type BaseHandleProps = HandleProps
|
||||||
|
|
||||||
|
export function BaseHandle({ className, children, ...props }: ComponentProps<typeof Handle>) {
|
||||||
|
return (
|
||||||
|
<Handle
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
'h-[10px] w-[10px] rounded-full border border-default bg-elevated transition-opacity',
|
||||||
|
'opacity-0 group-hover:opacity-100',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Handle>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
frontend/src/components/network/ui/base-node.tsx
Normal file
56
frontend/src/components/network/ui/base-node.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { ComponentProps } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function BaseNode({ className, ...props }: ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'bg-card text-heading relative rounded-lg border border-default',
|
||||||
|
'transition-colors hover:border-hover',
|
||||||
|
'in-[.selected]:border-accent',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
tabIndex={0}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BaseNodeHeader({ className, ...props }: ComponentProps<'header'>) {
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
{...props}
|
||||||
|
className={cn('flex flex-row items-center gap-2 px-3 py-2', className)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BaseNodeHeaderTitle({ className, ...props }: ComponentProps<'h3'>) {
|
||||||
|
return (
|
||||||
|
<h3
|
||||||
|
data-slot="base-node-title"
|
||||||
|
className={cn('select-none flex-1 text-xs font-semibold text-heading', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BaseNodeContent({ className, ...props }: ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="base-node-content"
|
||||||
|
className={cn('flex flex-col gap-y-1 px-3 pb-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BaseNodeFooter({ className, ...props }: ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="base-node-footer"
|
||||||
|
className={cn('flex flex-col items-center gap-y-1 border-t border-default px-3 pt-1.5 pb-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
frontend/src/components/network/ui/labeled-handle.tsx
Normal file
39
frontend/src/components/network/ui/labeled-handle.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { ComponentProps } from 'react'
|
||||||
|
import { type HandleProps, Position } from '@xyflow/react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { BaseHandle } from './base-handle'
|
||||||
|
|
||||||
|
const flexDirections: Record<string, string> = {
|
||||||
|
[Position.Top]: 'flex-col',
|
||||||
|
[Position.Right]: 'flex-row-reverse justify-end',
|
||||||
|
[Position.Bottom]: 'flex-col-reverse justify-end',
|
||||||
|
[Position.Left]: 'flex-row',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LabeledHandle({
|
||||||
|
className,
|
||||||
|
labelClassName,
|
||||||
|
handleClassName,
|
||||||
|
title,
|
||||||
|
position,
|
||||||
|
...props
|
||||||
|
}: HandleProps &
|
||||||
|
ComponentProps<'div'> & {
|
||||||
|
title: string
|
||||||
|
handleClassName?: string
|
||||||
|
labelClassName?: string
|
||||||
|
}) {
|
||||||
|
const { ref, ...handleProps } = props
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
title={title}
|
||||||
|
className={cn('relative flex items-center', flexDirections[position], className)}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<BaseHandle position={position} className={handleClassName} {...handleProps} />
|
||||||
|
<label className={cn('text-muted-foreground text-[10px] font-mono px-1.5', labelClassName)}>
|
||||||
|
{title}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
frontend/src/components/network/ui/node-status-indicator.tsx
Normal file
43
frontend/src/components/network/ui/node-status-indicator.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type NodeStatus = 'online' | 'offline' | 'degraded' | 'unknown'
|
||||||
|
|
||||||
|
const STATUS_BORDER_COLORS: Record<NodeStatus, string> = {
|
||||||
|
online: 'border-emerald-400',
|
||||||
|
offline: 'border-red-400',
|
||||||
|
degraded: 'border-yellow-400',
|
||||||
|
unknown: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_GLOW: Record<NodeStatus, string> = {
|
||||||
|
online: 'shadow-[0_0_8px_rgba(52,211,153,0.3)]',
|
||||||
|
offline: 'shadow-[0_0_8px_rgba(248,113,113,0.3)]',
|
||||||
|
degraded: 'shadow-[0_0_8px_rgba(250,204,21,0.3)]',
|
||||||
|
unknown: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NodeStatusIndicatorProps {
|
||||||
|
status?: NodeStatus
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeStatusIndicator({ status = 'unknown', children, className }: NodeStatusIndicatorProps) {
|
||||||
|
if (status === 'unknown') {
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border-2 transition-colors',
|
||||||
|
STATUS_BORDER_COLORS[status],
|
||||||
|
STATUS_GLOW[status],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
frontend/src/components/network/ui/node-tooltip.tsx
Normal file
77
frontend/src/components/network/ui/node-tooltip.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, type ReactNode, type ComponentProps } from 'react'
|
||||||
|
import { NodeToolbar, type NodeToolbarProps } from '@xyflow/react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface NodeTooltipContextValue {
|
||||||
|
visible: boolean
|
||||||
|
show: () => void
|
||||||
|
hide: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const NodeTooltipContext = createContext<NodeTooltipContextValue>({
|
||||||
|
visible: false,
|
||||||
|
show: () => {},
|
||||||
|
hide: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function NodeTooltip({ children, ...props }: ComponentProps<'div'>) {
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
const show = useCallback(() => setVisible(true), [])
|
||||||
|
const hide = useCallback(() => setVisible(false), [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeTooltipContext.Provider value={{ visible, show, hide }}>
|
||||||
|
<div {...props}>{children}</div>
|
||||||
|
</NodeTooltipContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeTooltipTrigger({
|
||||||
|
children,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
...props
|
||||||
|
}: ComponentProps<'div'>) {
|
||||||
|
const { show, hide } = useContext(NodeTooltipContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
show()
|
||||||
|
onMouseEnter?.(e)
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
hide()
|
||||||
|
onMouseLeave?.(e)
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeTooltipContent({
|
||||||
|
className,
|
||||||
|
position,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: Omit<NodeToolbarProps, 'children'> & { children: ReactNode }) {
|
||||||
|
const { visible } = useContext(NodeTooltipContext)
|
||||||
|
|
||||||
|
if (!visible) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeToolbar
|
||||||
|
position={position}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border border-default bg-elevated px-3 py-2',
|
||||||
|
'pointer-events-none',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</NodeToolbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user