summaryrefslogtreecommitdiff
path: root/makima/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src')
-rw-r--r--makima/frontend/src/components/chains/ChainEditor.tsx121
-rw-r--r--makima/frontend/src/lib/api.ts2
2 files changed, 114 insertions, 9 deletions
diff --git a/makima/frontend/src/components/chains/ChainEditor.tsx b/makima/frontend/src/components/chains/ChainEditor.tsx
index 09d590a..a4c8a39 100644
--- a/makima/frontend/src/components/chains/ChainEditor.tsx
+++ b/makima/frontend/src/components/chains/ChainEditor.tsx
@@ -67,11 +67,12 @@ export function ChainEditor({
toPoint: { x: number; y: number };
} | null>(null);
- // Context menu state
+ // Context menu state (nodeId is null for canvas context menu)
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
- nodeId: string;
+ nodeId: string | null;
+ canvasPosition?: { gridX: number; gridY: number };
} | null>(null);
// Load definitions when chain changes
@@ -221,9 +222,41 @@ export function ChainEditor({
}
}, [chain.id, onRefresh]);
+ // Position for new definition (set from canvas right-click)
+ const [newDefinitionPosition, setNewDefinitionPosition] = useState<{ x: number; y: number } | null>(null);
+
+ // Find free space on the grid for new definitions
+ const findFreePosition = useCallback(() => {
+ if (!definitionGraph?.nodes || definitionGraph.nodes.length === 0) {
+ return { x: 0, y: 0 };
+ }
+
+ // Get all occupied positions
+ const occupied = new Set<string>();
+ for (const node of definitionGraph.nodes) {
+ occupied.add(`${node.x},${node.y}`);
+ }
+
+ // Find first free position by scanning row by row
+ for (let y = 0; y < 10; y++) {
+ for (let x = 0; x < 10; x++) {
+ if (!occupied.has(`${x},${y}`)) {
+ return { x, y };
+ }
+ }
+ }
+
+ // Fallback: place at the end of the last row
+ const maxY = Math.max(...definitionGraph.nodes.map((n) => n.y || 0));
+ return { x: 0, y: maxY + 1 };
+ }, [definitionGraph?.nodes]);
+
const handleAddDefinition = useCallback(async (req: AddContractDefinitionRequest) => {
try {
- await createChainDefinition(chain.id, req);
+ // Use specified position or find free space
+ const position = newDefinitionPosition || findFreePosition();
+ const reqWithPosition = { ...req, editorX: position.x, editorY: position.y };
+ await createChainDefinition(chain.id, reqWithPosition);
// Reload definitions
const [defs, defGraph] = await Promise.all([
listChainDefinitions(chain.id),
@@ -232,11 +265,12 @@ export function ChainEditor({
setDefinitions(defs);
setDefinitionGraph(defGraph);
setShowAddDefinition(false);
+ setNewDefinitionPosition(null);
} catch (err) {
console.error("Failed to add definition:", err);
setError(err instanceof Error ? err.message : "Failed to add definition");
}
- }, [chain.id]);
+ }, [chain.id, newDefinitionPosition, findFreePosition]);
const handleDeleteDefinition = useCallback(async (definitionId: string) => {
if (!confirm("Are you sure you want to delete this definition?")) return;
@@ -274,10 +308,39 @@ export function ChainEditor({
setContextMenu({ x: e.clientX, y: e.clientY, nodeId });
}, []);
+ // Canvas context menu (for creating new definitions)
+ const handleCanvasContextMenu = useCallback((e: React.MouseEvent) => {
+ if (!showDefinitions || chain.status !== "pending") return;
+ e.preventDefault();
+
+ const canvasRect = canvasRef.current?.getBoundingClientRect();
+ if (!canvasRect) return;
+
+ // Calculate grid position from click
+ const clickX = e.clientX - canvasRect.left;
+ const clickY = e.clientY - canvasRect.top;
+ const gridX = Math.round((clickX - CANVAS_PADDING) / (NODE_WIDTH + 60));
+ const gridY = Math.round((clickY - CANVAS_PADDING) / (NODE_HEIGHT + 40));
+
+ setContextMenu({
+ x: e.clientX,
+ y: e.clientY,
+ nodeId: null,
+ canvasPosition: { gridX: Math.max(0, gridX), gridY: Math.max(0, gridY) },
+ });
+ }, [showDefinitions, chain.status]);
+
const closeContextMenu = useCallback(() => {
setContextMenu(null);
}, []);
+ // Handle creating definition at canvas position
+ const handleCreateAtPosition = useCallback((gridX: number, gridY: number) => {
+ setNewDefinitionPosition({ x: gridX, y: gridY });
+ setShowAddDefinition(true);
+ closeContextMenu();
+ }, [closeContextMenu]);
+
// Handle canvas click to close context menu
const handleCanvasClick = useCallback(() => {
if (contextMenu) {
@@ -525,6 +588,7 @@ export function ChainEditor({
cursor: draggedNode ? "grabbing" : edgeDrawing ? "crosshair" : "default",
}}
onClick={handleCanvasClick}
+ onContextMenu={handleCanvasContextMenu}
onMouseMove={(e) => {
if (draggedNode) handleDragMove(e);
if (edgeDrawing) handleEdgeDrawMove(e);
@@ -654,13 +718,15 @@ export function ChainEditor({
onMouseEnter={() => !draggedNode && setHoveredNode(node.id)}
onMouseLeave={() => !draggedNode && setHoveredNode(null)}
onMouseUp={() => edgeDrawing && handleEdgeDrawEnd(node.id)}
- className={`absolute transition-all duration-150 ${
+ className={`absolute ${
+ isDragging ? "z-50 shadow-lg" : "transition-all duration-150"
+ } ${
isSelected
? isCheckpoint
? "ring-2 ring-[#a78bfa] ring-offset-2 ring-offset-[#050d18]"
: "ring-2 ring-[#75aafc] ring-offset-2 ring-offset-[#050d18]"
: ""
- } ${isDragging ? "z-50 shadow-lg" : ""}`}
+ }`}
style={{
left: pos.x,
top: pos.y,
@@ -877,11 +943,13 @@ export function ChainEditor({
x={contextMenu.x}
y={contextMenu.y}
nodeId={contextMenu.nodeId}
- definition={definitions.find((d) => d.id === contextMenu.nodeId)}
+ canvasPosition={contextMenu.canvasPosition}
+ definition={contextMenu.nodeId ? definitions.find((d) => d.id === contextMenu.nodeId) : undefined}
allDefinitions={definitions}
onClose={closeContextMenu}
onDelete={handleDeleteDefinition}
onRemoveDependency={handleRemoveDependency}
+ onCreateAtPosition={handleCreateAtPosition}
/>
)}
</div>
@@ -892,23 +960,27 @@ export function ChainEditor({
interface ContextMenuProps {
x: number;
y: number;
- nodeId: string;
+ nodeId: string | null;
+ canvasPosition?: { gridX: number; gridY: number };
definition?: ChainContractDefinition;
allDefinitions: ChainContractDefinition[];
onClose: () => void;
onDelete: (id: string) => void;
onRemoveDependency: (nodeId: string, depName: string) => void;
+ onCreateAtPosition: (gridX: number, gridY: number) => void;
}
function ContextMenu({
x,
y,
nodeId,
+ canvasPosition,
definition,
allDefinitions: _allDefinitions,
onClose,
onDelete,
onRemoveDependency,
+ onCreateAtPosition,
}: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
@@ -932,7 +1004,29 @@ function ContextMenu({
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
- if (!definition) return null;
+ // Canvas context menu (no node selected)
+ if (!nodeId && canvasPosition) {
+ return (
+ <div
+ ref={menuRef}
+ className="fixed z-50 min-w-40 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-xl rounded-lg overflow-hidden"
+ style={{ left: x, top: y }}
+ >
+ <div className="py-1">
+ <button
+ onClick={() => onCreateAtPosition(canvasPosition.gridX, canvasPosition.gridY)}
+ className="w-full px-3 py-2 text-left font-mono text-xs text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] transition-colors flex items-center gap-2"
+ >
+ <PlusIcon className="w-3 h-3" />
+ Create Definition Here
+ </button>
+ </div>
+ </div>
+ );
+ }
+
+ // Node context menu requires both nodeId and definition
+ if (!nodeId || !definition) return null;
const dependencies = definition.dependsOnNames || [];
@@ -1006,6 +1100,15 @@ function TrashIcon({ className }: { className?: string }) {
);
}
+// Plus Icon
+function PlusIcon({ className }: { className?: string }) {
+ return (
+ <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
+ </svg>
+ );
+}
+
interface DefinitionDetailPanelProps {
definition: ChainContractDefinition;
onClose: () => void;
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts
index 6a40aec..d68c1ad 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -3425,6 +3425,8 @@ export async function getChainDefinitionGraph(
export async function startChain(chainId: string): Promise<StartChainResponse> {
const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}/start`, {
method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: res.statusText }));