lint: change folder names
This commit is contained in:
148
frontend/src/components/graph/AnimatedEdge.tsx
Normal file
148
frontend/src/components/graph/AnimatedEdge.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React, { useContext, useMemo } from 'react'
|
||||
import {
|
||||
BaseEdge,
|
||||
getBezierPath,
|
||||
type EdgeProps,
|
||||
} from '@xyflow/react'
|
||||
import FlowContext from '../../lib/graph/flowContext'
|
||||
import { getConnectionStatus, CONNECTION_STATUS_CLASS } from '../../lib/connectionStatus'
|
||||
import { getConnectionLabelForTarget } from '../../lib/graph/nodeRegistry'
|
||||
|
||||
const EDGE_STROKE_WIDTH = 2
|
||||
const DOT_MARKER_R = 1.5
|
||||
const EMPTY_PATH_NODE_IDS = new Set<string>()
|
||||
|
||||
export function AnimatedEdge({
|
||||
id,
|
||||
source,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
style,
|
||||
label: labelProp,
|
||||
interactionWidth,
|
||||
target,
|
||||
}: EdgeProps) {
|
||||
const ctx = useContext(FlowContext)
|
||||
const nodes = ctx?.nodes ?? []
|
||||
const pathNodeIds = ctx?.connectionPathNodeIds ?? EMPTY_PATH_NODE_IDS
|
||||
const pausedSegmentNodeIds = ctx?.connectionPathPausedSegmentNodeIds ?? EMPTY_PATH_NODE_IDS
|
||||
const activeSegmentNodeIds = ctx?.connectionPathActiveSegmentNodeIds ?? EMPTY_PATH_NODE_IDS
|
||||
const errorTargetNodeIds = useMemo(
|
||||
() => new Set(ctx?.connectionPathErrorNodeIds ?? []),
|
||||
[ctx?.connectionPathErrorNodeIds]
|
||||
)
|
||||
const targetNode = useMemo(() => nodes.find((n: any) => n.id === target), [nodes, target])
|
||||
const derivedLabel = useMemo(
|
||||
() => (targetNode?.type != null ? getConnectionLabelForTarget(targetNode.type) : undefined),
|
||||
[targetNode?.type]
|
||||
)
|
||||
const label = labelProp ?? derivedLabel
|
||||
|
||||
const connectionStatus = useMemo(
|
||||
() =>
|
||||
getConnectionStatus({
|
||||
source,
|
||||
target,
|
||||
pathNodeIds,
|
||||
pausedSegmentNodeIds,
|
||||
activeSegmentNodeIds,
|
||||
errorTargetNodeIds,
|
||||
}),
|
||||
[
|
||||
source,
|
||||
target,
|
||||
pathNodeIds,
|
||||
pausedSegmentNodeIds,
|
||||
activeSegmentNodeIds,
|
||||
errorTargetNodeIds,
|
||||
]
|
||||
)
|
||||
const statusClass = CONNECTION_STATUS_CLASS[connectionStatus]
|
||||
|
||||
const [edgePath, edgeLabelX, edgeLabelY] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
})
|
||||
|
||||
const startId = `animated-edge-dot-start-${id}`
|
||||
const endId = `animated-edge-dot-end-${id}`
|
||||
|
||||
return (
|
||||
<>
|
||||
<defs>
|
||||
<marker
|
||||
id={startId}
|
||||
markerWidth={DOT_MARKER_R * 2}
|
||||
markerHeight={DOT_MARKER_R * 2}
|
||||
refX={DOT_MARKER_R - 1}
|
||||
refY={DOT_MARKER_R}
|
||||
orient="auto"
|
||||
>
|
||||
<circle
|
||||
r={DOT_MARKER_R}
|
||||
cx={DOT_MARKER_R}
|
||||
cy={DOT_MARKER_R}
|
||||
className="fill-primary"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</marker>
|
||||
<marker
|
||||
id={endId}
|
||||
markerWidth={DOT_MARKER_R * 2}
|
||||
markerHeight={DOT_MARKER_R * 2}
|
||||
refX={DOT_MARKER_R + 1}
|
||||
refY={DOT_MARKER_R}
|
||||
orient="auto"
|
||||
>
|
||||
<circle
|
||||
r={DOT_MARKER_R}
|
||||
cx={DOT_MARKER_R}
|
||||
cy={DOT_MARKER_R}
|
||||
className="fill-primary"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</marker>
|
||||
</defs>
|
||||
<BaseEdge
|
||||
path={edgePath}
|
||||
markerStart={`url(#${startId})`}
|
||||
markerEnd={`url(#${endId})`}
|
||||
style={{
|
||||
strokeWidth: EDGE_STROKE_WIDTH,
|
||||
...style,
|
||||
}}
|
||||
className={`animated-edge-path${statusClass ? ` ${statusClass}` : ''}`}
|
||||
interactionWidth={interactionWidth}
|
||||
/>
|
||||
{label != null && (
|
||||
<g transform={`translate(${edgeLabelX}, ${edgeLabelY})`} className="nodrag nopan">
|
||||
<rect
|
||||
x={-32}
|
||||
y={-9}
|
||||
width={64}
|
||||
height={18}
|
||||
rx={4}
|
||||
ry={4}
|
||||
className="fill-background stroke-border"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
className="fill-foreground text-[10px] font-medium"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
215
frontend/src/components/graph/BaseNode.tsx
Normal file
215
frontend/src/components/graph/BaseNode.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { NodeResizer } from "@xyflow/react";
|
||||
|
||||
import { useConnectionPathRole } from "@/lib/graph/flowContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Default min size for resizable nodes (used by NodeResizer). */
|
||||
export const RESIZE_MIN_WIDTH = 120;
|
||||
export const RESIZE_MIN_HEIGHT = 80;
|
||||
|
||||
export type BaseNodeProps = ComponentProps<"div"> & {
|
||||
/** When true, shows resize handles on corners/edges (via NodeResizer). Requires nodeId when used inside React Flow. */
|
||||
resizable?: boolean;
|
||||
/** Node id for the resize control (required when resizable is true). */
|
||||
nodeId?: string;
|
||||
/** Connection handles (InputHandle, OutputHandle). Rendered outside the overflow layer so they stay visible. */
|
||||
handles?: ReactNode;
|
||||
/** When provided, node uses this size (e.g. from React Flow width/height). Enables correct resize behavior. */
|
||||
dimensions?: { width: number; height: number };
|
||||
/** When true, node is selected (from React Flow NodeProps). Used for visible selected state. */
|
||||
selected?: boolean;
|
||||
/** Optional min/max for NodeResizer. Omit to use RESIZE_MIN_WIDTH / RESIZE_MIN_HEIGHT. */
|
||||
resizeConstraints?: { minWidth?: number; minHeight?: number; maxWidth?: number; maxHeight?: number };
|
||||
};
|
||||
|
||||
export function BaseNode({
|
||||
className,
|
||||
style,
|
||||
dimensions,
|
||||
resizable,
|
||||
nodeId,
|
||||
handles,
|
||||
children,
|
||||
selected,
|
||||
resizeConstraints,
|
||||
...props
|
||||
}: BaseNodeProps) {
|
||||
const connectionPathRole = useConnectionPathRole(nodeId);
|
||||
const hasSize =
|
||||
dimensions &&
|
||||
dimensions.width > 0 &&
|
||||
dimensions.height > 0;
|
||||
// Don't set overflow: hidden on the root — handles are positioned at left/right -10px
|
||||
// and would be clipped. The inner content wrapper has overflow-hidden for scrolling.
|
||||
const appliedStyle = hasSize
|
||||
? {
|
||||
...style,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
display: "flex" as const,
|
||||
flexDirection: "column" as const,
|
||||
contain: "layout" as const,
|
||||
}
|
||||
: style
|
||||
? { ...style, display: "flex", flexDirection: "column" as const }
|
||||
: undefined;
|
||||
|
||||
const minW = resizeConstraints?.minWidth ?? RESIZE_MIN_WIDTH;
|
||||
const minH = resizeConstraints?.minHeight ?? RESIZE_MIN_HEIGHT;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card text-card-foreground relative rounded-md border transition-[border-color,box-shadow] duration-200",
|
||||
"hover:ring-1",
|
||||
selected && "border-primary/50 shadow-[0_0_0_2px_hsl(var(--primary)_/_0.15)] dark:border-primary/35 dark:shadow-[0_0_0_2px_hsl(var(--primary)_/_0.1)]",
|
||||
connectionPathRole === "trigger" && "connection-path-trigger",
|
||||
connectionPathRole === "updating" && "connection-path-updating",
|
||||
connectionPathRole === "on-path" && "connection-path-on-path",
|
||||
className,
|
||||
)}
|
||||
data-selected={selected}
|
||||
data-path-role={connectionPathRole ?? undefined}
|
||||
style={appliedStyle}
|
||||
tabIndex={0}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="min-h-0 flex-1 flex flex-col overflow-hidden"
|
||||
style={{ contain: "layout" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{resizable && nodeId && (
|
||||
<NodeResizer
|
||||
nodeId={nodeId}
|
||||
isVisible={selected}
|
||||
minWidth={minW}
|
||||
minHeight={minH}
|
||||
maxWidth={resizeConstraints?.maxWidth}
|
||||
maxHeight={resizeConstraints?.maxHeight}
|
||||
color="transparent"
|
||||
handleClassName="base-node-resize-handle nodrag nopan"
|
||||
/>
|
||||
)}
|
||||
{handles}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A container for a consistent header layout intended to be used inside the
|
||||
* `<BaseNode />` component.
|
||||
*/
|
||||
export function BaseNodeHeader({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<"header">) {
|
||||
return (
|
||||
<header
|
||||
{...props}
|
||||
className={cn(
|
||||
"shrink-0 mx-0 my-0 -mb-1 flex flex-row items-center justify-between gap-2 px-3 py-2",
|
||||
// Remove or modify these classes if you modify the padding in the
|
||||
// `<BaseNode />` component.
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard node header: icon on the left, title taking the rest. Use for all node types.
|
||||
* Inlined so it does not depend on BaseNodeHeader/BaseNodeHeaderTitle (avoids reference errors with HMR).
|
||||
*/
|
||||
export function BaseNodeHeaderRow({
|
||||
icon,
|
||||
title,
|
||||
right,
|
||||
className,
|
||||
onHeaderDoubleClick,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: ReactNode;
|
||||
/** Optional right-side content (e.g. type selector). */
|
||||
right?: ReactNode;
|
||||
className?: string;
|
||||
/** When provided, double-click on the header triggers this (e.g. fullscreen). Header gets cursor-pointer and nodrag. */
|
||||
onHeaderDoubleClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<header
|
||||
role={onHeaderDoubleClick ? 'button' : undefined}
|
||||
tabIndex={onHeaderDoubleClick ? 0 : undefined}
|
||||
onDoubleClick={onHeaderDoubleClick}
|
||||
onKeyDown={
|
||||
onHeaderDoubleClick
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onHeaderDoubleClick();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"shrink-0 mx-0 my-0 flex flex-row items-center justify-between gap-2 px-3 py-2",
|
||||
onHeaderDoubleClick && "cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
title={onHeaderDoubleClick ? 'Double-click for full screen' : undefined}
|
||||
>
|
||||
{icon}
|
||||
<h3
|
||||
data-slot="base-node-title"
|
||||
className="user-select-none flex-1 font-mono min-w-0 truncate"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
{right != null ? <div className="shrink-0 nodrag nopan">{right}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-line muted text for the footer. Use for status or hints in all node types.
|
||||
*/
|
||||
export function BaseNodeFooterText({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("w-full text-xs text-muted-foreground", className)}
|
||||
data-slot="base-node-footer-text"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BaseNodeContent({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="base-node-content"
|
||||
className={cn("min-h-0 flex-1 flex flex-col overflow-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BaseNodeFooter({ className, ...props }: ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="base-node-footer"
|
||||
className={cn(
|
||||
"shrink-0 flex flex-col items-center gap-y-2 border-t p-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
162
frontend/src/components/graph/FlowKeyboardShortcuts.tsx
Normal file
162
frontend/src/components/graph/FlowKeyboardShortcuts.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { useCallback, useContext, useEffect } from 'react'
|
||||
import { useReactFlow } from '@xyflow/react'
|
||||
import type { Node } from '@xyflow/react'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
import { getNextNodeId, getDefaultDataForType } from '@/lib/graph/flowUtils'
|
||||
import { getDefaultStyle, getRegisteredNodeTypeIds } from '@/lib/graph/nodeRegistry'
|
||||
|
||||
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
||||
|
||||
function isMod(ev: KeyboardEvent) {
|
||||
return ev.ctrlKey || ev.metaKey
|
||||
}
|
||||
|
||||
/** Must be rendered inside ReactFlowProvider and FlowContext. Handles Escape, ⌘C, ⌘V, ⌘D, ⌘0. */
|
||||
export function FlowKeyboardShortcuts() {
|
||||
const { fitView, screenToFlowPosition } = useReactFlow()
|
||||
const ctx = useContext(FlowContext)
|
||||
const nodes = ctx?.nodes ?? []
|
||||
const setNodes = ctx?.setNodes
|
||||
const setConnectionFrom = ctx?.setConnectionFrom
|
||||
const flowActionsRef = ctx?.flowActionsRef
|
||||
|
||||
const pasteAtViewportCenter = useCallback(async () => {
|
||||
if (!setNodes || !screenToFlowPosition) return
|
||||
try {
|
||||
const text = await navigator.clipboard?.readText()
|
||||
if (!text) return
|
||||
const raw = JSON.parse(text) as {
|
||||
id?: string
|
||||
type?: string
|
||||
data?: Record<string, unknown>
|
||||
position?: { x: number; y: number }
|
||||
style?: unknown
|
||||
}
|
||||
const validIds = getRegisteredNodeTypeIds()
|
||||
if (!raw || typeof raw.type !== 'string' || !validIds.includes(raw.type)) return
|
||||
const nodeType = raw.type
|
||||
const pane = document.querySelector('.react-flow__viewport')
|
||||
const rect = pane?.getBoundingClientRect()
|
||||
const center = rect
|
||||
? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
|
||||
: { x: window.innerWidth / 2, y: window.innerHeight / 2 }
|
||||
const position = screenToFlowPosition(center)
|
||||
setNodes((nds: Node[]) => {
|
||||
const existingIds = nds.map((n) => n.id).filter((id): id is string => id != null)
|
||||
const newId = getNextNodeId(nodeType, existingIds)
|
||||
const data: Record<string, unknown> =
|
||||
raw.data != null && typeof raw.data === 'object'
|
||||
? { ...raw.data }
|
||||
: (getDefaultDataForType(nodeType, newId) as Record<string, unknown>)
|
||||
if (nodeType === 'config') data.title = `config-${newId}`
|
||||
const style = getDefaultStyle(nodeType)
|
||||
const newNode: Node = {
|
||||
id: newId,
|
||||
type: raw.type as Node['type'],
|
||||
position: { x: position.x, y: position.y },
|
||||
data,
|
||||
style,
|
||||
}
|
||||
return nds.concat(newNode)
|
||||
})
|
||||
} catch {
|
||||
// Invalid clipboard or not a copied node
|
||||
}
|
||||
}, [setNodes, screenToFlowPosition])
|
||||
|
||||
const doFitView = useCallback(() => {
|
||||
fitView?.({ duration: 200 })
|
||||
}, [fitView])
|
||||
|
||||
useEffect(() => {
|
||||
if (flowActionsRef) {
|
||||
flowActionsRef.current = { pasteAtViewportCenter, fitView: doFitView }
|
||||
return () => {
|
||||
flowActionsRef.current = null
|
||||
}
|
||||
}
|
||||
}, [flowActionsRef, pasteAtViewportCenter, doFitView])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (ev: KeyboardEvent) => {
|
||||
if (ev.key === 'Escape') {
|
||||
const openDialog = document.querySelector('[role="dialog"]')
|
||||
if (openDialog && ev.target instanceof Node && openDialog.contains(ev.target)) return
|
||||
setConnectionFrom?.(null)
|
||||
setNodes?.((nds) => nds.map((n) => ({ ...n, selected: false })))
|
||||
ev.preventDefault()
|
||||
return
|
||||
}
|
||||
if (ev.key === 'c' && isMod(ev) && !ev.shiftKey) {
|
||||
const selectedNodes = nodes.filter((n) => (n as Node & { selected?: boolean }).selected)
|
||||
if (selectedNodes.length === 1) {
|
||||
const node = selectedNodes[0] as Node & { selected?: boolean }
|
||||
const copy = {
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
data: node.data,
|
||||
position: node.position,
|
||||
style: node.style,
|
||||
}
|
||||
navigator.clipboard?.writeText(JSON.stringify(copy)).catch(() => { })
|
||||
ev.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (ev.key === 'v' && isMod(ev) && !ev.shiftKey) {
|
||||
pasteAtViewportCenter()
|
||||
ev.preventDefault()
|
||||
return
|
||||
}
|
||||
if (ev.key === 'd' && isMod(ev) && !ev.shiftKey) {
|
||||
const selectedNodes = nodes.filter((n) => (n as Node & { selected?: boolean }).selected)
|
||||
if (selectedNodes.length > 0 && setNodes) {
|
||||
setNodes((nds: Node[]) => {
|
||||
const existingIds = nds.map((n) => n.id)
|
||||
const toAdd: Node[] = []
|
||||
for (const node of selectedNodes) {
|
||||
const n = node as Node & { selected?: boolean }
|
||||
const pos = n.position ?? { x: 0, y: 0 }
|
||||
const newId = getNextNodeId(String(n.type), [...existingIds, ...toAdd.map((x) => x.id)])
|
||||
existingIds.push(newId)
|
||||
const newNode: Node = {
|
||||
id: newId,
|
||||
type: n.type,
|
||||
position: { x: pos.x + DUPLICATE_OFFSET.x, y: pos.y + DUPLICATE_OFFSET.y },
|
||||
data: typeof n.data === 'object' && n.data !== null ? { ...(n.data as object) } : n.data,
|
||||
style: getDefaultStyle(String(n.type)),
|
||||
}
|
||||
if (
|
||||
newNode.data &&
|
||||
typeof newNode.data === 'object' &&
|
||||
'title' in newNode.data &&
|
||||
String(n.type) === 'config'
|
||||
) {
|
||||
; (newNode.data as Record<string, unknown>).title = `${newId}`
|
||||
}
|
||||
toAdd.push(newNode)
|
||||
}
|
||||
return nds.concat(toAdd)
|
||||
})
|
||||
ev.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (ev.key === '0' && isMod(ev) && !ev.shiftKey) {
|
||||
doFitView()
|
||||
ev.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||
}, [
|
||||
nodes,
|
||||
setNodes,
|
||||
setConnectionFrom,
|
||||
pasteAtViewportCenter,
|
||||
doFitView,
|
||||
])
|
||||
|
||||
return null
|
||||
}
|
||||
63
frontend/src/components/graph/NodeFooterEdgeIndicators.tsx
Normal file
63
frontend/src/components/graph/NodeFooterEdgeIndicators.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React, { useContext, useMemo } from 'react'
|
||||
import FlowContext from '../../lib/graph/flowContext'
|
||||
import { ArrowDownLeft, ArrowUpRight } from 'lucide-react'
|
||||
import { NodeHelpPopover } from './NodeHelpPopover'
|
||||
import { getNodeType, getNodeClassificationLabel } from '../../lib/graph/nodeRegistry'
|
||||
|
||||
type Props = {
|
||||
nodeId: string
|
||||
nodeType: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function NodeFooterEdgeIndicators({ nodeId, nodeType, children }: Props) {
|
||||
const ctx = useContext(FlowContext)
|
||||
const edges = ctx?.edges ?? []
|
||||
|
||||
const { inputs, outputs } = useMemo(() => {
|
||||
let inputs = 0
|
||||
let outputs = 0
|
||||
for (const e of edges) {
|
||||
if (e.target === nodeId) inputs += 1
|
||||
if (e.source === nodeId) outputs += 1
|
||||
}
|
||||
return { inputs, outputs }
|
||||
}, [edges, nodeId])
|
||||
|
||||
const descriptor = getNodeType(nodeType)
|
||||
const showInput = descriptor?.hasInput ?? false
|
||||
const showOutput = descriptor?.hasOutput ?? false
|
||||
const classificationLabel = getNodeClassificationLabel(nodeType)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full text-xs text-muted-foreground">
|
||||
{showInput && (
|
||||
<span className="flex items-center gap-1 shrink-0" title="Input connections">
|
||||
<ArrowDownLeft className="size-3.5" />
|
||||
<span>{inputs}</span>
|
||||
</span>
|
||||
)}
|
||||
{showOutput && (
|
||||
<span className="flex items-center gap-1 shrink-0" title="Output connections">
|
||||
<ArrowUpRight className="size-3.5" />
|
||||
<span>{outputs}</span>
|
||||
</span>
|
||||
)}
|
||||
{children != null && (
|
||||
<>
|
||||
{(showInput || showOutput) && <span className="shrink-0">|</span>}
|
||||
<span className="min-w-0 truncate">{children}</span>
|
||||
</>
|
||||
)}
|
||||
{classificationLabel != null && (
|
||||
<>
|
||||
{(showInput || showOutput || children != null) && <span className="shrink-0">|</span>}
|
||||
<span className="shrink-0" title="Node classification">{classificationLabel}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="shrink-0 ml-auto">
|
||||
<NodeHelpPopover nodeType={nodeType} />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
frontend/src/components/graph/NodeHandles.tsx
Normal file
76
frontend/src/components/graph/NodeHandles.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { useContext } from 'react'
|
||||
import { Handle, Position } from '@xyflow/react'
|
||||
import { ArrowDownLeft, ArrowUpRight } from 'lucide-react'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type NodeHandleProps = {
|
||||
id: string
|
||||
/** Pass when this handle is a connection target so valid highlight can show as soon as connection starts */
|
||||
nodeId?: string
|
||||
}
|
||||
|
||||
export function InputHandle({ id, nodeId }: NodeHandleProps) {
|
||||
const ctx = useContext(FlowContext)
|
||||
const connectionFrom = ctx?.connectionFrom ?? null
|
||||
const isValidConnection = ctx?.isValidConnection
|
||||
const isConnecting = Boolean(nodeId && connectionFrom && connectionFrom.nodeId !== nodeId)
|
||||
const isValidTarget =
|
||||
isConnecting &&
|
||||
isValidConnection?.({
|
||||
source: connectionFrom!.nodeId,
|
||||
sourceHandle: connectionFrom!.sourceHandle ?? null,
|
||||
target: nodeId!,
|
||||
targetHandle: id,
|
||||
})
|
||||
|
||||
return (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id={id}
|
||||
className={cn(isValidTarget && 'connection-valid-target', isConnecting && !isValidTarget && 'connection-invalid-target')}
|
||||
style={{
|
||||
top: 20,
|
||||
width: 20,
|
||||
height: 20,
|
||||
left: -15,
|
||||
borderRadius: '9999px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<ArrowDownLeft className="w-4 h-4 text-foreground pointer-events-none" />
|
||||
</Handle>
|
||||
)
|
||||
}
|
||||
|
||||
export function OutputHandle({ id }: NodeHandleProps) {
|
||||
return (
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id={id}
|
||||
style={{
|
||||
top: 20,
|
||||
width: 20,
|
||||
height: 20,
|
||||
right: -15,
|
||||
borderRadius: '9999px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<ArrowUpRight className="w-4 h-4 text-foreground pointer-events-none" />
|
||||
</Handle>
|
||||
)
|
||||
}
|
||||
|
||||
83
frontend/src/components/graph/NodeHeaderTitle.tsx
Normal file
83
frontend/src/components/graph/NodeHeaderTitle.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import FlowContext from '../../lib/graph/flowContext'
|
||||
import type { AppNode } from '../../lib/graph/nodeTypes'
|
||||
import { replaceNodeIdInGraph } from '../../lib/graph/flowUtils'
|
||||
|
||||
type Props = {
|
||||
nodeId: string
|
||||
displayTitle: string
|
||||
}
|
||||
|
||||
export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
|
||||
const ctx = useContext(FlowContext)
|
||||
const nodes = ctx?.nodes ?? []
|
||||
const setNodes = ctx?.setNodes
|
||||
const edges = ctx?.edges ?? []
|
||||
const setEdges = ctx?.setEdges
|
||||
const renamingNodeId = ctx?.renamingNodeId ?? null
|
||||
const setRenamingNodeId = ctx?.setRenamingNodeId
|
||||
|
||||
const [inputValue, setInputValue] = useState(nodeId)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const isRenaming = renamingNodeId === nodeId
|
||||
|
||||
useEffect(() => {
|
||||
if (isRenaming) {
|
||||
setInputValue(nodeId)
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
}
|
||||
}, [isRenaming, nodeId])
|
||||
|
||||
const applyRename = useCallback(() => {
|
||||
if (!setNodes || !setEdges || !setRenamingNodeId) return
|
||||
const newId = inputValue.trim()
|
||||
if (!newId || newId === nodeId) {
|
||||
setRenamingNodeId(null)
|
||||
return
|
||||
}
|
||||
const existingIds = nodes.map((n: any) => n.id)
|
||||
if (existingIds.includes(newId)) {
|
||||
return
|
||||
}
|
||||
const { nodes: nextNodes, edges: nextEdges } = replaceNodeIdInGraph(nodes, edges, nodeId, newId)
|
||||
setNodes(nextNodes as AppNode[])
|
||||
setEdges(nextEdges)
|
||||
setRenamingNodeId(null)
|
||||
}, [nodeId, inputValue, nodes, edges, setNodes, setEdges, setRenamingNodeId])
|
||||
|
||||
const cancelRename = useCallback(() => {
|
||||
setRenamingNodeId?.(null)
|
||||
}, [setRenamingNodeId])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
applyRename()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelRename()
|
||||
}
|
||||
},
|
||||
[applyRename, cancelRename]
|
||||
)
|
||||
|
||||
if (!isRenaming) {
|
||||
return <>{displayTitle}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={cancelRename}
|
||||
className="nodrag nopan flex-1 min-w-0 rounded border border-input bg-background px-1.5 py-0 text-sm font-semibold outline-none focus:ring-1 focus:ring-ring"
|
||||
data-slot="base-node-title"
|
||||
/>
|
||||
)
|
||||
}
|
||||
35
frontend/src/components/graph/NodeHelpPopover.tsx
Normal file
35
frontend/src/components/graph/NodeHelpPopover.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react'
|
||||
import { HelpCircle } from 'lucide-react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
|
||||
import { getNodeHelp } from '../../lib/graph/nodeRegistry'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
type Props = {
|
||||
nodeType: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function NodeHelpPopover({ nodeType, className }: Props) {
|
||||
const { title, content } = getNodeHelp(nodeType)
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
className={cn(
|
||||
'shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||
className
|
||||
)}
|
||||
aria-label={`Help: ${title}`}
|
||||
>
|
||||
<button type="button">
|
||||
<HelpCircle className="size-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="end" className="w-80 max-h-[70vh] overflow-y-auto">
|
||||
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||
<div className="mt-2">{content}</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
130
frontend/src/components/graph/NodeMenubar.tsx
Normal file
130
frontend/src/components/graph/NodeMenubar.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React, { useCallback, useContext } from 'react'
|
||||
import FlowContext from '../../lib/graph/flowContext'
|
||||
import {
|
||||
Menubar,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarMenu,
|
||||
MenubarSeparator,
|
||||
MenubarShortcut,
|
||||
MenubarSub,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
MenubarTrigger,
|
||||
} from '../ui/menubar'
|
||||
import { Kbd } from '../ui/kbd'
|
||||
type Props = {
|
||||
nodeId: string
|
||||
nodeType: string
|
||||
/** Content for Insert → Inputs (function nodes); when inputsMenuContent is set, Inputs is a separate menu and this is not used in Insert */
|
||||
editInputsContent?: React.ReactNode
|
||||
/** When set, Inputs is rendered as its own top-level menu (config nodes); Insert then only shows markup-specific options */
|
||||
inputsMenuContent?: React.ReactNode
|
||||
/** Content for Insert → [Blocks/Tags] (e.g. type-specific snippets, config nodes) */
|
||||
insertTagsContent?: React.ReactNode
|
||||
/** Label for the Insert/Blocks menu trigger (default "Insert") */
|
||||
insertMenuLabel?: string
|
||||
/** When true, render insertTagsContent directly in the menu (no submenu level). Use for flat Blocks list. */
|
||||
insertContentDirect?: boolean
|
||||
/** Label for Insert submenu that shows insertTagsContent when not insertContentDirect (default "Tags") */
|
||||
insertTagsLabel?: string
|
||||
/** Extra content in Node menu (e.g. Export submenu for render nodes), before the separator */
|
||||
nodeMenuExtraContent?: React.ReactNode
|
||||
/** When set, renders a top-level "Data" menu with this content (e.g. data nodes) */
|
||||
dataMenuContent?: React.ReactNode
|
||||
}
|
||||
|
||||
export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuContent, insertTagsContent, insertMenuLabel = 'Insert', insertContentDirect, insertTagsLabel = 'Tags', nodeMenuExtraContent, dataMenuContent }: Props) {
|
||||
const ctx = useContext(FlowContext)
|
||||
const nodes = ctx?.nodes ?? []
|
||||
const setNodes = ctx?.setNodes
|
||||
const setEdges = ctx?.setEdges
|
||||
|
||||
const edges = ctx?.edges ?? []
|
||||
const node = nodes.find((n: any) => n.id === nodeId)
|
||||
const hasEdit = nodeType === 'config' || nodeType === 'function'
|
||||
const hasConnectedNodes = edges.some((e: any) => e.target === nodeId)
|
||||
|
||||
const onDelete = useCallback(() => {
|
||||
if (!setNodes || !setEdges) return
|
||||
setNodes((nds: any[]) => nds.filter((n: any) => n.id !== nodeId))
|
||||
setEdges((eds: any[]) => eds.filter((e: any) => e.source !== nodeId && e.target !== nodeId))
|
||||
}, [nodeId, setNodes, setEdges])
|
||||
|
||||
const onRename = useCallback(() => {
|
||||
ctx?.setRenamingNodeId?.(nodeId)
|
||||
}, [nodeId, ctx])
|
||||
|
||||
return (
|
||||
<Menubar className="h-auto min-h-0 flex items-center bg-none p-1 border-t-0 border-l-0 border-r-0 border-b border-b-secondary shadow-none rounded-none text-muted-foreground">
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="px-1.5 py-0 text-xs">
|
||||
Node
|
||||
</MenubarTrigger>
|
||||
<MenubarContent className="min-w-[12rem]">
|
||||
<MenubarItem className="text-xs" onClick={onRename}>
|
||||
Rename
|
||||
</MenubarItem>
|
||||
{nodeMenuExtraContent}
|
||||
<MenubarSeparator />
|
||||
<MenubarItem className="text-xs text-destructive focus:text-destructive" onClick={onDelete}>
|
||||
Delete
|
||||
<MenubarShortcut className="ml-auto pl-4"><Kbd>⌫</Kbd></MenubarShortcut>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
{dataMenuContent != null && (
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="px-1.5 py-0 text-xs">
|
||||
Data
|
||||
</MenubarTrigger>
|
||||
<MenubarContent className="min-w-[12rem]">
|
||||
{dataMenuContent}
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
)}
|
||||
{inputsMenuContent != null && (
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="px-1.5 py-0 text-xs" disabled={!hasConnectedNodes}>
|
||||
Inputs
|
||||
</MenubarTrigger>
|
||||
<MenubarContent className="min-w-[12rem]">
|
||||
{inputsMenuContent}
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
)}
|
||||
{hasEdit && (insertTagsContent != null || (editInputsContent != null && inputsMenuContent == null)) && (
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="px-1.5 py-0 text-xs">
|
||||
{insertMenuLabel}
|
||||
</MenubarTrigger>
|
||||
<MenubarContent className="min-w-[10rem]">
|
||||
{inputsMenuContent == null && editInputsContent != null && (
|
||||
<MenubarSub>
|
||||
<MenubarSubTrigger className="text-xs" disabled={!hasConnectedNodes}>
|
||||
Inputs
|
||||
</MenubarSubTrigger>
|
||||
<MenubarSubContent className="min-w-[12rem]">
|
||||
{editInputsContent}
|
||||
</MenubarSubContent>
|
||||
</MenubarSub>
|
||||
)}
|
||||
{insertTagsContent != null &&
|
||||
(insertContentDirect ? (
|
||||
insertTagsContent
|
||||
) : (
|
||||
<MenubarSub>
|
||||
<MenubarSubTrigger className="text-xs">
|
||||
{insertTagsLabel}
|
||||
</MenubarSubTrigger>
|
||||
<MenubarSubContent className="min-w-[12rem]">
|
||||
{insertTagsContent}
|
||||
</MenubarSubContent>
|
||||
</MenubarSub>
|
||||
))}
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
)}
|
||||
</Menubar>
|
||||
)
|
||||
}
|
||||
206
frontend/src/components/graph/NodeStatusIndicator.tsx
Normal file
206
frontend/src/components/graph/NodeStatusIndicator.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type NodeStatus = 'loading' | 'success' | 'error' | 'initial'
|
||||
|
||||
export type NodeStatusVariant = 'overlay' | 'border'
|
||||
|
||||
export type NodeStatusIndicatorProps = {
|
||||
status?: NodeStatus
|
||||
variant?: NodeStatusVariant
|
||||
children: ReactNode
|
||||
/** Optional: node width/height so the spinner can match the border exactly */
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
const R = 6 // rounded corner radius (0.375rem ≈ 6px)
|
||||
|
||||
const DURATION_MS = 2400
|
||||
|
||||
/** Ease-out with overshoot: 0→70%→85%→100% keyframe values */
|
||||
function dashOffsetAtProgress(progress: number, pathLength: number): number {
|
||||
if (progress <= 0.7) return ((pathLength + 30) * progress) / 0.7
|
||||
if (progress <= 0.85)
|
||||
return pathLength + 30 + (25 * (progress - 0.7)) / 0.15
|
||||
return pathLength + 55 - (5 * (progress - 0.85)) / 0.15
|
||||
}
|
||||
|
||||
/** One solid segment (half path length) moving along the border, with gradient opacity 0→1 along the segment.
|
||||
* Uses ResizeObserver so the border always matches the actual rendered node size (avoids min-width/min-height mismatch). */
|
||||
function BorderLoadingIndicator({
|
||||
children,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
children: ReactNode
|
||||
width?: number
|
||||
height?: number
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [measured, setMeasured] = useState({ w: 0, h: 0 })
|
||||
const hasMeasured = measured.w > 0 && measured.h > 0
|
||||
const fallbackW = (width != null && height != null && width > 0 && height > 0) ? width + 4 : 0
|
||||
const fallbackH = (width != null && height != null && width > 0 && height > 0) ? height + 4 : 0
|
||||
const w = hasMeasured ? measured.w + 4 : fallbackW
|
||||
const h = hasMeasured ? measured.h + 4 : fallbackH
|
||||
const hasSize = w > 0 && h > 0
|
||||
const pathD = hasSize
|
||||
? `M ${R + 2} ${2} L ${w - R - 2} ${2} Q ${w - 2} ${2} ${w - 2} ${R + 2} L ${w - 2} ${h - R - 2} Q ${w - 2} ${h - 2} ${w - R - 2} ${h - 2} L ${R + 2} ${h - 2} Q ${2} ${h - 2} ${2} ${h - R - 2} L ${2} ${R + 2} Q ${2} ${2} ${R + 2} ${2} Z`
|
||||
: ''
|
||||
const gradientId = useId().replace(/:/g, '-')
|
||||
const pathRef = useRef<SVGPathElement>(null)
|
||||
const gradientRef = useRef<SVGLinearGradientElement>(null)
|
||||
const rafRef = useRef<number>(0)
|
||||
const startTimeRef = useRef<number>(0)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
// Observe the first child (BaseNode) so we use its actual rendered size.
|
||||
const target =
|
||||
container.firstElementChild instanceof HTMLElement
|
||||
? container.firstElementChild
|
||||
: container
|
||||
const syncMeasure = () => {
|
||||
const cw = (target as HTMLElement).offsetWidth
|
||||
const ch = (target as HTMLElement).offsetHeight
|
||||
if (cw > 0 && ch > 0) {
|
||||
queueMicrotask(() => setMeasured({ w: cw, h: ch }))
|
||||
}
|
||||
}
|
||||
syncMeasure()
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (!entry) return
|
||||
const { width: cw, height: ch } = entry.contentRect
|
||||
setMeasured({ w: Math.round(cw), h: Math.round(ch) })
|
||||
})
|
||||
ro.observe(target)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!hasSize) return
|
||||
const pathEl = pathRef.current
|
||||
const gradientEl = gradientRef.current
|
||||
if (!pathEl || !gradientEl) return
|
||||
|
||||
const totalLen = pathEl.getTotalLength()
|
||||
const segmentLen = totalLen * 0.5
|
||||
const gapLen = totalLen * 0.5 + 80
|
||||
|
||||
pathEl.style.strokeDasharray = `${segmentLen} ${gapLen}`
|
||||
|
||||
const tick = () => {
|
||||
const elapsed = (performance.now() - startTimeRef.current) % DURATION_MS
|
||||
const progress = Math.min(1, elapsed / DURATION_MS)
|
||||
const dashOffset = dashOffsetAtProgress(progress, totalLen)
|
||||
|
||||
pathEl.style.strokeDashoffset = String(dashOffset)
|
||||
|
||||
const startLen = dashOffset % totalLen
|
||||
const endLen = (dashOffset + segmentLen) % totalLen
|
||||
const startPt = pathEl.getPointAtLength(startLen)
|
||||
const endPt = pathEl.getPointAtLength(endLen)
|
||||
gradientEl.setAttribute('x1', String(startPt.x))
|
||||
gradientEl.setAttribute('y1', String(startPt.y))
|
||||
gradientEl.setAttribute('x2', String(endPt.x))
|
||||
gradientEl.setAttribute('y2', String(endPt.y))
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
startTimeRef.current = performance.now()
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(rafRef.current)
|
||||
}, [hasSize, w, h])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full h-full overflow-visible">
|
||||
{children}
|
||||
{hasSize && (
|
||||
<svg
|
||||
className="absolute pointer-events-none z-10"
|
||||
style={{ top: -2, left: -2, width: w, height: h }}
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
ref={gradientRef}
|
||||
id={gradientId}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="1"
|
||||
y2="0"
|
||||
>
|
||||
<stop offset="0" stopColor="hsl(var(--primary))" stopOpacity="0" />
|
||||
<stop offset="1" stopColor="hsl(var(--primary))" stopOpacity="1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
ref={pathRef}
|
||||
className="fill-none stroke-[2]"
|
||||
d={pathD}
|
||||
stroke={`url(#${gradientId})`}
|
||||
style={{ strokeLinecap: 'round' }}
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{!hasSize && (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes node-status-pulse {
|
||||
0%, 100% { opacity: 0.35; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
className="absolute -inset-[2px] rounded-md border-[1.5px] border-primary pointer-events-none z-10"
|
||||
style={{ opacity: 0.8, animation: 'node-status-pulse 2s ease-in-out infinite' }}
|
||||
aria-hidden
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Error state: red/destructive border around the node */
|
||||
function ErrorStatusBorder({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn('rounded-md ring-2 ring-destructive', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Success state: optional subtle border (e.g. green) - not required per user, keep minimal */
|
||||
function SuccessStatusBorder({ children }: { children: ReactNode }) {
|
||||
return <div className="rounded-md">{children}</div>
|
||||
}
|
||||
|
||||
export function NodeStatusIndicator({
|
||||
status,
|
||||
variant = 'border',
|
||||
children,
|
||||
width,
|
||||
height,
|
||||
}: NodeStatusIndicatorProps) {
|
||||
switch (status) {
|
||||
case 'loading':
|
||||
return variant === 'border' ? (
|
||||
<BorderLoadingIndicator width={width} height={height}>{children}</BorderLoadingIndicator>
|
||||
) : (
|
||||
<>{children}</>
|
||||
)
|
||||
case 'error':
|
||||
return <ErrorStatusBorder>{children}</ErrorStatusBorder>
|
||||
case 'success':
|
||||
return <SuccessStatusBorder>{children}</SuccessStatusBorder>
|
||||
default:
|
||||
return <>{children}</>
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user