From 4d179365705fb32f348446908b5d798f4ec385a0 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 25 Mar 2026 14:21:19 +0100 Subject: [PATCH] feat: implement Convex-synced canvas foundation --- .docs/Canvas_Implementation_Guide.md | 794 ++++++++++++++++++ .docs/lemonspace_wireframes.html | 639 ++++++++++++++ app/(app)/canvas/[canvasId]/page.tsx | 27 + app/dashboard/page.tsx | 79 +- components/canvas/canvas-sidebar.tsx | 3 + components/canvas/canvas-toolbar.tsx | 96 +++ components/canvas/canvas.tsx | 158 ++++ components/canvas/edges/default-edge.tsx | 3 + components/canvas/node-types.ts | 21 + components/canvas/nodes/ai-image-node.tsx | 67 ++ components/canvas/nodes/base-node-wrapper.tsx | 37 + components/canvas/nodes/compare-node.tsx | 50 ++ components/canvas/nodes/frame-node.tsx | 28 + components/canvas/nodes/group-node.tsx | 15 + components/canvas/nodes/image-node.tsx | 38 + components/canvas/nodes/note-node.tsx | 20 + components/canvas/nodes/prompt-node.tsx | 29 + components/canvas/nodes/text-node.tsx | 25 + lib/canvas-utils.ts | 35 + package.json | 3 + pnpm-lock.yaml | 215 +++++ 21 files changed, 2347 insertions(+), 35 deletions(-) create mode 100644 .docs/Canvas_Implementation_Guide.md create mode 100644 .docs/lemonspace_wireframes.html create mode 100644 app/(app)/canvas/[canvasId]/page.tsx create mode 100644 components/canvas/canvas-sidebar.tsx create mode 100644 components/canvas/canvas-toolbar.tsx create mode 100644 components/canvas/canvas.tsx create mode 100644 components/canvas/edges/default-edge.tsx create mode 100644 components/canvas/node-types.ts create mode 100644 components/canvas/nodes/ai-image-node.tsx create mode 100644 components/canvas/nodes/base-node-wrapper.tsx create mode 100644 components/canvas/nodes/compare-node.tsx create mode 100644 components/canvas/nodes/frame-node.tsx create mode 100644 components/canvas/nodes/group-node.tsx create mode 100644 components/canvas/nodes/image-node.tsx create mode 100644 components/canvas/nodes/note-node.tsx create mode 100644 components/canvas/nodes/prompt-node.tsx create mode 100644 components/canvas/nodes/text-node.tsx create mode 100644 lib/canvas-utils.ts diff --git a/.docs/Canvas_Implementation_Guide.md b/.docs/Canvas_Implementation_Guide.md new file mode 100644 index 0000000..27160bf --- /dev/null +++ b/.docs/Canvas_Implementation_Guide.md @@ -0,0 +1,794 @@ +# 🍋 LemonSpace — Canvas Implementation Guide + +**Schritte 1–3: Basis-Canvas mit Convex-Sync** + +--- + +## Voraussetzungen + +Das Convex-Backend ist deployed und folgende Funktionen existieren bereits: + +- `api.nodes.list` (Query, benötigt `canvasId`) +- `api.nodes.create` (Mutation) +- `api.nodes.move` (Mutation, benötigt `nodeId` + `position`) +- `api.nodes.resize` (Mutation) +- `api.nodes.batchMove` (Mutation) +- `api.nodes.updateData` (Mutation) +- `api.nodes.updateStatus` (Mutation) +- `api.nodes.remove` (Mutation) +- `api.edges.list` (Query, benötigt `canvasId`) +- `api.edges.create` (Mutation) +- `api.edges.remove` (Mutation) +- `api.canvases.list`, `api.canvases.get`, `api.canvases.create` + +Auth via Better Auth + `@convex-dev/better-auth` ist funktionsfĂ€hig. + +--- + +## Schritt 0 — Package-Installation + +```bash +pnpm add @xyflow/react +``` + +> **dnd-kit** wird erst in einem spĂ€teren Schritt benötigt (Sidebar → Canvas Drag). FĂŒr Schritt 1–3 reicht @xyflow/react allein — das bringt Drag & Drop von bestehenden Nodes bereits mit. + +--- + +## Schritt 1 — Dateistruktur anlegen + +``` +components/ + canvas/ + canvas.tsx ← Haupt-Canvas (ReactFlow + Convex-Sync) + canvas-toolbar.tsx ← Toolbar oben (Node hinzufĂŒgen, Zoom) + node-types.ts ← nodeTypes-Map (AUSSERHALB jeder Komponente!) + nodes/ + image-node.tsx ← Bild-Node (Upload/URL) + text-node.tsx ← Freitext (Markdown) + prompt-node.tsx ← Prompt fĂŒr KI-Nodes + ai-image-node.tsx ← KI-Bild-Output + group-node.tsx ← Container/Gruppe + frame-node.tsx ← Artboard/Export-Boundary + note-node.tsx ← Annotation + compare-node.tsx ← Slider-Vergleich + base-node-wrapper.tsx ← Shared Wrapper (Border, Selection-Ring, Status) + +app/ + (app)/ + canvas/ + [canvasId]/ + page.tsx ← Canvas-Page (Server Component, Auth-Check) + +lib/ + canvas-utils.ts ← Hilfsfunktionen (Convex → RF Mapping) +``` + +--- + +## Schritt 2 — Custom Node Components + +### 2.1 Base Node Wrapper + +Jeder Node teilt sich visuelle Grundeigenschaften: Border, Selection-Ring, Status-Anzeige. Das kapseln wir in einem Wrapper. + +```tsx +// components/canvas/nodes/base-node-wrapper.tsx +'use client'; + +import type { ReactNode } from 'react'; + +interface BaseNodeWrapperProps { + selected?: boolean; + status?: 'idle' | 'executing' | 'done' | 'error'; + children: ReactNode; + className?: string; +} + +export default function BaseNodeWrapper({ + selected, + status = 'idle', + children, + className = '', +}: BaseNodeWrapperProps) { + const statusStyles = { + idle: '', + executing: 'animate-pulse border-yellow-400', + done: 'border-green-500', + error: 'border-red-500', + }; + + return ( +
+ {children} +
+ ); +} +``` + +### 2.2 Note Node (einfachster Node — guter Startpunkt) + +```tsx +// components/canvas/nodes/note-node.tsx +'use client'; + +import { type NodeProps, type Node } from '@xyflow/react'; +import BaseNodeWrapper from './base-node-wrapper'; + +export type NoteNodeData = { + content?: string; +}; + +export type NoteNode = Node; + +export default function NoteNode({ data, selected }: NodeProps) { + return ( + +
📌 Notiz
+

