import { useState, useCallback, useMemo, useRef } from "react";
import type {
ChainWithContracts,
ChainGraphResponse,
ChainContractDetail,
} from "../../lib/api";
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<HTMLDivElement>(null);
const [selectedNode, setSelectedNode] = useState<string | null>(null);
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
// Use positions from graph nodes directly (x, y from server)
const nodePositions = useMemo(() => {
if (!graph?.nodes) return new Map<string, { x: number; y: number }>();
const positions = new Map<string, { x: number; y: number }>();
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 (
<div className="panel h-full flex flex-col">
{/* Header */}
<div className="p-3 border-b border-[rgba(117,170,252,0.2)]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors"
>
Back
</button>
<div>
<h2 className="font-mono text-sm text-[#dbe7ff]">{chain.chain.name}</h2>
{chain.chain.description && (
<p className="font-mono text-xs text-[#8b949e]">{chain.chain.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<span
className={`px-2 py-1 font-mono text-[10px] uppercase rounded ${
chain.chain.status === "active"
? "text-[#4ade80] bg-[#4ade80]/10"
: chain.chain.status === "completed"
? "text-[#60a5fa] bg-[#60a5fa]/10"
: "text-[#6b7280] bg-[#6b7280]/10"
}`}
>
{chain.chain.status}
</span>
<button
onClick={onRefresh}
className="px-3 py-1 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] border border-[#3f6fb3] hover:border-[#75aafc] transition-colors"
>
Refresh
</button>
</div>
</div>
</div>
{/* Main content */}
<div className="flex-1 flex min-h-0">
{/* DAG Canvas */}
<div className="flex-1 overflow-auto bg-[#050d18]">
{loading ? (
<div className="flex items-center justify-center h-full">
<p className="font-mono text-xs text-[#8b949e]">Loading graph...</p>
</div>
) : !graph || graph.nodes.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="font-mono text-sm text-[#8b949e] mb-2">
No contracts in this chain yet
</p>
<p className="font-mono text-xs text-[#556677]">
Contracts will appear here once added via CLI or API
</p>
</div>
</div>
) : (
<div
ref={canvasRef}
className="relative"
style={{
width: canvasDimensions.width,
height: canvasDimensions.height,
minWidth: "100%",
minHeight: "100%",
}}
>
{/* SVG layer for edges */}
<svg
className="absolute inset-0 pointer-events-none"
style={{
width: canvasDimensions.width,
height: canvasDimensions.height,
}}
>
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon
points="0 0, 10 3.5, 0 7"
fill="#75aafc"
opacity="0.6"
/>
</marker>
</defs>
{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 (
<path
key={`${edge.from}-${edge.to}-${index}`}
d={`M ${startX} ${startY} C ${startX} ${midY}, ${endX} ${midY}, ${endX} ${endY}`}
fill="none"
stroke={isHighlighted ? "#75aafc" : "#3f6fb3"}
strokeWidth={isHighlighted ? 2 : 1.5}
strokeDasharray={isHighlighted ? "none" : "4 2"}
markerEnd="url(#arrowhead)"
opacity={isHighlighted ? 1 : 0.6}
/>
);
})}
</svg>
{/* 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 (
<div
key={node.contractId}
onClick={() => 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)",
}}
>
<div
className="w-full h-full rounded-lg border-2 bg-[#0a1628] overflow-hidden"
style={{
borderColor: isSelected ? "#75aafc" : colors.border,
}}
>
{/* Status indicator bar */}
<div
className="h-1.5"
style={{ backgroundColor: colors.bg }}
/>
{/* Content */}
<div className="p-2">
<div className="flex items-center justify-between mb-1">
<span className="font-mono text-xs text-[#dbe7ff] truncate flex-1">
{node.name}
</span>
<ChainIcon className="w-4 h-4 text-[#75aafc] opacity-50 flex-shrink-0 ml-1" />
</div>
<div className="flex items-center justify-between">
<span
className="font-mono text-[10px] uppercase px-1.5 py-0.5 rounded"
style={{
color: colors.bg,
backgroundColor: `${colors.bg}20`,
}}
>
{node.status}
</span>
{node.phase && (
<span className="font-mono text-[10px] text-[#8b949e]">
{node.phase}
</span>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Detail panel */}
{selectedContract && (
<ContractDetailPanel
contract={selectedContract}
allContracts={chain.contracts}
onClose={() => setSelectedNode(null)}
onSelectContract={setSelectedNode}
onOpenContract={onContractClick}
/>
)}
</div>
{/* Footer with stats */}
<div className="p-3 border-t border-[rgba(117,170,252,0.2)] bg-[#0a1628]">
<div className="flex items-center gap-4 font-mono text-[10px] text-[#8b949e]">
<span>{chain.contracts.length} contracts</span>
<span>
{chain.contracts.filter((c) => c.contractStatus === "completed").length} completed
</span>
<span>
{chain.contracts.filter((c) => c.contractStatus === "active").length} active
</span>
<span className="flex-1" />
<span>Double-click node to open contract</span>
</div>
</div>
</div>
);
}
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 (
<div className="w-72 border-l border-[rgba(117,170,252,0.2)] bg-[#0a1628] overflow-y-auto">
<div className="p-3 border-b border-[rgba(117,170,252,0.2)]">
<div className="flex items-center justify-between mb-2">
<h3 className="font-mono text-xs text-[#75aafc] uppercase">
Contract Details
</h3>
<button
onClick={onClose}
className="font-mono text-xs text-[#8b949e] hover:text-[#dbe7ff]"
>
Close
</button>
</div>
</div>
<div className="p-3 space-y-4">
{/* Name */}
<div>
<label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
Name
</label>
<p className="font-mono text-sm text-[#dbe7ff]">
{contract.contractName}
</p>
</div>
{/* Status */}
<div>
<label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
Status
</label>
<span
className={`inline-block px-2 py-1 font-mono text-xs uppercase rounded ${
contract.contractStatus === "active"
? "text-[#4ade80] bg-[#4ade80]/10"
: contract.contractStatus === "completed"
? "text-[#60a5fa] bg-[#60a5fa]/10"
: "text-[#6b7280] bg-[#6b7280]/10"
}`}
>
{contract.contractStatus}
</span>
</div>
{/* Phase */}
<div>
<label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
Phase
</label>
<span className="font-mono text-sm text-[#dbe7ff]">
{contract.contractPhase}
</span>
</div>
{/* Dependencies */}
{contract.dependsOn && contract.dependsOn.length > 0 && (
<div>
<label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
Depends On
</label>
<div className="space-y-1">
{contract.dependsOn.map((depId) => {
const dep = allContracts.find((c) => c.contractId === depId);
return (
<button
key={depId}
onClick={() => onSelectContract(depId)}
className="block w-full text-left font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] truncate"
>
{dep?.contractName || depId}
</button>
);
})}
</div>
</div>
)}
{/* Order Index */}
<div>
<label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
Order Index
</label>
<p className="font-mono text-xs text-[#dbe7ff]">
{contract.orderIndex}
</p>
</div>
{/* Created */}
<div>
<label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
Created
</label>
<p className="font-mono text-xs text-[#dbe7ff]">
{new Date(contract.createdAt).toLocaleString()}
</p>
</div>
{/* Actions */}
<div className="pt-2 border-t border-[rgba(117,170,252,0.2)]">
<button
onClick={() => onOpenContract(contract.contractId)}
className="w-full px-3 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors"
>
Open Contract
</button>
</div>
</div>
</div>
);
}
function ChainIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
);
}