import { useState, useCallback, useMemo, useRef } from "react"; import type { ChainWithContracts, ChainGraphResponse, ChainContractDetail, } from "../../lib/api"; const statusColors: Record = { active: "text-green-400", completed: "text-blue-400", archived: "text-[#555]", }; interface ChainEditorProps { chain: ChainWithContracts; graph: ChainGraphResponse | null; loading: boolean; onBack: () => void; onRefresh: () => void; onContractClick: (contractId: string) => void; } // Node dimensions const NODE_WIDTH = 180; const NODE_HEIGHT = 80; const CANVAS_PADDING = 40; export function ChainEditor({ chain, graph, loading, onBack, onRefresh, onContractClick, }: ChainEditorProps) { const canvasRef = useRef(null); const [selectedNode, setSelectedNode] = useState(null); const [hoveredNode, setHoveredNode] = useState(null); // Use positions from graph nodes directly (x, y from server) const nodePositions = useMemo(() => { if (!graph?.nodes) return new Map(); const positions = new Map(); for (const node of graph.nodes) { positions.set(node.contractId, { x: CANVAS_PADDING + (node.x || 0) * (NODE_WIDTH + 60), y: CANVAS_PADDING + (node.y || 0) * (NODE_HEIGHT + 40), }); } return positions; }, [graph?.nodes]); // Canvas dimensions const canvasDimensions = useMemo(() => { if (nodePositions.size === 0) { return { width: 600, height: 400 }; } let maxX = 0; let maxY = 0; for (const pos of nodePositions.values()) { maxX = Math.max(maxX, pos.x + NODE_WIDTH); maxY = Math.max(maxY, pos.y + NODE_HEIGHT); } return { width: Math.max(600, maxX + CANVAS_PADDING), height: Math.max(400, maxY + CANVAS_PADDING), }; }, [nodePositions]); const handleNodeClick = useCallback((contractId: string) => { setSelectedNode(contractId); }, []); const handleNodeDoubleClick = useCallback( (contractId: string) => { onContractClick(contractId); }, [onContractClick] ); const getStatusColor = (status: string) => { switch (status) { case "active": return { bg: "#4ade80", border: "#22c55e", text: "#166534" }; case "completed": return { bg: "#60a5fa", border: "#3b82f6", text: "#1e40af" }; case "pending": return { bg: "#f59e0b", border: "#d97706", text: "#92400e" }; case "blocked": return { bg: "#ef4444", border: "#dc2626", text: "#991b1b" }; default: return { bg: "#6b7280", border: "#4b5563", text: "#374151" }; } }; // Find selected contract from chain.contracts const selectedContract = selectedNode ? chain.contracts.find((c) => c.contractId === selectedNode) : null; return (
{/* Header */}

{chain.name}

{chain.description && (

{chain.description}

)}
{chain.status}
{/* Main content */}
{/* DAG Canvas */}
{loading ? (

Loading graph...

) : !graph || graph.nodes.length === 0 ? (

No contracts in this chain yet

Contracts will appear here once added via CLI or API

) : (
{/* SVG layer for edges */} {graph.edges.map((edge, index) => { const fromPos = nodePositions.get(edge.from); const toPos = nodePositions.get(edge.to); if (!fromPos || !toPos) return null; // Calculate edge path (from bottom of source to top of target) const startX = fromPos.x + NODE_WIDTH / 2; const startY = fromPos.y + NODE_HEIGHT; const endX = toPos.x + NODE_WIDTH / 2; const endY = toPos.y; // Bezier control points for smooth curves const midY = (startY + endY) / 2; const isHighlighted = hoveredNode === edge.from || hoveredNode === edge.to; return ( ); })} {/* Node layer */} {graph.nodes.map((node) => { const pos = nodePositions.get(node.contractId); if (!pos) return null; const colors = getStatusColor(node.status); const isSelected = selectedNode === node.contractId; const isHovered = hoveredNode === node.contractId; return (
handleNodeClick(node.contractId)} onDoubleClick={() => handleNodeDoubleClick(node.contractId)} onMouseEnter={() => setHoveredNode(node.contractId)} onMouseLeave={() => setHoveredNode(null)} className={`absolute cursor-pointer transition-all duration-150 ${ isSelected ? "ring-2 ring-[#75aafc] ring-offset-2 ring-offset-[#050d18]" : "" }`} style={{ left: pos.x, top: pos.y, width: NODE_WIDTH, height: NODE_HEIGHT, transform: isHovered ? "scale(1.02)" : "scale(1)", }} >
{/* Status indicator bar */}
{/* Content */}
{node.name}
{node.status} {node.phase && ( {node.phase} )}
); })}
)}
{/* Detail panel */} {selectedContract && ( setSelectedNode(null)} onSelectContract={setSelectedNode} onOpenContract={onContractClick} /> )}
{/* Footer with stats */}
{chain.contracts.length} contracts {chain.contracts.filter((c) => c.contractStatus === "completed").length} completed {chain.contracts.filter((c) => c.contractStatus === "active").length} active Double-click node to open contract
); } interface ContractDetailPanelProps { contract: ChainContractDetail; allContracts: ChainContractDetail[]; onClose: () => void; onSelectContract: (contractId: string) => void; onOpenContract: (contractId: string) => void; } function ContractDetailPanel({ contract, allContracts, onClose, onSelectContract, onOpenContract, }: ContractDetailPanelProps) { return (

Contract Details

{/* Name */}

{contract.contractName}

{/* Status */}
{contract.contractStatus}
{/* Phase */}
{contract.contractPhase}
{/* Dependencies */} {contract.dependsOn && contract.dependsOn.length > 0 && (
{contract.dependsOn.map((depId) => { const dep = allContracts.find((c) => c.contractId === depId); return ( ); })}
)} {/* Order Index */}

{contract.orderIndex}

{/* Created */}

{new Date(contract.createdAt).toLocaleString()}

{/* Actions */}
); } function ChainIcon({ className }: { className?: string }) { return ( ); }