+ {data.content || 'Leere Notiz'} +

+
+ ); +} +``` + +### 2.3 Image Node + +```tsx +// components/canvas/nodes/image-node.tsx +'use client'; + +import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'; +import BaseNodeWrapper from './base-node-wrapper'; + +export type ImageNodeData = { + storageId?: string; + url?: string; + filename?: string; +}; + +export type ImageNode = Node; + +export default function ImageNode({ data, selected }: NodeProps) { + return ( + +
đŸ–Œïž Bild
+ {data.url ? ( + {data.filename + ) : ( +
+ Bild hochladen oder URL einfĂŒgen +
+ )} + +
+ ); +} +``` + +### 2.4 Text Node + +```tsx +// components/canvas/nodes/text-node.tsx +'use client'; + +import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'; +import BaseNodeWrapper from './base-node-wrapper'; + +export type TextNodeData = { + content?: string; +}; + +export type TextNode = Node; + +export default function TextNode({ data, selected }: NodeProps) { + return ( + +
📝 Text
+

+ {data.content || 'Text eingeben
'} +

+ +
+ ); +} +``` + +### 2.5 Prompt Node + +```tsx +// components/canvas/nodes/prompt-node.tsx +'use client'; + +import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'; +import BaseNodeWrapper from './base-node-wrapper'; + +export type PromptNodeData = { + prompt?: string; + model?: string; +}; + +export type PromptNode = Node; + +export default function PromptNode({ data, selected }: NodeProps) { + return ( + +
✹ Prompt
+

