Canvas: Update layout

Combine separate Overview and Canvas tabs into one Build tab
Add Overview <-> Canvas switcher to Actions

Change-Id: I40f7742be587b475ae6e88af2bcf9cae34f93168
diff --git a/apps/canvas/front/src/Canvas.tsx b/apps/canvas/front/src/Canvas.tsx
index eaaaf70..ba17153 100644
--- a/apps/canvas/front/src/Canvas.tsx
+++ b/apps/canvas/front/src/Canvas.tsx
@@ -1,41 +1,132 @@
-import { Resources } from "./components/resources";
-import { Canvas } from "./components/canvas";
-import { Details } from "./Details";
-import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./components/ui/resizable";
-import { Tools } from "./Tools";
-import { useStateStore } from "./lib/state";
+import "@xyflow/react/dist/style.css";
+import {
+	ReactFlow,
+	Background,
+	Controls,
+	Connection,
+	BackgroundVariant,
+	Edge,
+	useReactFlow,
+	Panel,
+	useStoreApi,
+} from "@xyflow/react";
+import { useStateStore, AppState, useZoom } from "@/lib/state";
+import { useShallow } from "zustand/react/shallow";
+import { useCallback, useEffect, useMemo } from "react";
+import { NodeGatewayHttps } from "@/components/node-gateway-https";
+import { NodeApp } from "@/components/node-app";
+import { NodeVolume } from "./components/node-volume";
+import { NodePostgreSQL } from "./components/node-postgresql";
+import { NodeMongoDB } from "./components/node-mongodb";
+import { NodeGithub } from "./components/node-github";
+import { Actions } from "./components/actions";
+import { NodeGatewayTCP } from "./components/node-gateway-tcp";
+import { NodeNetwork } from "./components/node-network";
+import { AppNode } from "config";
 
-export function CanvasBuilder() {
+const selector = (state: AppState) => ({
+	nodes: state.nodes,
+	edges: state.edges,
+	onNodesChange: state.onNodesChange,
+	onEdgesChange: state.onEdgesChange,
+	onConnect: state.onConnect,
+});
+
+export function Canvas({ className }: { className?: string }) {
+	const { nodes, edges, onNodesChange, onEdgesChange, onConnect } = useStateStore(useShallow(selector));
 	const store = useStateStore();
+	const instance = useReactFlow();
+	const flow = useStoreApi();
+	const viewport = useZoom();
+	useEffect(() => {
+		store.setViewport({
+			width: flow.getState().width,
+			height: flow.getState().height,
+			transformX: flow.getState().transform[0],
+			transformY: flow.getState().transform[1],
+			transformZoom: flow.getState().transform[2],
+		});
+	}, [store, flow]);
+	useEffect(() => {
+		instance.setViewport(viewport);
+	}, [viewport, instance]);
+	const nodeTypes = useMemo(
+		() => ({
+			network: NodeNetwork,
+			app: NodeApp,
+			"gateway-https": NodeGatewayHttps,
+			"gateway-tcp": NodeGatewayTCP,
+			volume: NodeVolume,
+			postgresql: NodePostgreSQL,
+			mongodb: NodeMongoDB,
+			github: NodeGithub,
+		}),
+		[],
+	);
+	const isValidConnection = useCallback(
+		(c: Edge | Connection) => {
+			if (c.source === c.target) {
+				return false;
+			}
+			const sn = instance.getNode(c.source)! as AppNode;
+			const tn = instance.getNode(c.target)! as AppNode;
+
+			if (sn.type === "github") {
+				return c.targetHandle === "repository";
+			}
+			if (sn.type === "app") {
+				if (c.sourceHandle === "ports" && (!sn.data.ports || sn.data.ports.length === 0)) {
+					return false;
+				}
+			}
+			if (tn.type === "gateway-https") {
+				if (c.targetHandle === "https" && tn.data.https !== undefined) {
+					return false;
+				}
+			}
+			if (sn.type === "volume") {
+				if (c.targetHandle !== "volume") {
+					return false;
+				}
+				return true;
+			}
+			if (tn.type === "network") {
+				if (c.sourceHandle !== "subdomain") {
+					return false;
+				}
+				if (sn.type !== "gateway-https" && sn.type !== "gateway-tcp") {
+					return false;
+				}
+			}
+			return true;
+		},
+		[instance],
+	);
 	return (
-		<ResizablePanelGroup direction="horizontal" className="w-full h-full">
-			<ResizablePanel defaultSize={80}>
-				<ResizablePanelGroup direction="vertical">
-					<ResizablePanel defaultSize={80}>
-						<ResizablePanelGroup direction="horizontal">
-							{store.mode === "edit" && (
-								<>
-									<ResizablePanel defaultSize={15}>
-										<Resources />
-									</ResizablePanel>
-									<ResizableHandle withHandle />
-								</>
-							)}
-							<ResizablePanel defaultSize={store.mode === "edit" ? 85 : 100}>
-								<Canvas />
-							</ResizablePanel>
-						</ResizablePanelGroup>
-					</ResizablePanel>
-					<ResizableHandle withHandle />
-					<ResizablePanel defaultSize={20}>
-						<Tools />
-					</ResizablePanel>
-				</ResizablePanelGroup>
-			</ResizablePanel>
-			<ResizableHandle withHandle />
-			<ResizablePanel defaultSize={20} className="!overflow-y-auto !overflow-x-hidden">
-				<Details />
-			</ResizablePanel>
-		</ResizablePanelGroup>
+		<div style={{ width: "100%", height: "100%" }} className={className}>
+			<ReactFlow
+				nodeTypes={nodeTypes}
+				nodes={nodes}
+				edges={edges}
+				onNodesChange={onNodesChange}
+				onEdgesChange={onEdgesChange}
+				onConnect={onConnect}
+				isValidConnection={isValidConnection}
+				fitView
+				proOptions={{ hideAttribution: true }}
+			>
+				<Controls showInteractive={false} />
+				<Background
+					variant={store.mode === "deploy" ? BackgroundVariant.Dots : BackgroundVariant.Lines}
+					gap={12}
+					size={1}
+				/>
+				<Panel position="top-right">
+					<Actions />
+				</Panel>
+			</ReactFlow>
+		</div>
 	);
 }
+
+export default Canvas;