Canvas: build application infrastructure with drag and drop

Change-Id: I5cfd12e67794f3376c5c025af29470d52d77cf16
diff --git a/apps/canvas/src/Messages.tsx b/apps/canvas/src/Messages.tsx
new file mode 100644
index 0000000..94cd42e
--- /dev/null
+++ b/apps/canvas/src/Messages.tsx
@@ -0,0 +1,58 @@
+import { Button } from "./components/ui/button";
+import { AppNode, AppState, Message, nodeLabel, useMessages, useStateStore } from "./lib/state";
+import { useCallback, useEffect, useState } from "react";
+import { useNodes } from "@xyflow/react";
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./components/ui/accordion";
+import { Badge } from "./components/ui/badge";
+
+export function Messages() {
+    const store = useStateStore();
+    const nodes = useNodes<AppNode>();
+    const [nodeMap, setNodeMap] = useState<Map<string, AppNode>>();
+    useEffect(() => {
+        setNodeMap(new Map(nodes.map((n) => [n.id, n])));
+    }, [nodes, setNodeMap]);
+    const onClick = useCallback((fn?: (state: AppState) => void) => {
+        return () => {
+            if (fn) {
+                fn(store);
+            }
+        };
+    }, [store]);
+    const messages = useMessages();
+    const [grouped, setGrouped] = useState<Map<string, Message[]>>(new Map());
+    useEffect(() => {
+        const g = new Map<string, Message[]>();
+        messages.forEach((m) => {
+            const id = m.nodeId || "global";
+            const existing: Message[] = g.get(id) || [];
+            existing.push(m);
+            g.set(id, existing);
+        });
+        setGrouped(g);
+    }, [messages, setGrouped]);
+    const [open, setOpen] = useState<string[]>([...grouped.keys()]);
+    useEffect(() => {
+        // TODO(gio): do not reopen closed ones
+        setOpen([...grouped.keys()]);
+    }, [grouped, setOpen]);
+    return (
+            <Accordion type="multiple" value={open} onValueChange={(v) => setOpen(v)}>
+                {[...grouped.entries()].map(([id, messages]) => (
+                    <AccordionItem key={id} value={id}>
+                        <AccordionTrigger className="flex flex-row-reverse !space-x-4 !justify-end">
+                            <Badge>{messages.length}</Badge>
+                            <div>{id === "global" ? "Global" : nodeLabel(nodeMap?.get(id)!)}</div>
+                        </AccordionTrigger>
+                        <AccordionContent>
+                            <div className="flex flex-col space-y-1">
+                                {messages.map((m) => (
+                                    <Button key={m.id} variant="ghost" style={{ justifyContent: "flex-start" }} onMouseOver={onClick(m.onHighlight)} onMouseLeave={onClick(m.onLooseHighlight)} onClick={onClick(m.onClick)}>{m.message}</Button>
+                                ))}
+                            </div>
+                        </AccordionContent>
+                    </AccordionItem>
+                ))}
+            </Accordion>
+    )
+}
\ No newline at end of file