+ {data.prompt || 'Prompt eingeben
'} +

+ {data.model && ( +
+ Modell: {data.model} +
+ )} + {/* Nur Source — verbindet sich ausschließlich mit KI-Nodes */} + +
+ ); +} +``` + +### 2.6 AI Image Node + +```tsx +// components/canvas/nodes/ai-image-node.tsx +'use client'; + +import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'; +import BaseNodeWrapper from './base-node-wrapper'; + +export type AiImageNodeData = { + url?: string; + prompt?: string; + model?: string; + status?: 'idle' | 'executing' | 'done' | 'error'; + error?: string; +}; + +export type AiImageNode = Node; + +export default function AiImageNode({ data, selected }: NodeProps) { + const status = data.status ?? 'idle'; + + return ( + +
đŸ€– KI-Bild
+ + {status === 'executing' && ( +
+
+
+ )} + + {status === 'done' && data.url && ( + {data.prompt + )} + + {status === 'error' && ( +
+ {data.error ?? 'Fehler bei der Generierung'} +
+ )} + + {status === 'idle' && ( +
+ Prompt verbinden +
+ )} + + {data.prompt && status === 'done' && ( +

+ {data.prompt} +

+ )} + + {/* Target: EmpfĂ€ngt Input von Prompt/Bild */} + + {/* Source: Output weitergeben (an Compare, Frame, etc.) */} + + + ); +} +``` + +### 2.7 Platzhalter fĂŒr Group, Frame, Compare + +Diese sind komplexer (Group braucht `expandParent`, Frame braucht Resize, Compare braucht Slider). FĂŒr Schritt 1–3 reichen einfache Platzhalter: + +```tsx +// components/canvas/nodes/group-node.tsx +'use client'; +import { type NodeProps, type Node } from '@xyflow/react'; +import BaseNodeWrapper from './base-node-wrapper'; + +export type GroupNode = Node<{ label?: string }, 'group'>; + +export default function GroupNode({ data, selected }: NodeProps) { + return ( + +
📁 {data.label || 'Gruppe'}
+
+ ); +} +``` + +```tsx +// components/canvas/nodes/frame-node.tsx +'use client'; +import { type NodeProps, type Node } from '@xyflow/react'; +import BaseNodeWrapper from './base-node-wrapper'; + +export type FrameNode = Node<{ label?: string; resolution?: string }, 'frame'>; + +export default function FrameNode({ data, selected }: NodeProps) { + return ( + +
+ đŸ–„ïž {data.label || 'Frame'} {data.resolution && `(${data.resolution})`} +
+
+ ); +} +``` + +```tsx +// components/canvas/nodes/compare-node.tsx +'use client'; +import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'; +import BaseNodeWrapper from './base-node-wrapper'; + +export type CompareNode = Node<{ leftUrl?: string; rightUrl?: string }, 'compare'>; + +export default function CompareNode({ data, selected }: NodeProps) { + return ( + +
🔀 Vergleich
+
+
+ {data.leftUrl ? : 'Bild A'} +
+
+ {data.rightUrl ? : 'Bild B'} +
+
+ + +
+ ); +} +``` + +--- + +## Schritt 3 — nodeTypes registrieren + +**Kritisch:** Diese Map muss AUSSERHALB jeder React-Komponente definiert werden. Wenn sie innerhalb einer Komponente liegt, erstellt React bei jedem Render ein neues Objekt → React Flow re-rendert alle Nodes. + +```tsx +// components/canvas/node-types.ts +import ImageNode from './nodes/image-node'; +import TextNode from './nodes/text-node'; +import PromptNode from './nodes/prompt-node'; +import AiImageNode from './nodes/ai-image-node'; +import GroupNode from './nodes/group-node'; +import FrameNode from './nodes/frame-node'; +import NoteNode from './nodes/note-node'; +import CompareNode from './nodes/compare-node'; + +export const nodeTypes = { + image: ImageNode, + text: TextNode, + prompt: PromptNode, + 'ai-image': AiImageNode, + group: GroupNode, + frame: FrameNode, + note: NoteNode, + compare: CompareNode, +} as const; +``` + +--- + +## Schritt 4 — Convex ↔ React Flow Mapping + +Das HerzstĂŒck: Convex-Daten in React Flow-Format transformieren und Änderungen zurĂŒckschreiben. + +```tsx +// lib/canvas-utils.ts +import type { Node as RFNode, Edge as RFEdge } from '@xyflow/react'; +import type { Doc } from '@/convex/_generated/dataModel'; + +/** + * Transformiert einen Convex-Node in das React Flow-Format. + */ +export function convexNodeToRF(node: Doc<'nodes'>): RFNode { + return { + id: node._id, + type: node.type, + position: node.position, + data: node.data ?? {}, + // parentId: node.parentNodeId ?? undefined, // ← fĂŒr Group-Nodes, aktivieren wenn nötig + style: node.size + ? { width: node.size.width, height: node.size.height } + : undefined, + }; +} + +/** + * Transformiert einen Convex-Edge in das React Flow-Format. + */ +export function convexEdgeToRF(edge: Doc<'edges'>): RFEdge { + return { + id: edge._id, + source: edge.sourceNodeId, + target: edge.targetNodeId, + // sourceHandle und targetHandle können spĂ€ter ergĂ€nzt werden + }; +} +``` + +--- + +## Schritt 5 — Haupt-Canvas-Komponente + +Die zentrale Architekturentscheidung: **Lokaler State fĂŒr flĂŒssiges Interagieren, Convex als Sync-Layer.** + +React Flow braucht `onNodesChange` fĂŒr jede Interaktion (Drag, Select, Remove). Wenn wir jede Drag-Bewegung direkt an Convex senden wĂŒrden, wĂ€re das zu viel Traffic und der Canvas wĂŒrde laggen. Stattdessen: + +1. Convex-Daten kommen rein → werden in lokalen State geschrieben +2. Lokaler State wird von React Flow gesteuert (Drag, Select, etc.) +3. Bei `onNodeDragStop` wird die finale Position an Convex committed +4. Convex-Subscription aktualisiert den lokalen State bei Remote-Änderungen + +```tsx +// components/canvas/canvas.tsx +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + ReactFlow, + Background, + Controls, + MiniMap, + applyNodeChanges, + applyEdgeChanges, + type Node as RFNode, + type Edge as RFEdge, + type NodeChange, + type EdgeChange, + type Connection, + type ReactFlowInstance, + BackgroundVariant, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; + +import { useMutation, useQuery } from 'convex/react'; +import { api } from '@/convex/_generated/api'; +import type { Id } from '@/convex/_generated/dataModel'; + +import { nodeTypes } from './node-types'; +import { convexNodeToRF, convexEdgeToRF } from '@/lib/canvas-utils'; + +interface CanvasProps { + canvasId: Id<'canvases'>; +} + +export default function Canvas({ canvasId }: CanvasProps) { + // ─── Convex Realtime Queries ─── + const convexNodes = useQuery(api.nodes.list, { canvasId }); + const convexEdges = useQuery(api.edges.list, { canvasId }); + + // ─── Convex Mutations ─── + const moveNode = useMutation(api.nodes.move); + const createNode = useMutation(api.nodes.create); + const removeNode = useMutation(api.nodes.remove); + const createEdge = useMutation(api.edges.create); + const removeEdge = useMutation(api.edges.remove); + + // ─── Lokaler State (fĂŒr flĂŒssiges Dragging) ─── + const [nodes, setNodes] = useState([]); + const [edges, setEdges] = useState([]); + + // Track ob gerade gedraggt wird — dann kein Convex-Override + const isDragging = useRef(false); + + // React Flow Instance Ref (fĂŒr screenToFlowPosition, etc.) + const [rfInstance, setRfInstance] = useState(null); + + // ─── Convex → Lokaler State Sync ─── + useEffect(() => { + if (!convexNodes) return; + // Nur aktualisieren wenn NICHT gerade gedraggt wird + if (!isDragging.current) { + setNodes(convexNodes.map(convexNodeToRF)); + } + }, [convexNodes]); + + useEffect(() => { + if (!convexEdges) return; + setEdges(convexEdges.map(convexEdgeToRF)); + }, [convexEdges]); + + // ─── Node Changes (Drag, Select, Remove) ─── + const onNodesChange = useCallback((changes: NodeChange[]) => { + setNodes((nds) => applyNodeChanges(changes, nds)); + }, []); + + const onEdgesChange = useCallback((changes: EdgeChange[]) => { + setEdges((eds) => applyEdgeChanges(changes, eds)); + }, []); + + // ─── Drag Start → Lock Convex-Sync ─── + const onNodeDragStart = useCallback(() => { + isDragging.current = true; + }, []); + + // ─── Drag Stop → Commit zu Convex ─── + const onNodeDragStop = useCallback( + (_: React.MouseEvent, node: RFNode) => { + isDragging.current = false; + moveNode({ + nodeId: node.id as Id<'nodes'>, + position: { x: node.position.x, y: node.position.y }, + }); + }, + [moveNode] + ); + + // ─── Neue Verbindung → Convex Edge ─── + const onConnect = useCallback( + (connection: Connection) => { + if (connection.source && connection.target) { + createEdge({ + canvasId, + sourceNodeId: connection.source as Id<'nodes'>, + targetNodeId: connection.target as Id<'nodes'>, + }); + } + }, + [createEdge, canvasId] + ); + + // ─── Node löschen → Convex ─── + const onNodesDelete = useCallback( + (deletedNodes: RFNode[]) => { + for (const node of deletedNodes) { + removeNode({ nodeId: node.id as Id<'nodes'> }); + } + }, + [removeNode] + ); + + // ─── Edge löschen → Convex ─── + const onEdgesDelete = useCallback( + (deletedEdges: RFEdge[]) => { + for (const edge of deletedEdges) { + removeEdge({ edgeId: edge.id as Id<'edges'> }); + } + }, + [removeEdge] + ); + + // ─── Loading State ─── + if (convexNodes === undefined || convexEdges === undefined) { + return ( +
+
+
+ Canvas lÀdt
 +
