Canvas: build application infrastructure with drag and drop

Change-Id: I5cfd12e67794f3376c5c025af29470d52d77cf16
diff --git a/apps/canvas/src/components/actions.tsx b/apps/canvas/src/components/actions.tsx
new file mode 100644
index 0000000..cc09a59
--- /dev/null
+++ b/apps/canvas/src/components/actions.tsx
@@ -0,0 +1,116 @@
+import { AppNode, useEnv, useMessages, useProjectId, useStateStore } from "@/lib/state";
+import { Button } from "./ui/button";
+import { useCallback, useEffect, useState } from "react";
+import { generateDodoConfig } from "@/lib/config";
+import { useNodes, useReactFlow } from "@xyflow/react";
+import { useToast } from "@/hooks/use-toast";
+
+export function Actions() {
+    const { toast } = useToast();
+    const store = useStateStore();
+    const projectId = useProjectId();
+    const nodes = useNodes<AppNode>();
+    const env = useEnv();
+    const messages = useMessages();
+    const instance = useReactFlow();
+    const [ok, setOk] = useState(false);
+    const [loading, setLoading] = useState(false);
+    useEffect(() => {
+        setOk(!messages.some((m) => m.type === "FATAL"));
+    }, [messages, setOk]);
+    const deploy = useCallback(async () => {
+        if (projectId == null) {
+            return;
+        }
+        setLoading(true);
+        try {
+            const config = generateDodoConfig(nodes, env);
+            if (config == null) {
+                throw new Error("MUST NOT REACH!");
+            }
+            const resp = await fetch(`/api/project/${projectId}/deploy`, {
+                method: "POST",
+                headers: {
+                    "Content-Type": "application/json",
+                },
+                body: JSON.stringify({
+                    state: JSON.stringify(instance.toObject()),
+                    config,
+                }),
+            });
+            if (resp.ok) {
+                toast({
+                    title: "Deployment succeeded",
+                });
+            } else {
+                toast({
+                    variant: "destructive",
+                    title: "Deployment failed",
+                    description: await resp.text(),
+                });
+            }            
+        } catch (e) {
+            console.log(e);
+            toast({
+                variant: "destructive",
+                title: "Deployment failed",
+            });
+        } finally {
+            setLoading(false);
+        }
+    }, [projectId, instance, nodes, env, setLoading]);
+    const [st, setSt] = useState<string>();
+    const save = useCallback(async () => {
+        if (projectId == null) {
+            return;
+        }
+        const resp = await fetch(`/api/project/${projectId}/saved`, {
+            method: "POST",
+            headers: {
+                "Content-Type": "application/json",
+            },
+            body: JSON.stringify(instance.toObject()),
+        });
+        if (resp.ok) {
+            toast({
+                title: "Save succeeded",
+            });
+        } else {
+            toast({
+                variant: "destructive",
+                title: "Save failed",
+                description: await resp.text(),
+            });
+        }            
+    }, [projectId, instance, setSt]);
+    const restoreSaved = useCallback(async () => {
+        if (projectId == null) {
+            return;
+        }
+        const resp = await fetch(`/api/project/${projectId}/saved`, {
+            method: "GET",
+        });
+        const inst = await resp.json();
+        const { x = 0, y = 0, zoom = 1 } = inst.viewport;
+        store.setNodes(inst.nodes || []);
+        store.setEdges(inst.edges || []);
+        instance.setViewport({ x, y, zoom });
+    }, [projectId, instance, st]);
+    const [props, setProps] = useState({});
+    useEffect(() => {
+        if (loading) {
+            setProps({ loading: true });
+        } else if (ok) {
+            setProps({ disabled: false });
+        } else {
+            setProps({ disabled: true });
+        }
+    }, [ok, loading, setProps]);
+    return (
+        <>
+            <Button onClick={deploy} {...props}>Deploy</Button>
+            <Button onClick={save}>Save</Button>
+            <Button onClick={restoreSaved}>Restore</Button>
+        </>
+    )
+}
\ No newline at end of file