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 ? (
+
+ ) : (
+
+ 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 && (
+
+ )}
+
+ {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 (
+
+ );
+ }
+
+ 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) => (
+ handleAddNode(t.type, t.defaultData)}
+ className="rounded-lg px-3 py-1.5 text-sm hover:bg-accent transition-colors"
+ title={`${t.label} hinzufĂŒgen`}
+ >
+ {t.label}
+
+ ))}
+
+ );
+}
+```
+
+---
+
+## 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
+
+
+
+
+
+
+
+ 1 â Canvas interface
+ 2 â Node status & skeletons
+ 3 â Agent clarification UX
+ 4 â Dashboard
+
+
+
+
+
+
+
+
LemonSpace â Brand Campaign Q2
+
+
Autosaved
+
Share
+
MH
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
product-hero.jpg
+
+
+
+
+
+
+
Gemini 2.5 Flash
+
~âŹ0,03 / Bild
+
+
+
+
+
+
+
+
Frame â 1080Ă1080px
+
+
+
Fit
+
+
50%
+
+
100%
+
+
+ Node
+
+
+
+ Credits:
+ âŹ34,18
+
+
+
+
+
+
KI-Bild â Inspector
+
+
+
Modell
+
+ Gemini 2.5 Flash Image
+ FLUX.2 Klein 4B
+ GPT-5 Image
+
+
+
+
+
Auflösung
+
1024Ă1024 1792Ă1024
+
+
+
+ GeschÀtzte Kosten
+ ~âŹ0,03
+
+
Generieren
+
+
+
+
+
+
+
+
+
+
+
+
Node Status â AusfĂŒhrungsmodell
+
+
+
+
+
Idle
+
+
+
+
Bereit zur AusfĂŒhrung
+
+
+
+
Executing
+
+
+
+
Gemini 2.5 Flash â ~âŹ0,03 reserviert
+
+
+
+
Done
+
+
+
+
âŹ0,028 verbucht â 4,2s
+
+
+
+
Error
+
+
+
+
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
+
+
+
+ executing 2/6
+ Nodes verschiebbar â Position bleibt bei Output-Ersatz erhalten
+
+
+
+
+
+
+
+
+
+
+
+
Agent Clarification â 3 Varianten
+
+
+
+
+
+
Option A â Inline am Node
+
+
+
Instagram Curator
+
+
Fehlende Angabe
+
Welche Zielgruppe soll angesprochen werden?
+
+
+
Weiter
+
+
+ + 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?
+
+
+ Abbrechen
+ Weiter
+
+
+
+
+ + 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
+
+
+
+
+
+
LemonSpace
+
Alle Canvases
+
Zuletzt geöffnet
+
Templates
+
+
Konto
+
Credits & Abo
+
Einstellungen
+
+
Credits verbleibend
+
âŹ6,18
+
+
Starter â erneuert am 1. Apr
+
Upgrade
+
+
+
+
+
+
Meine Canvases
+
+ Neuer Canvas
+
+
+
+
+
Brand Campaign Q2
+
12 Nodes · vor 2h
+
+
+
+
Instagram Curator Test
+
8 Nodes · gestern
+
+
+
+
+
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
-
+
Neuer Workspace
@@ -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) => (
+
+ void handleAddNode(
+ template.type,
+ template.defaultData,
+ template.width,
+ template.height,
+ )
+ }
+ className="rounded-lg px-3 py-1.5 text-sm transition-colors hover:bg-accent"
+ title={`${template.label} hinzufuegen`}
+ type="button"
+ >
+ {template.label}
+
+ ))}
+
+ );
+}
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 (
+
+ );
+ }
+
+ 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 ? (
+
+ ) : 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"
+ )}
+
+
+ {data.rightUrl ? (
+
+ ) : (
+ "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 ? (
+
+ ) : (
+
+ 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