summaryrefslogblamecommitdiff
path: root/makima/frontend/src/components/chains/ChainEditor.tsx
blob: 0dcabe173ffd1e2a43ffea4e4cfe3a57aa1e36bd (plain) (tree)
1
2
3
4
5
6
7






                                                               





                                              






































































































                                                                                                 


                                                                                       




                                                   
                                                            
                                                           

                 
                            





















































































































































































































































                                                                                                                                                          

                                                                    
























































































                                                                                                                                                   
import { useState, useCallback, useMemo, useRef } from "react";
import type {
  ChainWithContracts,
  ChainGraphResponse,
  ChainContractDetail,
} from "../../lib/api";

const statusColors: Record<string, string> = {
  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<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.name}</h2>
              {chain.description && (
                <p className="font-mono text-xs text-[#8b949e]">{chain.description}</p>
              )}
            </div>
          </div>
          <div className="flex items-center gap-2">
            <span
              className={`font-mono text-[10px] uppercase ${
                statusColors[chain.status] || "text-[#555]"
              }`}
            >
              {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={`font-mono text-xs uppercase ${
              statusColors[contract.contractStatus] || "text-[#555]"
            }`}
          >
            {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>
  );
}