+
+ ); + } + + return ( +
+ + + + + +
+ ); +} +``` + +--- + +## Schritt 6 — Canvas Toolbar + +Eine einfache Toolbar zum Anlegen neuer Nodes. In Phase 1 ist das der einfachste Weg, Nodes zu erstellen (Sidebar + Drag kommt danach). + +```tsx +// components/canvas/canvas-toolbar.tsx +'use client'; + +import { useMutation } from 'convex/react'; +import { api } from '@/convex/_generated/api'; +import type { Id } from '@/convex/_generated/dataModel'; + +const nodeTemplates = [ + { type: 'image', label: 'đŸ–Œïž Bild', defaultData: {} }, + { type: 'text', label: '📝 Text', defaultData: { content: '' } }, + { type: 'prompt', label: '✹ Prompt', defaultData: { prompt: '' } }, + { type: 'note', label: '📌 Notiz', defaultData: { content: '' } }, + { type: 'frame', label: 'đŸ–„ïž Frame', defaultData: { label: 'Untitled', resolution: '1080x1080' } }, +] as const; + +interface CanvasToolbarProps { + canvasId: Id<'canvases'>; +} + +export default function CanvasToolbar({ canvasId }: CanvasToolbarProps) { + const createNode = useMutation(api.nodes.create); + + const handleAddNode = async (type: string, data: Record) => { + // Platziere neue Nodes leicht versetzt, damit sie nicht ĂŒbereinander liegen + const offset = Math.random() * 200; + await createNode({ + canvasId, + type, + position: { x: 100 + offset, y: 100 + offset }, + data, + }); + }; + + return ( +
+ {nodeTemplates.map((t) => ( + + ))} +
+ ); +} +``` + +--- + +## Schritt 7 — Canvas Page (Next.js App Router) + +```tsx +// app/(app)/canvas/[canvasId]/page.tsx +import Canvas from '@/components/canvas/canvas'; +import CanvasToolbar from '@/components/canvas/canvas-toolbar'; +import type { Id } from '@/convex/_generated/dataModel'; + +export default async function CanvasPage({ + params, +}: { + params: Promise<{ canvasId: string }>; +}) { + const { canvasId } = await params; + const typedCanvasId = canvasId as Id<'canvases'>; + + return ( +
+ + +
+ ); +} +``` + +--- + +## Schritt 8 — CSS Import nicht vergessen! + +@xyflow/react braucht sein eigenes CSS. Importiere es entweder in der Canvas-Komponente (wie oben) oder global in `app/globals.css`: + +```css +/* app/globals.css — NACH den Tailwind-Imports */ +@import '@xyflow/react/dist/style.css'; +``` + +> **Tailwind v4 Hinweis:** Falls die React Flow Styles von Tailwinds Reset ĂŒberschrieben werden, importiere sie NACH dem Tailwind-Import. + +--- + +## Testing-Reihenfolge + +Nachdem du alle Dateien erstellt hast, teste in dieser Reihenfolge: + +### Test 1: Canvas rendert +- Navigiere zu `/canvas/` (du brauchst eine existierende Canvas-ID aus Convex) +- Erwartung: Leerer Canvas mit Dot-Background, Controls unten links, MiniMap unten rechts +- Falls 404: PrĂŒfe ob die Route `app/(app)/canvas/[canvasId]/page.tsx` korrekt liegt + +### Test 2: Node hinzufĂŒgen +- Klicke auf "📌 Notiz" in der Toolbar +- Erwartung: Note-Node erscheint auf dem Canvas +- PrĂŒfe im Convex Dashboard: neuer Eintrag in der `nodes`-Tabelle + +### Test 3: Node verschieben +- Ziehe den Node an eine neue Position, lasse los +- Erwartung: Node bleibt an der neuen Position +- PrĂŒfe im Convex Dashboard: `position.x` und `position.y` haben sich aktualisiert + +### Test 4: Verbindung erstellen +- Erstelle einen Prompt-Node und einen (leeren) AI-Image-Node +- Ziehe vom Source-Handle (rechts am Prompt) zum Target-Handle (links am AI-Image) +- Erwartung: Edge erscheint als Linie zwischen den Nodes +- PrĂŒfe im Convex Dashboard: neuer Eintrag in der `edges`-Tabelle + +### Test 5: Node löschen +- Selektiere einen Node (Klick), drĂŒcke `Delete` oder `Backspace` +- Erwartung: Node verschwindet, zugehörige Edges werden ebenfalls entfernt +- PrĂŒfe im Convex Dashboard: Node und Edges sind gelöscht + +--- + +## Bekannte Fallstricke + +### 1. `nodeTypes` innerhalb der Komponente definiert +→ React Flow re-rendert ALLE Nodes bei jedem State-Update. Die Map MUSS in einer eigenen Datei liegen. + +### 2. React Flow CSS fehlt +→ Nodes sind unsichtbar oder falsch positioniert. Import von `@xyflow/react/dist/style.css` ist Pflicht. + +### 3. Convex-Sync wĂ€hrend Drag +→ Wenn Convex einen neuen Wert pusht wĂ€hrend der User draggt, springt der Node zur alten Position zurĂŒck. Die `isDragging`-Ref verhindert das. + +### 4. Handle-Styling +→ Die Standard-Handles von React Flow sind winzig und dunkel. Die `!`-Klassen in Tailwind erzwingen Custom-Styling ĂŒber die React Flow Defaults. + +### 5. Batch-Drag (mehrere Nodes selektiert) +→ `onNodeDragStop` feuert nur fĂŒr den primĂ€r gedraggten Node. FĂŒr Batch-Moves nutze `onSelectionDragStop` oder `batchMove` Mutation. + +--- + +## NĂ€chste Schritte (nach Schritt 1–3) + +- **Schritt 4:** Sidebar mit Node-Palette + dnd-kit (Drag von Sidebar auf Canvas) +- **Schritt 5:** Inline-Editing (Text direkt im Node bearbeiten → `updateData` Mutation) +- **Schritt 6:** Bild-Upload (Convex File Storage + Image-Node) +- **Schritt 7:** OpenRouter-Integration (Prompt → KI-Bild-Generierung) +- **Schritt 8:** Node-Status-Modell visuell ausbauen (Shimmer, Progress, Error) diff --git a/.docs/lemonspace_wireframes.html b/.docs/lemonspace_wireframes.html new file mode 100644 index 0000000..9769894 --- /dev/null +++ b/.docs/lemonspace_wireframes.html @@ -0,0 +1,639 @@ + + + + + +LemonSpace — Wireframes v1 + + + + + + + + + +
+
+
+
+ LemonSpace — Brand Campaign Q2 +
+ Autosaved + Share +
MH
+
+
+ +
+ + +
+
+
Quelle
+
+
+
+ + Bild +
+
+
+
+ + Text +
+
+
+
+ + Prompt Phase 2 +
+
+
+
+
+
+
KI-Ausgabe
+
+
+
+ + KI-Bild +
+
+
+
+
+
+
Canvas & Layout
+
+
+
+ + Frame +
+
+
+
+ + Gruppe +
+
+
+
+ + Notiz +
+
+
+
+
+ + +
+ + + + + + +
+
+ Quelle + Bild +
+
+ +
+
product-hero.jpg
+
+
+ +
+
+ KI-Ausgabe + KI-Bild +
+
+ +
+
Gemini 2.5 Flash
+
~€0,03 / Bild
+
+
+
+ +
+
+ KI-Ausgabe + KI-Bild +
+
+ +
+
+
+ +
+
Frame — 1080×1080px
+ +
+ +
+ +
+ +
+ +
+ +
+ Credits: + €34,18 +
+
+ + +
+
KI-Bild — Inspector
+
+
+
Modell
+ +
+
+
Prompt
+ +
+
+
Auflösung
+ +
+
+
+ GeschĂ€tzte Kosten + ~€0,03 +
+ +
+
+
+
+
+ + +
+
+
+
+ Node Status — AusfĂŒhrungsmodell +
+
+
+
+
Idle
+
+
KI-Bildidle
+
+ +
+
Bereit zur AusfĂŒhrung
+
+
+
+
Executing
+
+
KI-Bildexecuting
+
+
+
+
+
+
+
+
Gemini 2.5 Flash — ~€0,03 reserviert
+
+
+
+
Done
+
+
KI-Bilddone
+
+ +
+
€0,028 verbucht — 4,2s
+
+
+
+
Error
+
+
KI-Bilderror
+
+ +
+
Timeout — Credits nicht abgebucht
+
+
+
+ +
+
Skeleton-Nodes — Agent Execution Plan
+
+
+ Agent hat Plan erstellt: 3× KI-Bild, 2× KI-Text, 1× Text-Overlay — Skeleton-Nodes platziert +
+
+
+
KI-Bild 1/3
+
+ +
+
+
+
KI-Bild 2/3
+
+
+
+
KI-Bild 3/3
+
+
+
+
KI-Text 1/2
+
+
+
+
+
+
+
+
KI-Text 2/2
+
+
+
+
+
+
+
Text-Overlay
+
+
+
+
+ executing 2/6 + Nodes verschiebbar — Position bleibt bei Output-Ersatz erhalten +
+
+
+
+
+ + +
+
+
+
+ Agent Clarification — 3 Varianten +
+
+
+ +
+
Option A — Inline am Node
+
+
Agentclarifying
+
Instagram Curator
+
+
Fehlende Angabe
+
Welche Zielgruppe soll angesprochen werden?
+
+ + +
+
+ + Direkt am Node, kein Kontextverlust
+ + Canvas bleibt sichtbar
+ − Wenig Platz fĂŒr lange Antworten +
+
+ +
+
Option B — Modal
+
+
+
Agent braucht Angaben
+
Instagram Curator — Clarification
+
Welche Zielgruppe soll angesprochen werden?
+ +
+ + +
+
+
+
+ + Mehr Platz, klarer Fokus
+ + Bekanntes Interaktionsmuster
+ − Canvas wird verdeckt +
+
+ +
+
Option C — Chat-Sidebar
+
+
Agent — Instagram Curator
+
+
Ich habe alle Inputs analysiert. Eine Angabe fehlt noch: Welche Zielgruppe soll angesprochen werden?
+
25–35, Mode-affin, urban
+
Danke! Starte jetzt Execution...
+
+
+ + +
+
+
+ + NatĂŒrlich, erweiterbar auf Multi-Turn
+ + Verlauf bleibt sichtbar
+ − Platzbedarf, Canvas verkleinert +
+
+
+ +
+
+
Empfehlung zur Entscheidung
+
Option A (inline) passt am besten zur Canvas-nativen Philosophie von LemonSpace. Option C (Chat) skaliert besser fĂŒr komplexere Agent-Workflows mit mehreren RĂŒckfragen. Option B (Modal) ist der sicherste UX-Kompromiss — vertraut, fokussiert, aber ohne Canvas-NĂ€he.
+
+
+
+
+ + +
+
+
+
+ LemonSpace — Dashboard +
+ Starter +
MH
+
+
+ +
+
+
LemonSpace
+
Alle Canvases
+
Zuletzt geöffnet
+
Templates
+
+
Konto
+
Credits & Abo
+
Einstellungen
+
+
Credits verbleibend
+
€6,18
+
+
+
+
Starter — erneuert am 1. Apr
+ +
+
+ +
+
+
Meine Canvases
+ +
+
+
+
+ +
+
Brand Campaign Q2
+
12 Nodes · vor 2h
+
+
+
+ +
+
Instagram Curator Test
+
8 Nodes · gestern
+
+
+ +
Neuer Canvas
+
+
+
+
Templates
+
+
+
Instagram Curator
+
Agent · Phase 2
+
+
+
Product Batch
+
Agent · coming soon
+
+
+
Brand Guidelines
+
Agent · coming soon
+
+
+
+
+
+
+ + + + diff --git a/app/(app)/canvas/[canvasId]/page.tsx b/app/(app)/canvas/[canvasId]/page.tsx new file mode 100644 index 0000000..ef6eaad --- /dev/null +++ b/app/(app)/canvas/[canvasId]/page.tsx @@ -0,0 +1,27 @@ +import { redirect } from "next/navigation"; + +import Canvas from "@/components/canvas/canvas"; +import CanvasToolbar from "@/components/canvas/canvas-toolbar"; +import type { Id } from "@/convex/_generated/dataModel"; +import { isAuthenticated } from "@/lib/auth-server"; + +export default async function CanvasPage({ + params, +}: { + params: Promise<{ canvasId: string }>; +}) { + const authenticated = await isAuthenticated(); + if (!authenticated) { + redirect("/auth/sign-in"); + } + + const { canvasId } = await params; + const typedCanvasId = canvasId as Id<"canvases">; + + return ( +
+ + +
+ ); +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 272f7a6..9f59305 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,6 +1,6 @@ -"use client" +"use client"; -import Image from "next/image" +import Image from "next/image"; import { Activity, ArrowUpRight, @@ -9,11 +9,11 @@ import { LayoutTemplate, Search, Sparkles, -} from "lucide-react" +} from "lucide-react"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, @@ -21,16 +21,16 @@ import { DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { Input } from "@/components/ui/input" -import { Progress } from "@/components/ui/progress" -import { cn } from "@/lib/utils" +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Progress } from "@/components/ui/progress"; +import { cn } from "@/lib/utils"; const formatEurFromCents = (cents: number) => new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR", - }).format(cents / 100) + }).format(cents / 100); const mockRuns = [ { @@ -69,53 +69,53 @@ const mockRuns = [ credits: 0, updated: "vor 2 Std.", }, -] +]; const mockWorkspaces = [ { name: "Sommer-Kampagne", nodes: 24, frames: 3, initial: "S" }, { name: "Produktfotos", nodes: 11, frames: 2, initial: "P" }, { name: "Social Variants", nodes: 8, frames: 1, initial: "V" }, -] +]; function StatusDot({ status }: { status: (typeof mockRuns)[0]["status"] }) { - const base = "inline-block size-2 rounded-full" + const base = "inline-block size-2 rounded-full"; switch (status) { case "done": - return + return ; case "executing": return ( - ) + ); case "idle": - return + return ; case "error": - return + return ; } } function statusLabel(status: (typeof mockRuns)[0]["status"]) { switch (status) { case "done": - return "Fertig" + return "Fertig"; case "executing": - return "LĂ€uft" + return "LĂ€uft"; case "idle": - return "Bereit" + return "Bereit"; case "error": - return "Fehler" + return "Fehler"; } } export default function DashboardPage() { - const balanceCents = 4320 - const reservedCents = 180 - const monthlyPoolCents = 5000 + const balanceCents = 4320; + const reservedCents = 180; + const monthlyPoolCents = 5000; const usagePercent = Math.round( ((monthlyPoolCents - balanceCents) / monthlyPoolCents) * 100, - ) + ); return (
@@ -131,6 +131,7 @@ export default function DashboardPage() { unoptimized className="h-5 w-auto shrink-0" aria-hidden + loading="eager" />
@@ -204,7 +205,9 @@ export default function DashboardPage() {
- Monatskontingent + + Monatskontingent + {usagePercent}% @@ -214,7 +217,8 @@ export default function DashboardPage() {

- Bei fehlgeschlagenen Jobs werden reservierte Credits automatisch freigegeben. + Bei fehlgeschlagenen Jobs werden reservierte Credits automatisch + freigegeben.

@@ -248,9 +252,7 @@ export default function DashboardPage() {

Step 2 von 4 —{" "} - - flux-schnell - + flux-schnell

@@ -262,7 +264,12 @@ export default function DashboardPage() { Workspaces - @@ -273,7 +280,7 @@ export default function DashboardPage() { key={ws.name} className={cn( "group flex items-center gap-4 rounded-xl border bg-card p-4 text-left shadow-sm shadow-foreground/3 transition-all", - "hover:bg-muted/60 hover:shadow-md hover:shadow-foreground/4" + "hover:bg-muted/60 hover:shadow-md hover:shadow-foreground/4", )} disabled > @@ -319,7 +326,9 @@ export default function DashboardPage() {
{run.model !== "—" && ( - {run.model} + + {run.model} + )} {run.credits > 0 && ( <> @@ -345,5 +354,5 @@ export default function DashboardPage() {
- ) + ); } diff --git a/components/canvas/canvas-sidebar.tsx b/components/canvas/canvas-sidebar.tsx new file mode 100644 index 0000000..8b2e0b2 --- /dev/null +++ b/components/canvas/canvas-sidebar.tsx @@ -0,0 +1,3 @@ +export default function CanvasSidebar() { + return null; +} diff --git a/components/canvas/canvas-toolbar.tsx b/components/canvas/canvas-toolbar.tsx new file mode 100644 index 0000000..c602328 --- /dev/null +++ b/components/canvas/canvas-toolbar.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useMutation } from "convex/react"; +import { useRef } from "react"; + +import { api } from "@/convex/_generated/api"; +import type { Id } from "@/convex/_generated/dataModel"; + +const nodeTemplates = [ + { + type: "image", + label: "Bild", + width: 280, + height: 180, + defaultData: {}, + }, + { + type: "text", + label: "Text", + width: 256, + height: 120, + defaultData: { content: "" }, + }, + { + type: "prompt", + label: "Prompt", + width: 320, + height: 140, + defaultData: { content: "", model: "" }, + }, + { + type: "note", + label: "Notiz", + width: 220, + height: 120, + defaultData: { content: "" }, + }, + { + type: "frame", + label: "Frame", + width: 360, + height: 240, + defaultData: { label: "Untitled", exportWidth: 1080, exportHeight: 1080 }, + }, +] as const; + +interface CanvasToolbarProps { + canvasId: Id<"canvases">; +} + +export default function CanvasToolbar({ canvasId }: CanvasToolbarProps) { + const createNode = useMutation(api.nodes.create); + const nodeCountRef = useRef(0); + + const handleAddNode = async ( + type: (typeof nodeTemplates)[number]["type"], + data: (typeof nodeTemplates)[number]["defaultData"], + width: number, + height: number, + ) => { + const offset = (nodeCountRef.current % 8) * 24; + nodeCountRef.current += 1; + await createNode({ + canvasId, + type, + positionX: 100 + offset, + positionY: 100 + offset, + width, + height, + data, + }); + }; + + return ( +
+ {nodeTemplates.map((template) => ( + + ))} +
+ ); +} diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx new file mode 100644 index 0000000..28b7cab --- /dev/null +++ b/components/canvas/canvas.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + ReactFlow, + Background, + Controls, + MiniMap, + applyNodeChanges, + applyEdgeChanges, + type Connection, + type Edge as RFEdge, + type EdgeChange, + type Node as RFNode, + type NodeChange, + BackgroundVariant, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; + +import { useMutation, useQuery } from "convex/react"; + +import { api } from "@/convex/_generated/api"; +import type { Id } from "@/convex/_generated/dataModel"; +import { convexEdgeToRF, convexNodeToRF } from "@/lib/canvas-utils"; + +import { nodeTypes } from "./node-types"; + +interface CanvasProps { + canvasId: Id<"canvases">; +} + +export default function Canvas({ canvasId }: CanvasProps) { + const convexNodes = useQuery(api.nodes.list, { canvasId }); + const convexEdges = useQuery(api.edges.list, { canvasId }); + + const moveNode = useMutation(api.nodes.move); + const createEdge = useMutation(api.edges.create); + const removeNode = useMutation(api.nodes.remove); + const removeEdge = useMutation(api.edges.remove); + + const [nodes, setNodes] = useState([]); + const [edges, setEdges] = useState([]); + + const isDragging = useRef(false); + + useEffect(() => { + if (!convexNodes) return; + if (!isDragging.current) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setNodes(convexNodes.map(convexNodeToRF)); + } + }, [convexNodes]); + + useEffect(() => { + if (!convexEdges) return; + // eslint-disable-next-line react-hooks/set-state-in-effect + setEdges(convexEdges.map(convexEdgeToRF)); + }, [convexEdges]); + + const onNodesChange = useCallback((changes: NodeChange[]) => { + setNodes((current) => applyNodeChanges(changes, current)); + }, []); + + const onEdgesChange = useCallback((changes: EdgeChange[]) => { + setEdges((current) => applyEdgeChanges(changes, current)); + }, []); + + const onNodeDragStart = useCallback(() => { + isDragging.current = true; + }, []); + + const onNodeDragStop = useCallback( + (_event: React.MouseEvent, node: RFNode) => { + isDragging.current = false; + void moveNode({ + nodeId: node.id as Id<"nodes">, + positionX: node.position.x, + positionY: node.position.y, + }); + }, + [moveNode], + ); + + const onConnect = useCallback( + (connection: Connection) => { + if (!connection.source || !connection.target) return; + void createEdge({ + canvasId, + sourceNodeId: connection.source as Id<"nodes">, + targetNodeId: connection.target as Id<"nodes">, + sourceHandle: connection.sourceHandle ?? undefined, + targetHandle: connection.targetHandle ?? undefined, + }); + }, + [canvasId, createEdge], + ); + + const onNodesDelete = useCallback( + (deletedNodes: RFNode[]) => { + for (const node of deletedNodes) { + void removeNode({ nodeId: node.id as Id<"nodes"> }); + } + }, + [removeNode], + ); + + const onEdgesDelete = useCallback( + (deletedEdges: RFEdge[]) => { + for (const edge of deletedEdges) { + void removeEdge({ edgeId: edge.id as Id<"edges"> }); + } + }, + [removeEdge], + ); + + if (convexNodes === undefined || convexEdges === undefined) { + return ( +
+
+
+ Canvas laedt... +
+
+ ); + } + + return ( +
+ + + + + +
+ ); +} diff --git a/components/canvas/edges/default-edge.tsx b/components/canvas/edges/default-edge.tsx new file mode 100644 index 0000000..4a93263 --- /dev/null +++ b/components/canvas/edges/default-edge.tsx @@ -0,0 +1,3 @@ +export default function DefaultEdge() { + return null; +} diff --git a/components/canvas/node-types.ts b/components/canvas/node-types.ts new file mode 100644 index 0000000..bdc38b5 --- /dev/null +++ b/components/canvas/node-types.ts @@ -0,0 +1,21 @@ +import type { NodeTypes } from "@xyflow/react"; + +import AiImageNode from "./nodes/ai-image-node"; +import CompareNode from "./nodes/compare-node"; +import FrameNode from "./nodes/frame-node"; +import GroupNode from "./nodes/group-node"; +import ImageNode from "./nodes/image-node"; +import NoteNode from "./nodes/note-node"; +import PromptNode from "./nodes/prompt-node"; +import TextNode from "./nodes/text-node"; + +export const nodeTypes: NodeTypes = { + image: ImageNode, + text: TextNode, + prompt: PromptNode, + "ai-image": AiImageNode, + group: GroupNode, + frame: FrameNode, + note: NoteNode, + compare: CompareNode, +}; diff --git a/components/canvas/nodes/ai-image-node.tsx b/components/canvas/nodes/ai-image-node.tsx new file mode 100644 index 0000000..fbeffc2 --- /dev/null +++ b/components/canvas/nodes/ai-image-node.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; + +import BaseNodeWrapper from "./base-node-wrapper"; + +export type AiImageNodeData = { + url?: string; + prompt?: string; + model?: string; + status?: "idle" | "executing" | "done" | "error"; + errorMessage?: string; +}; + +export type AiImageNode = Node; + +export default function AiImageNode({ data, selected }: NodeProps) { + const status = data.status ?? "idle"; + + return ( + +
KI-Bild
+ + {status === "executing" ? ( +
+
+
+ ) : null} + + {status === "done" && data.url ? ( + {data.prompt + ) : null} + + {status === "error" ? ( +
+ {data.errorMessage ?? "Fehler bei der Generierung"} +
+ ) : null} + + {status === "idle" ? ( +
+ Prompt verbinden +
+ ) : null} + + {data.prompt && status === "done" ? ( +

{data.prompt}

+ ) : null} + + + + + ); +} diff --git a/components/canvas/nodes/base-node-wrapper.tsx b/components/canvas/nodes/base-node-wrapper.tsx new file mode 100644 index 0000000..073d5fb --- /dev/null +++ b/components/canvas/nodes/base-node-wrapper.tsx @@ -0,0 +1,37 @@ +"use client"; + +import type { ReactNode } from "react"; + +interface BaseNodeWrapperProps { + selected?: boolean; + status?: "idle" | "executing" | "done" | "error"; + children: ReactNode; + className?: string; +} + +const statusClassMap: Record, string> = { + idle: "", + executing: "animate-pulse border-yellow-400", + done: "border-green-500", + error: "border-red-500", +}; + +export default function BaseNodeWrapper({ + selected, + status = "idle", + children, + className = "", +}: BaseNodeWrapperProps) { + return ( +
+ {children} +
+ ); +} diff --git a/components/canvas/nodes/compare-node.tsx b/components/canvas/nodes/compare-node.tsx new file mode 100644 index 0000000..8416fb0 --- /dev/null +++ b/components/canvas/nodes/compare-node.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; + +import BaseNodeWrapper from "./base-node-wrapper"; + +export type CompareNodeData = { + leftUrl?: string; + rightUrl?: string; +}; + +export type CompareNode = Node; + +export default function CompareNode({ data, selected }: NodeProps) { + return ( + +
Vergleich
+
+
+ {data.leftUrl ? ( + Bild A + ) : ( + "Bild A" + )} +
+
+ {data.rightUrl ? ( + Bild B + ) : ( + "Bild B" + )} +
+
+ + +
+ ); +} diff --git a/components/canvas/nodes/frame-node.tsx b/components/canvas/nodes/frame-node.tsx new file mode 100644 index 0000000..1a9b6a9 --- /dev/null +++ b/components/canvas/nodes/frame-node.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { type Node, type NodeProps } from "@xyflow/react"; + +import BaseNodeWrapper from "./base-node-wrapper"; + +export type FrameNodeData = { + label?: string; + exportWidth?: number; + exportHeight?: number; +}; + +export type FrameNode = Node; + +export default function FrameNode({ data, selected }: NodeProps) { + const resolution = + data.exportWidth && data.exportHeight + ? `${data.exportWidth}x${data.exportHeight}` + : undefined; + + return ( + +
+ {data.label || "Frame"} {resolution ? `(${resolution})` : ""} +
+
+ ); +} diff --git a/components/canvas/nodes/group-node.tsx b/components/canvas/nodes/group-node.tsx new file mode 100644 index 0000000..28befdf --- /dev/null +++ b/components/canvas/nodes/group-node.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { type Node, type NodeProps } from "@xyflow/react"; + +import BaseNodeWrapper from "./base-node-wrapper"; + +export type GroupNode = Node<{ label?: string }, "group">; + +export default function GroupNode({ data, selected }: NodeProps) { + return ( + +
{data.label || "Gruppe"}
+
+ ); +} diff --git a/components/canvas/nodes/image-node.tsx b/components/canvas/nodes/image-node.tsx new file mode 100644 index 0000000..c5a1131 --- /dev/null +++ b/components/canvas/nodes/image-node.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; + +import BaseNodeWrapper from "./base-node-wrapper"; + +export type ImageNodeData = { + storageId?: string; + url?: string; + originalFilename?: string; +}; + +export type ImageNode = Node; + +export default function ImageNode({ data, selected }: NodeProps) { + return ( + +
Bild
+ {data.url ? ( + {data.originalFilename + ) : ( +
+ Bild hochladen oder URL einfuegen +
+ )} + +
+ ); +} diff --git a/components/canvas/nodes/note-node.tsx b/components/canvas/nodes/note-node.tsx new file mode 100644 index 0000000..952fc46 --- /dev/null +++ b/components/canvas/nodes/note-node.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { type Node, type NodeProps } from "@xyflow/react"; + +import BaseNodeWrapper from "./base-node-wrapper"; + +export type NoteNodeData = { + content?: string; +}; + +export type NoteNode = Node; + +export default function NoteNode({ data, selected }: NodeProps) { + return ( + +
Notiz
+

{data.content || "Leere Notiz"}

+
+ ); +} diff --git a/components/canvas/nodes/prompt-node.tsx b/components/canvas/nodes/prompt-node.tsx new file mode 100644 index 0000000..7ca90d0 --- /dev/null +++ b/components/canvas/nodes/prompt-node.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; + +import BaseNodeWrapper from "./base-node-wrapper"; + +export type PromptNodeData = { + content?: string; + model?: string; +}; + +export type PromptNode = Node; + +export default function PromptNode({ data, selected }: NodeProps) { + return ( + +
Prompt
+

{data.content || "Prompt eingeben..."}

+ {data.model ? ( +
Modell: {data.model}
+ ) : null} + +
+ ); +} diff --git a/components/canvas/nodes/text-node.tsx b/components/canvas/nodes/text-node.tsx new file mode 100644 index 0000000..0b03c65 --- /dev/null +++ b/components/canvas/nodes/text-node.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; + +import BaseNodeWrapper from "./base-node-wrapper"; + +export type TextNodeData = { + content?: string; +}; + +export type TextNode = Node; + +export default function TextNode({ data, selected }: NodeProps) { + return ( + +
Text
+

{data.content || "Text eingeben..."}

+ +
+ ); +} diff --git a/lib/canvas-utils.ts b/lib/canvas-utils.ts new file mode 100644 index 0000000..82c6d12 --- /dev/null +++ b/lib/canvas-utils.ts @@ -0,0 +1,35 @@ +import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react"; + +import type { Doc } from "@/convex/_generated/dataModel"; + +export function convexNodeToRF(node: Doc<"nodes">): RFNode { + return { + id: node._id, + type: node.type, + position: { + x: node.positionX, + y: node.positionY, + }, + data: { + ...(typeof node.data === "object" && node.data !== null ? node.data : {}), + status: node.status, + statusMessage: node.statusMessage, + }, + style: { + width: node.width, + height: node.height, + }, + zIndex: node.zIndex, + parentId: node.parentId, + }; +} + +export function convexEdgeToRF(edge: Doc<"edges">): RFEdge { + return { + id: edge._id, + source: edge.sourceNodeId, + target: edge.targetNodeId, + sourceHandle: edge.sourceHandle, + targetHandle: edge.targetHandle, + }; +} diff --git a/package.json b/package.json index 46f9f3d..b4b2476 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "dependencies": { "@convex-dev/better-auth": "^0.11.3", "@daveyplate/better-auth-ui": "^3.4.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/utilities": "^3.2.2", + "@xyflow/react": "^12.10.1", "better-auth": "^1.5.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb34daa..c4b3554 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,15 @@ importers: '@daveyplate/better-auth-ui': specifier: ^3.4.0 version: 3.4.0(aed41ba285ba33af5b1a54a1a4efb176) + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.4) + '@xyflow/react': + specifier: ^12.10.1 + version: 12.10.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) better-auth: specifier: ^1.5.6 version: 1.5.6(@opentelemetry/api@1.9.0)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -376,6 +385,22 @@ packages: tailwindcss: '>=3.0.0' zod: '>=3.0.0' + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@dotenvx/dotenvx@1.57.2': resolution: {integrity: sha512-lv9+UZPnl/KOvShepevLWm3+/wc1It5kgO5Q580evnvOFMZcgKVEYFwxlL7Ohl9my1yjTsWo28N3PJYUEO8wFQ==} hasBin: true @@ -2046,6 +2071,24 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -2244,6 +2287,15 @@ packages: '@types/react': optional: true + '@xyflow/react@12.10.1': + resolution: {integrity: sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@xyflow/system@0.0.75': + resolution: {integrity: sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -2509,6 +2561,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -2653,6 +2708,44 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -4701,6 +4794,21 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -5048,6 +5156,24 @@ snapshots: - '@types/react' - '@types/react-dom' + '@dnd-kit/accessibility@3.1.1(react@19.2.4)': + dependencies: + react: 19.2.4 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.4) + '@dnd-kit/utilities': 3.2.2(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.4)': + dependencies: + react: 19.2.4 + tslib: 2.8.1 + '@dotenvx/dotenvx@1.57.2': dependencies: commander: 11.1.0 @@ -6650,6 +6776,27 @@ snapshots: tslib: 2.8.1 optional: true + '@types/d3-color@3.1.3': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} @@ -6830,6 +6977,29 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@xyflow/react@12.10.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@xyflow/system': 0.0.75 + classcat: 5.0.5 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + zustand: 4.5.7(@types/react@19.2.14)(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + - immer + + '@xyflow/system@0.0.75': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -7092,6 +7262,8 @@ snapshots: dependencies: clsx: 2.1.1 + classcat@5.0.5: {} + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -7191,6 +7363,42 @@ snapshots: csstype@3.2.3: {} + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + damerau-levenshtein@1.0.8: {} data-uri-to-buffer@4.0.1: {} @@ -9539,3 +9747,10 @@ snapshots: zod@3.25.76: {} zod@4.3.6: {} + + zustand@4.5.7(@types/react@19.2.14)(react@19.2.4): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.4