summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/chains/ChainEditor.tsx
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-02-03 22:01:29 +0000
committersoryu <soryu@soryu.co>2026-02-03 22:01:37 +0000
commitcf0a25af1d2834bfe6c5ea892ce5769936e5a673 (patch)
tree476ba326ac1752281a441b5c17d2b3be4b23a2a9 /makima/frontend/src/components/chains/ChainEditor.tsx
parent8361916ce67f3d2ba191ebf27cb50e79cb42e39c (diff)
downloadsoryu-cf0a25af1d2834bfe6c5ea892ce5769936e5a673.tar.gz
soryu-cf0a25af1d2834bfe6c5ea892ce5769936e5a673.zip
Add makima chain mechanism
Diffstat (limited to 'makima/frontend/src/components/chains/ChainEditor.tsx')
-rw-r--r--makima/frontend/src/components/chains/ChainEditor.tsx468
1 files changed, 468 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>
+ );
+}