diff options
Diffstat (limited to 'makima/frontend/src/components/chains')
| -rw-r--r-- | makima/frontend/src/components/chains/ChainEditor.tsx | 468 | ||||
| -rw-r--r-- | makima/frontend/src/components/chains/ChainList.tsx | 205 |
2 files changed, 673 insertions, 0 deletions
diff --git a/makima/frontend/src/components/chains/ChainEditor.tsx b/makima/frontend/src/components/chains/ChainEditor.tsx new file mode 100644 index 0000000..9077d19 --- /dev/null +++ b/makima/frontend/src/components/chains/ChainEditor.tsx @@ -0,0 +1,468 @@ +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> + ); +} diff --git a/makima/frontend/src/components/chains/ChainList.tsx b/makima/frontend/src/components/chains/ChainList.tsx new file mode 100644 index 0000000..eda79d7 --- /dev/null +++ b/makima/frontend/src/components/chains/ChainList.tsx @@ -0,0 +1,205 @@ +import { useState, useCallback } from "react"; +import type { ChainSummary, ChainStatus } from "../../lib/api"; + +interface ChainListProps { + chains: ChainSummary[]; + loading: boolean; + onSelect: (chainId: string) => void; + onCreate: () => void; + selectedId?: string; + onArchive: (chain: ChainSummary) => void; +} + +export function ChainList({ + chains, + loading, + onSelect, + onCreate, + selectedId, + onArchive, +}: ChainListProps) { + const [statusFilter, setStatusFilter] = useState<ChainStatus | "all">("all"); + const [contextMenu, setContextMenu] = useState<{ + chain: ChainSummary; + x: number; + y: number; + } | null>(null); + + const filteredChains = chains.filter((chain) => + statusFilter === "all" ? true : chain.status === statusFilter + ); + + const handleContextMenu = useCallback( + (e: React.MouseEvent, chain: ChainSummary) => { + e.preventDefault(); + setContextMenu({ chain, x: e.clientX, y: e.clientY }); + }, + [] + ); + + const closeContextMenu = useCallback(() => { + setContextMenu(null); + }, []); + + const handleArchive = useCallback(() => { + if (contextMenu) { + onArchive(contextMenu.chain); + setContextMenu(null); + } + }, [contextMenu, onArchive]); + + const getStatusColor = (status: ChainStatus) => { + switch (status) { + case "active": + return "text-[#4ade80] bg-[#4ade80]/10"; + case "completed": + return "text-[#60a5fa] bg-[#60a5fa]/10"; + case "archived": + return "text-[#6b7280] bg-[#6b7280]/10"; + default: + return "text-[#8b949e] bg-[#8b949e]/10"; + } + }; + + const getStatusIcon = (status: ChainStatus) => { + switch (status) { + case "active": + return ( + <svg className="w-3 h-3" viewBox="0 0 24 24" fill="currentColor"> + <circle cx="12" cy="12" r="4" /> + </svg> + ); + case "completed": + return ( + <svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"> + <polyline points="20 6 9 17 4 12" /> + </svg> + ); + case "archived": + return ( + <svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <path d="M21 8v13H3V8" /> + <path d="M1 3h22v5H1z" /> + <path d="M10 12h4" /> + </svg> + ); + default: + return null; + } + }; + + return ( + <div className="panel h-full flex flex-col" onClick={closeContextMenu}> + {/* Header */} + <div className="p-3 border-b border-[rgba(117,170,252,0.2)]"> + <div className="flex items-center justify-between mb-3"> + <h2 className="font-mono text-sm text-[#75aafc] uppercase">Chains</h2> + <button + onClick={onCreate} + className="px-3 py-1 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors" + > + + New + </button> + </div> + + {/* Status filter */} + <div className="flex gap-1"> + {(["all", "active", "completed", "archived"] as const).map((status) => ( + <button + key={status} + onClick={() => setStatusFilter(status)} + className={`px-2 py-1 font-mono text-[10px] uppercase transition-colors ${ + statusFilter === status + ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]" + : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]" + }`} + > + {status} + </button> + ))} + </div> + </div> + + {/* Chain list */} + <div className="flex-1 overflow-y-auto"> + {loading ? ( + <div className="flex items-center justify-center h-32"> + <p className="font-mono text-xs text-[#8b949e]">Loading chains...</p> + </div> + ) : filteredChains.length === 0 ? ( + <div className="flex items-center justify-center h-32"> + <p className="font-mono text-xs text-[#8b949e]"> + {statusFilter === "all" ? "No chains yet" : `No ${statusFilter} chains`} + </p> + </div> + ) : ( + <div className="divide-y divide-[rgba(117,170,252,0.1)]"> + {filteredChains.map((chain) => ( + <div + key={chain.id} + onClick={() => onSelect(chain.id)} + onContextMenu={(e) => handleContextMenu(e, chain)} + className={`p-3 cursor-pointer transition-colors ${ + selectedId === chain.id + ? "bg-[rgba(117,170,252,0.15)]" + : "hover:bg-[rgba(117,170,252,0.05)]" + }`} + > + <div className="flex items-center justify-between mb-1"> + <span className="font-mono text-sm text-[#dbe7ff] truncate"> + {chain.name} + </span> + <span + className={`flex items-center gap-1 px-1.5 py-0.5 font-mono text-[10px] uppercase rounded ${getStatusColor( + chain.status + )}`} + > + {getStatusIcon(chain.status)} + {chain.status} + </span> + </div> + {chain.description && ( + <p className="font-mono text-xs text-[#8b949e] truncate mb-1"> + {chain.description} + </p> + )} + <div className="flex items-center gap-3 font-mono text-[10px] text-[#556677]"> + <span>{chain.contractCount} contracts</span> + <span> + {new Date(chain.updatedAt).toLocaleDateString()} + </span> + </div> + </div> + ))} + </div> + )} + </div> + + {/* Context menu */} + {contextMenu && ( + <div + className="fixed z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-lg py-1" + style={{ top: contextMenu.y, left: contextMenu.x }} + > + <button + onClick={() => { + onSelect(contextMenu.chain.id); + setContextMenu(null); + }} + className="w-full px-4 py-2 text-left font-mono text-xs text-[#dbe7ff] hover:bg-[rgba(117,170,252,0.1)]" + > + View Details + </button> + {contextMenu.chain.status !== "archived" && ( + <button + onClick={handleArchive} + className="w-full px-4 py-2 text-left font-mono text-xs text-red-400 hover:bg-red-400/10" + > + Archive + </button> + )} + </div> + )} + </div> + ); +} |
