+
+
-
+
@@ -156,3 +246,15 @@ export default function Canvas({ canvasId }: CanvasProps) {
);
}
+
+interface CanvasProps {
+ canvasId: Id<"canvases">;
+}
+
+export default function Canvas({ canvasId }: CanvasProps) {
+ return (
+
+
+
+ );
+}
diff --git a/components/canvas/node-types.ts b/components/canvas/node-types.ts
index bdc38b5..9e26c8f 100644
--- a/components/canvas/node-types.ts
+++ b/components/canvas/node-types.ts
@@ -1,15 +1,20 @@
-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";
+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: NodeTypes = {
+/**
+ * Node-Type-Map fΓΌr React Flow.
+ *
+ * WICHTIG: Diese Map MUSS auΓerhalb jeder React-Komponente definiert sein.
+ * Sonst erstellt React bei jedem Render ein neues Objekt und React Flow
+ * re-rendert alle Nodes.
+ */
+export const nodeTypes = {
image: ImageNode,
text: TextNode,
prompt: PromptNode,
@@ -18,4 +23,4 @@ export const nodeTypes: NodeTypes = {
frame: FrameNode,
note: NoteNode,
compare: CompareNode,
-};
+} as const;
diff --git a/components/canvas/nodes/ai-image-node.tsx b/components/canvas/nodes/ai-image-node.tsx
index fbeffc2..301fc8e 100644
--- a/components/canvas/nodes/ai-image-node.tsx
+++ b/components/canvas/nodes/ai-image-node.tsx
@@ -1,66 +1,78 @@
"use client";
-import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
-
+import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
import BaseNodeWrapper from "./base-node-wrapper";
-export type AiImageNodeData = {
+type AiImageNodeData = {
url?: string;
prompt?: string;
model?: string;
- status?: "idle" | "executing" | "done" | "error";
- errorMessage?: string;
+ _status?: string;
+ _statusMessage?: string;
};
export type AiImageNode = Node
;
-export default function AiImageNode({ data, selected }: NodeProps) {
- const status = data.status ?? "idle";
+export default function AiImageNode({
+ data,
+ selected,
+}: NodeProps) {
+ const status = data._status ?? "idle";
return (
-
- KI-Bild
-
- {status === "executing" ? (
-
-
+
+
+
+ π€ KI-Bild
- ) : null}
- {status === "done" && data.url ? (
-

- ) : null}
+ {status === "executing" && (
+
+ )}
- {status === "error" ? (
-
- {data.errorMessage ?? "Fehler bei der Generierung"}
-
- ) : null}
+ {status === "done" && data.url && (
+

+ )}
- {status === "idle" ? (
-
- Prompt verbinden
-
- ) : null}
+ {status === "error" && (
+
+ {data._statusMessage ?? "Fehler bei der Generierung"}
+
+ )}
- {data.prompt && status === "done" ? (
-
{data.prompt}
- ) : null}
+ {status === "idle" && (
+
+ Prompt verbinden
+
+ )}
+
+ {data.prompt && status === "done" && (
+
+ {data.prompt}
+
+ )}
+
);
diff --git a/components/canvas/nodes/base-node-wrapper.tsx b/components/canvas/nodes/base-node-wrapper.tsx
index 073d5fb..fd7b51c 100644
--- a/components/canvas/nodes/base-node-wrapper.tsx
+++ b/components/canvas/nodes/base-node-wrapper.tsx
@@ -4,34 +4,43 @@ import type { ReactNode } from "react";
interface BaseNodeWrapperProps {
selected?: boolean;
- status?: "idle" | "executing" | "done" | "error";
+ status?: string;
+ statusMessage?: string;
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",
+ statusMessage,
children,
className = "",
}: BaseNodeWrapperProps) {
+ const statusStyles: Record = {
+ idle: "",
+ analyzing: "border-yellow-400 animate-pulse",
+ clarifying: "border-amber-400",
+ executing: "border-yellow-400 animate-pulse",
+ done: "border-green-500",
+ error: "border-red-500",
+ };
+
return (
{children}
+ {status === "error" && statusMessage && (
+
+ {statusMessage}
+
+ )}
);
}
diff --git a/components/canvas/nodes/compare-node.tsx b/components/canvas/nodes/compare-node.tsx
index 8416fb0..c417c27 100644
--- a/components/canvas/nodes/compare-node.tsx
+++ b/components/canvas/nodes/compare-node.tsx
@@ -1,33 +1,57 @@
"use client";
-import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
-
+import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
+import Image from "next/image";
import BaseNodeWrapper from "./base-node-wrapper";
-export type CompareNodeData = {
+type CompareNodeData = {
leftUrl?: string;
rightUrl?: string;
+ _status?: string;
};
export type CompareNode = Node;
-export default function CompareNode({ data, selected }: NodeProps) {
+export default function CompareNode({
+ data,
+ selected,
+}: NodeProps) {
return (
- Vergleich
+
+ π Vergleich
+
-
+
{data.leftUrl ? (
-

+
) : (
- "Bild A"
+
+ Bild A
+
)}
-
+
{data.rightUrl ? (
-

+
) : (
- "Bild B"
+
+ Bild B
+
)}
@@ -35,14 +59,14 @@ export default function CompareNode({ data, selected }: NodeProps
)
type="target"
position={Position.Left}
id="left"
- className="!h-3 !w-3 !border-2 !border-background !bg-primary"
+ className="h-3! w-3! bg-primary! border-2! border-background!"
style={{ top: "40%" }}
/>
diff --git a/components/canvas/nodes/frame-node.tsx b/components/canvas/nodes/frame-node.tsx
index 1a9b6a9..e2038dd 100644
--- a/components/canvas/nodes/frame-node.tsx
+++ b/components/canvas/nodes/frame-node.tsx
@@ -1,28 +1,72 @@
"use client";
-import { type Node, type NodeProps } from "@xyflow/react";
-
+import { useState, useCallback } from "react";
+import { type NodeProps, type Node } from "@xyflow/react";
+import { useMutation } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import type { Id } from "@/convex/_generated/dataModel";
import BaseNodeWrapper from "./base-node-wrapper";
-export type FrameNodeData = {
+type FrameNodeData = {
label?: string;
- exportWidth?: number;
- exportHeight?: number;
+ resolution?: string;
+ _status?: string;
+ _statusMessage?: string;
};
export type FrameNode = Node;
-export default function FrameNode({ data, selected }: NodeProps) {
- const resolution =
- data.exportWidth && data.exportHeight
- ? `${data.exportWidth}x${data.exportHeight}`
- : undefined;
+export default function FrameNode({ id, data, selected }: NodeProps) {
+ const updateData = useMutation(api.nodes.updateData);
+ const [editingLabel, setEditingLabel] = useState(null);
+
+ const displayLabel = data.label ?? "Frame";
+ const isEditing = editingLabel !== null;
+
+ const handleDoubleClick = useCallback(() => {
+ setEditingLabel(displayLabel);
+ }, [displayLabel]);
+
+ const handleBlur = useCallback(() => {
+ if (editingLabel !== null && editingLabel !== data.label) {
+ updateData({
+ nodeId: id as Id<"nodes">,
+ data: {
+ ...data,
+ label: editingLabel,
+ _status: undefined,
+ _statusMessage: undefined,
+ },
+ });
+ }
+ setEditingLabel(null);
+ }, [editingLabel, data, id, updateData]);
return (
-
-
- {data.label || "Frame"} {resolution ? `(${resolution})` : ""}
-
+
+ {isEditing ? (
+ setEditingLabel(e.target.value)}
+ onBlur={handleBlur}
+ onKeyDown={(e) => e.key === "Enter" && handleBlur()}
+ autoFocus
+ className="nodrag text-xs font-medium text-blue-500 bg-transparent border-0 outline-none w-full"
+ />
+ ) : (
+
+ π₯οΈ {displayLabel}{" "}
+ {data.resolution && (
+ ({data.resolution})
+ )}
+
+ )}
);
}
diff --git a/components/canvas/nodes/group-node.tsx b/components/canvas/nodes/group-node.tsx
index 28befdf..72f01c0 100644
--- a/components/canvas/nodes/group-node.tsx
+++ b/components/canvas/nodes/group-node.tsx
@@ -1,15 +1,68 @@
"use client";
-import { type Node, type NodeProps } from "@xyflow/react";
-
+import { useState, useCallback, useEffect } from "react";
+import { type NodeProps, type Node } from "@xyflow/react";
+import { useMutation } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import type { Id } from "@/convex/_generated/dataModel";
import BaseNodeWrapper from "./base-node-wrapper";
-export type GroupNode = Node<{ label?: string }, "group">;
+type GroupNodeData = {
+ label?: string;
+ _status?: string;
+ _statusMessage?: string;
+};
+
+export type GroupNode = Node;
+
+export default function GroupNode({ id, data, selected }: NodeProps) {
+ const updateData = useMutation(api.nodes.updateData);
+ const [label, setLabel] = useState(data.label ?? "Gruppe");
+ const [isEditing, setIsEditing] = useState(false);
+
+ useEffect(() => {
+ if (!isEditing) {
+ setLabel(data.label ?? "Gruppe");
+ }
+ }, [data.label, isEditing]);
+
+ const handleBlur = useCallback(() => {
+ setIsEditing(false);
+ if (label !== data.label) {
+ updateData({
+ nodeId: id as Id<"nodes">,
+ data: {
+ ...data,
+ label,
+ _status: undefined,
+ _statusMessage: undefined,
+ },
+ });
+ }
+ }, [label, data, id, updateData]);
-export default function GroupNode({ data, selected }: NodeProps) {
return (
-
- {data.label || "Gruppe"}
+
+ {isEditing ? (
+ setLabel(e.target.value)}
+ onBlur={handleBlur}
+ onKeyDown={(e) => e.key === "Enter" && handleBlur()}
+ autoFocus
+ className="nodrag text-xs font-medium text-muted-foreground bg-transparent border-0 outline-none w-full"
+ />
+ ) : (
+ setIsEditing(true)}
+ className="text-xs font-medium text-muted-foreground cursor-text"
+ >
+ π {label}
+
+ )}
);
}
diff --git a/components/canvas/nodes/image-node.tsx b/components/canvas/nodes/image-node.tsx
index c5a1131..9f42060 100644
--- a/components/canvas/nodes/image-node.tsx
+++ b/components/canvas/nodes/image-node.tsx
@@ -1,37 +1,194 @@
"use client";
-import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
-
+import {
+ useState,
+ useCallback,
+ useRef,
+ type ChangeEvent,
+ type DragEvent,
+} from "react";
+import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
+import { useMutation } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import type { Id } from "@/convex/_generated/dataModel";
+import Image from "next/image";
import BaseNodeWrapper from "./base-node-wrapper";
-export type ImageNodeData = {
+type ImageNodeData = {
storageId?: string;
url?: string;
- originalFilename?: string;
+ filename?: string;
+ mimeType?: string;
+ _status?: string;
+ _statusMessage?: string;
};
export type ImageNode = Node;
-export default function ImageNode({ data, selected }: NodeProps) {
+export default function ImageNode({ id, data, selected }: NodeProps) {
+ const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
+ const updateData = useMutation(api.nodes.updateData);
+ const fileInputRef = useRef(null);
+ const [isUploading, setIsUploading] = useState(false);
+ const [isDragOver, setIsDragOver] = useState(false);
+
+ const uploadFile = useCallback(
+ async (file: File) => {
+ if (!file.type.startsWith("image/")) return;
+ setIsUploading(true);
+
+ try {
+ const uploadUrl = await generateUploadUrl();
+ const result = await fetch(uploadUrl, {
+ method: "POST",
+ headers: { "Content-Type": file.type },
+ body: file,
+ });
+
+ if (!result.ok) {
+ throw new Error("Upload failed");
+ }
+
+ const { storageId } = (await result.json()) as { storageId: string };
+
+ await updateData({
+ nodeId: id as Id<"nodes">,
+ data: {
+ storageId,
+ filename: file.name,
+ mimeType: file.type,
+ },
+ });
+ } catch (err) {
+ console.error("Upload failed:", err);
+ } finally {
+ setIsUploading(false);
+ }
+ },
+ [id, generateUploadUrl, updateData]
+ );
+
+ const handleClick = useCallback(() => {
+ if (!data.url && !isUploading) {
+ fileInputRef.current?.click();
+ }
+ }, [data.url, isUploading]);
+
+ const handleFileChange = useCallback(
+ (e: ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (file) uploadFile(file);
+ },
+ [uploadFile]
+ );
+
+ const handleDragOver = useCallback((e: DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.dataTransfer.types.includes("Files")) {
+ setIsDragOver(true);
+ e.dataTransfer.dropEffect = "copy";
+ }
+ }, []);
+
+ const handleDragLeave = useCallback((e: DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragOver(false);
+ }, []);
+
+ const handleDrop = useCallback(
+ (e: DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragOver(false);
+
+ const file = e.dataTransfer.files?.[0];
+ if (file && file.type.startsWith("image/")) {
+ uploadFile(file);
+ }
+ },
+ [uploadFile]
+ );
+
+ const handleReplace = useCallback(() => {
+ fileInputRef.current?.click();
+ }, []);
+
return (
-
- Bild
- {data.url ? (
-
- ) : (
-
- Bild hochladen oder URL einfuegen
+
+
+
+
πΌοΈ Bild
+ {data.url && (
+
+ )}
- )}
+
+ {isUploading ? (
+
+
+
+
Wird hochgeladen...
+
+
+ ) : data.url ? (
+
+
+
+ ) : (
+
+ π
+ Klicken oder hierhin ziehen
+ PNG, JPG, WebP
+
+ )}
+
+ {data.filename && data.url && (
+
+ {data.filename}
+
+ )}
+
+
+
+
);
diff --git a/components/canvas/nodes/note-node.tsx b/components/canvas/nodes/note-node.tsx
index 952fc46..f4e1006 100644
--- a/components/canvas/nodes/note-node.tsx
+++ b/components/canvas/nodes/note-node.tsx
@@ -1,20 +1,83 @@
"use client";
-import { type Node, type NodeProps } from "@xyflow/react";
-
+import { useState, useCallback, useEffect } from "react";
+import { type NodeProps, type Node } from "@xyflow/react";
+import { useMutation } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import type { Id } from "@/convex/_generated/dataModel";
+import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import BaseNodeWrapper from "./base-node-wrapper";
-export type NoteNodeData = {
+type NoteNodeData = {
content?: string;
+ _status?: string;
+ _statusMessage?: string;
};
export type NoteNode = Node
;
-export default function NoteNode({ data, selected }: NodeProps) {
+export default function NoteNode({ id, data, selected }: NodeProps) {
+ const updateData = useMutation(api.nodes.updateData);
+ const [content, setContent] = useState(data.content ?? "");
+ const [isEditing, setIsEditing] = useState(false);
+
+ useEffect(() => {
+ if (!isEditing) {
+ setContent(data.content ?? "");
+ }
+ }, [data.content, isEditing]);
+
+ const saveContent = useDebouncedCallback(
+ (newContent: string) => {
+ updateData({
+ nodeId: id as Id<"nodes">,
+ data: {
+ ...data,
+ content: newContent,
+ _status: undefined,
+ _statusMessage: undefined,
+ },
+ });
+ },
+ 500,
+ );
+
+ const handleChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const newContent = e.target.value;
+ setContent(newContent);
+ saveContent(newContent);
+ },
+ [saveContent],
+ );
+
return (
- Notiz
- {data.content || "Leere Notiz"}
+
+ π Notiz
+
+ {isEditing ? (
+
);
}
diff --git a/components/canvas/nodes/prompt-node.tsx b/components/canvas/nodes/prompt-node.tsx
index 7ca90d0..bceb846 100644
--- a/components/canvas/nodes/prompt-node.tsx
+++ b/components/canvas/nodes/prompt-node.tsx
@@ -1,28 +1,103 @@
"use client";
-import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
-
+import { useState, useCallback, useEffect } from "react";
+import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
+import { useMutation } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import type { Id } from "@/convex/_generated/dataModel";
+import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import BaseNodeWrapper from "./base-node-wrapper";
-export type PromptNodeData = {
- content?: string;
+type PromptNodeData = {
+ prompt?: string;
model?: string;
+ _status?: string;
+ _statusMessage?: string;
};
export type PromptNode = Node;
-export default function PromptNode({ data, selected }: NodeProps) {
+export default function PromptNode({
+ id,
+ data,
+ selected,
+}: NodeProps) {
+ const updateData = useMutation(api.nodes.updateData);
+ const [prompt, setPrompt] = useState(data.prompt ?? "");
+ const [isEditing, setIsEditing] = useState(false);
+
+ useEffect(() => {
+ if (!isEditing) {
+ setPrompt(data.prompt ?? "");
+ }
+ }, [data.prompt, isEditing]);
+
+ const savePrompt = useDebouncedCallback(
+ (newPrompt: string) => {
+ updateData({
+ nodeId: id as Id<"nodes">,
+ data: {
+ ...data,
+ prompt: newPrompt,
+ _status: undefined,
+ _statusMessage: undefined,
+ },
+ });
+ },
+ 500,
+ );
+
+ const handleChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const newPrompt = e.target.value;
+ setPrompt(newPrompt);
+ savePrompt(newPrompt);
+ },
+ [savePrompt],
+ );
+
return (
-
- Prompt
- {data.content || "Prompt eingeben..."}
- {data.model ? (
- Modell: {data.model}
- ) : null}
+
+
+
+ β¨ Prompt
+
+ {isEditing ? (
+
);
diff --git a/components/canvas/nodes/text-node.tsx b/components/canvas/nodes/text-node.tsx
index 0b03c65..dcd4a6f 100644
--- a/components/canvas/nodes/text-node.tsx
+++ b/components/canvas/nodes/text-node.tsx
@@ -1,24 +1,91 @@
"use client";
-import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
-
+import { useState, useCallback, useEffect } from "react";
+import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
+import { useMutation } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import type { Id } from "@/convex/_generated/dataModel";
+import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import BaseNodeWrapper from "./base-node-wrapper";
-export type TextNodeData = {
+type TextNodeData = {
content?: string;
+ _status?: string;
+ _statusMessage?: string;
};
export type TextNode = Node;
-export default function TextNode({ data, selected }: NodeProps) {
+export default function TextNode({ id, data, selected }: NodeProps) {
+ const updateData = useMutation(api.nodes.updateData);
+ const [content, setContent] = useState(data.content ?? "");
+ const [isEditing, setIsEditing] = useState(false);
+
+ // Sync von auΓen (Convex-Update) wenn nicht gerade editiert wird
+ useEffect(() => {
+ if (!isEditing) {
+ setContent(data.content ?? "");
+ }
+ }, [data.content, isEditing]);
+
+ // Debounced Save β 500ms nach letztem Tastendruck
+ const saveContent = useDebouncedCallback(
+ (newContent: string) => {
+ updateData({
+ nodeId: id as Id<"nodes">,
+ data: {
+ ...data,
+ content: newContent,
+ _status: undefined,
+ _statusMessage: undefined,
+ },
+ });
+ },
+ 500,
+ );
+
+ const handleChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const newContent = e.target.value;
+ setContent(newContent);
+ saveContent(newContent);
+ },
+ [saveContent],
+ );
+
return (
-
- Text
- {data.content || "Text eingeben..."}
+
+
+
+ π Text
+
+ {isEditing ? (
+
);
diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts
index 38f1a07..4aed78d 100644
--- a/convex/_generated/api.d.ts
+++ b/convex/_generated/api.d.ts
@@ -15,6 +15,7 @@ import type * as edges from "../edges.js";
import type * as helpers from "../helpers.js";
import type * as http from "../http.js";
import type * as nodes from "../nodes.js";
+import type * as storage from "../storage.js";
import type {
ApiFromModules,
@@ -30,6 +31,7 @@ declare const fullApi: ApiFromModules<{
helpers: typeof helpers;
http: typeof http;
nodes: typeof nodes;
+ storage: typeof storage;
}>;
/**
diff --git a/convex/helpers.ts b/convex/helpers.ts
index 772673e..a0f96ca 100644
--- a/convex/helpers.ts
+++ b/convex/helpers.ts
@@ -17,10 +17,14 @@ export async function requireAuth(
): Promise {
const user = await authComponent.safeGetAuthUser(ctx);
if (!user) {
+ console.error("[requireAuth] safeGetAuthUser returned null");
throw new Error("Unauthenticated");
}
const userId = user.userId ?? String(user._id);
if (!userId) {
+ console.error("[requireAuth] safeGetAuthUser returned user without userId", {
+ userRecordId: String(user._id),
+ });
throw new Error("Unauthenticated");
}
return { ...user, userId };
diff --git a/convex/nodes.ts b/convex/nodes.ts
index 7dab827..da100fe 100644
--- a/convex/nodes.ts
+++ b/convex/nodes.ts
@@ -22,6 +22,18 @@ async function getCanvasOrThrow(
return canvas;
}
+async function getCanvasIfAuthorized(
+ ctx: QueryCtx | MutationCtx,
+ canvasId: Id<"canvases">,
+ userId: string
+) {
+ const canvas = await ctx.db.get(canvasId);
+ if (!canvas || canvas.ownerId !== userId) {
+ return null;
+ }
+ return canvas;
+}
+
// ============================================================================
// Queries
// ============================================================================
@@ -35,10 +47,29 @@ export const list = query({
const user = await requireAuth(ctx);
await getCanvasOrThrow(ctx, canvasId, user.userId);
- return await ctx.db
+ const nodes = await ctx.db
.query("nodes")
.withIndex("by_canvas", (q) => q.eq("canvasId", canvasId))
.collect();
+
+ return Promise.all(
+ nodes.map(async (node) => {
+ const data = node.data as Record | undefined;
+ if (!data?.storageId) {
+ return node;
+ }
+
+ const url = await ctx.storage.getUrl(data.storageId as Id<"_storage">);
+
+ return {
+ ...node,
+ data: {
+ ...data,
+ url: url ?? undefined,
+ },
+ };
+ })
+ );
},
});
@@ -52,7 +83,11 @@ export const get = query({
const node = await ctx.db.get(nodeId);
if (!node) return null;
- await getCanvasOrThrow(ctx, node.canvasId, user.userId);
+ const canvas = await getCanvasIfAuthorized(ctx, node.canvasId, user.userId);
+ if (!canvas) {
+ return null;
+ }
+
return node;
},
});
@@ -67,7 +102,10 @@ export const listByType = query({
},
handler: async (ctx, { canvasId, type }) => {
const user = await requireAuth(ctx);
- await getCanvasOrThrow(ctx, canvasId, user.userId);
+ const canvas = await getCanvasIfAuthorized(ctx, canvasId, user.userId);
+ if (!canvas) {
+ return [];
+ }
return await ctx.db
.query("nodes")
diff --git a/convex/storage.ts b/convex/storage.ts
new file mode 100644
index 0000000..2351bd2
--- /dev/null
+++ b/convex/storage.ts
@@ -0,0 +1,10 @@
+import { mutation } from "./_generated/server";
+import { requireAuth } from "./helpers";
+
+export const generateUploadUrl = mutation({
+ args: {},
+ handler: async (ctx) => {
+ await requireAuth(ctx);
+ return await ctx.storage.generateUploadUrl();
+ },
+});
diff --git a/hooks/use-debounced-callback.ts b/hooks/use-debounced-callback.ts
new file mode 100644
index 0000000..f554cb2
--- /dev/null
+++ b/hooks/use-debounced-callback.ts
@@ -0,0 +1,37 @@
+import { useRef, useCallback, useEffect } from "react";
+
+/**
+ * Debounced callback β ruft `callback` erst auf, wenn `delay` ms
+ * ohne erneuten Aufruf vergangen sind. Perfekt fΓΌr Auto-Save.
+ */
+export function useDebouncedCallback(
+ callback: (...args: Args) => void,
+ delay: number,
+): (...args: Args) => void {
+ const timeoutRef = useRef | null>(null);
+ const callbackRef = useRef(callback);
+
+ // Callback-Ref aktuell halten ohne neu zu rendern
+ useEffect(() => {
+ callbackRef.current = callback;
+ }, [callback]);
+
+ // Cleanup bei Unmount
+ useEffect(() => {
+ return () => {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ };
+ }, []);
+
+ const debouncedFn = useCallback(
+ (...args: Args) => {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ timeoutRef.current = setTimeout(() => {
+ callbackRef.current(...args);
+ }, delay);
+ },
+ [delay],
+ );
+
+ return debouncedFn;
+}
diff --git a/implement/README.md b/implement/README.md
new file mode 100644
index 0000000..c56b635
--- /dev/null
+++ b/implement/README.md
@@ -0,0 +1,103 @@
+# Bild-Upload via Convex Storage β Einbau-Anleitung
+
+## Konzept
+
+Der Upload-Flow nutzt Convex File Storage in 3 Schritten:
+1. **generateUploadUrl** β kurzlebige Upload-URL vom Backend
+2. **fetch(POST)** β Datei direkt an Convex Storage senden
+3. **updateData** β `storageId` im Node speichern
+
+Die **URL wird serverseitig** in der `nodes.list` Query aufgelΓΆst β nicht
+am Client. Das heiΓt: der Node speichert nur die `storageId`, und bei
+jedem Query-Aufruf wird `ctx.storage.getUrl(storageId)` aufgerufen und
+als `data.url` zurΓΌckgegeben.
+
+## Dateien
+
+```
+upload-files/
+ convex/
+ storage.ts β convex/storage.ts (NEU)
+ nodes-list-patch.ts β PATCH fΓΌr convex/nodes.ts (NUR die list Query ersetzen)
+ components/canvas/nodes/
+ image-node.tsx β ERSETZT alte Version
+
+Gesamt: 3 Dateien (1 neu, 1 Patch, 1 Ersatz)
+```
+
+## Einbau-Schritte
+
+### 1. `convex/storage.ts` anlegen
+Kopiere die Datei direkt. Sie enthΓ€lt eine einzige Mutation: `generateUploadUrl`.
+
+### 2. `convex/nodes.ts` β `list` Query patchen
+Ersetze **nur die `list` Query** in deiner bestehenden `convex/nodes.ts`
+mit der Version aus `nodes-list-patch.ts`. Der Rest der Datei
+(create, move, resize, etc.) bleibt unverΓ€ndert.
+
+Die Γnderung: Nach dem `collect()` wird ΓΌber alle Nodes iteriert.
+Wenn ein Node `data.storageId` hat, wird `ctx.storage.getUrl()` aufgerufen
+und das Ergebnis als `data.url` eingefΓΌgt.
+
+**Wichtig:** Du brauchst den `Id` Import oben in der Datei:
+```ts
+import type { Doc, Id } from "./_generated/dataModel";
+```
+(Du hast `Doc` wahrscheinlich schon importiert β fΓΌge `Id` hinzu falls nΓΆtig.)
+
+### 3. `image-node.tsx` ersetzen
+Die neue Version hat:
+- **Click-to-Upload**: Klick auf den leeren Node ΓΆffnet File-Picker
+- **Drag & Drop**: Bilder direkt auf den Node ziehen (Files vom OS)
+- **Ersetzen-Button**: Wenn bereits ein Bild vorhanden, oben rechts "Ersetzen"
+- **Upload-Spinner**: WΓ€hrend des Uploads dreht sich ein Spinner
+- **Dateiname**: Wird unter dem Bild angezeigt
+
+## Upload-Flow im Detail
+
+```
+User zieht Bild auf Image-Node
+ β
+ ββ handleDrop() β uploadFile(file)
+ β
+ ββ 1. generateUploadUrl() β Convex Mutation
+ β β postUrl (kurzlebig)
+ β
+ ββ 2. fetch(postUrl, { body: file })
+ β β { storageId: "kg..." }
+ β
+ ββ 3. updateData({ nodeId, data: { storageId, filename, mimeType } })
+ β β Convex speichert storageId im Node
+ β
+ ββ 4. nodes.list Query feuert automatisch neu (Realtime)
+ β ctx.storage.getUrl(storageId) β data.url
+ β Image-Node rendert das Bild
+```
+
+## Testing
+
+### Test 1: Click-to-Upload
+- Erstelle einen Image-Node (Sidebar oder Toolbar)
+- Klicke auf "Klicken oder hierhin ziehen"
+- β
File-Picker ΓΆffnet sich
+- WΓ€hle ein Bild (PNG/JPG/WebP)
+- β
Spinner erscheint kurz, dann wird das Bild angezeigt
+- β
Convex Dashboard: `data.storageId` ist gesetzt
+
+### Test 2: Drag & Drop (File vom OS)
+- Ziehe ein Bild aus dem Finder/Explorer direkt auf den Image-Node
+- β
Drop-Zone wird blau hervorgehoben
+- β
Bild wird hochgeladen und angezeigt
+
+### Test 3: Bild ersetzen
+- Klicke "Ersetzen" oben rechts am Image-Node
+- WΓ€hle ein neues Bild
+- β
Altes Bild wird ersetzt, neue storageId in Convex
+
+### Test 4: URL wird serverseitig aufgelΓΆst
+- Lade die Seite neu
+- β
Bild wird weiterhin angezeigt (URL wird bei jedem Query neu aufgelΓΆst)
+
+### Test 5: Nicht-Bild-Dateien werden ignoriert
+- Versuche eine .txt oder .pdf auf den Node zu ziehen
+- β
Nichts passiert (nur image/* wird akzeptiert)
diff --git a/implement/image-node.tsx b/implement/image-node.tsx
new file mode 100644
index 0000000..4d8e43b
--- /dev/null
+++ b/implement/image-node.tsx
@@ -0,0 +1,187 @@
+"use client";
+
+import { useState, useCallback, useRef } from "react";
+import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
+import { useMutation } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import type { Id } from "@/convex/_generated/dataModel";
+import BaseNodeWrapper from "./base-node-wrapper";
+
+type ImageNodeData = {
+ storageId?: string;
+ url?: string;
+ filename?: string;
+ mimeType?: string;
+ _status?: string;
+ _statusMessage?: string;
+};
+
+export type ImageNode = Node;
+
+export default function ImageNode({ id, data, selected }: NodeProps) {
+ const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
+ const updateData = useMutation(api.nodes.updateData);
+ const fileInputRef = useRef(null);
+ const [isUploading, setIsUploading] = useState(false);
+ const [isDragOver, setIsDragOver] = useState(false);
+
+ const uploadFile = useCallback(
+ async (file: File) => {
+ if (!file.type.startsWith("image/")) return;
+ setIsUploading(true);
+
+ try {
+ // 1. Upload-URL generieren
+ const uploadUrl = await generateUploadUrl();
+
+ // 2. Datei hochladen
+ const result = await fetch(uploadUrl, {
+ method: "POST",
+ headers: { "Content-Type": file.type },
+ body: file,
+ });
+ const { storageId } = await result.json();
+
+ // 3. Node-Data mit storageId aktualisieren
+ // Die URL wird serverseitig in der nodes.list Query aufgelΓΆst
+ await updateData({
+ nodeId: id as Id<"nodes">,
+ data: {
+ storageId,
+ filename: file.name,
+ mimeType: file.type,
+ },
+ });
+ } catch (err) {
+ console.error("Upload failed:", err);
+ } finally {
+ setIsUploading(false);
+ }
+ },
+ [id, generateUploadUrl, updateData],
+ );
+
+ // Click-to-Upload
+ const handleClick = useCallback(() => {
+ if (!data.url && !isUploading) {
+ fileInputRef.current?.click();
+ }
+ }, [data.url, isUploading]);
+
+ const handleFileChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (file) uploadFile(file);
+ },
+ [uploadFile],
+ );
+
+ // Drag & Drop auf den Node
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.dataTransfer.types.includes("Files")) {
+ setIsDragOver(true);
+ e.dataTransfer.dropEffect = "copy";
+ }
+ }, []);
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragOver(false);
+ }, []);
+
+ const handleDrop = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragOver(false);
+
+ const file = e.dataTransfer.files?.[0];
+ if (file && file.type.startsWith("image/")) {
+ uploadFile(file);
+ }
+ },
+ [uploadFile],
+ );
+
+ // Bild ersetzen
+ const handleReplace = useCallback(() => {
+ fileInputRef.current?.click();
+ }, []);
+
+ return (
+
+
+
+
+ πΌοΈ Bild
+
+ {data.url && (
+
+ )}
+
+
+ {isUploading ? (
+
+
+
+
+ Wird hochgeladenβ¦
+
+
+
+ ) : data.url ? (
+

+ ) : (
+
+ π
+ Klicken oder hierhin ziehen
+ PNG, JPG, WebP
+
+ )}
+
+ {data.filename && data.url && (
+
+ {data.filename}
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/implement/nodes-list-patch.ts b/implement/nodes-list-patch.ts
new file mode 100644
index 0000000..36290f9
--- /dev/null
+++ b/implement/nodes-list-patch.ts
@@ -0,0 +1,37 @@
+/**
+ * PATCH fΓΌr convex/nodes.ts
+ *
+ * Ersetze die bestehende `list` Query mit dieser Version.
+ * Der einzige Unterschied: FΓΌr Nodes mit einem `storageId` im data-Objekt
+ * wird die Storage-URL aufgelΓΆst und als `data.url` zurΓΌckgegeben.
+ */
+
+export const list = query({
+ args: { canvasId: v.id("canvases") },
+ handler: async (ctx, { canvasId }) => {
+ const user = await requireAuth(ctx);
+ await getCanvasOrThrow(ctx, canvasId, user.userId);
+
+ const nodes = await ctx.db
+ .query("nodes")
+ .withIndex("by_canvas", (q) => q.eq("canvasId", canvasId))
+ .collect();
+
+ // Storage-URLs fΓΌr Nodes mit storageId auflΓΆsen
+ return Promise.all(
+ nodes.map(async (node) => {
+ const data = node.data as Record | undefined;
+ if (data?.storageId) {
+ const url = await ctx.storage.getUrl(
+ data.storageId as Id<"_storage">
+ );
+ return {
+ ...node,
+ data: { ...data, url: url ?? undefined },
+ };
+ }
+ return node;
+ })
+ );
+ },
+});
diff --git a/implement/storage.ts b/implement/storage.ts
new file mode 100644
index 0000000..9264e3f
--- /dev/null
+++ b/implement/storage.ts
@@ -0,0 +1,14 @@
+import { mutation } from "./_generated/server";
+import { requireAuth } from "./helpers";
+
+/**
+ * Generiert eine kurzlebige Upload-URL fΓΌr Convex File Storage.
+ * Der Client POSTet die Datei direkt an diese URL.
+ */
+export const generateUploadUrl = mutation({
+ args: {},
+ handler: async (ctx) => {
+ await requireAuth(ctx);
+ return await ctx.storage.generateUploadUrl();
+ },
+});
diff --git a/lib/canvas-utils.ts b/lib/canvas-utils.ts
index 82c6d12..97787b9 100644
--- a/lib/canvas-utils.ts
+++ b/lib/canvas-utils.ts
@@ -1,35 +1,62 @@
-import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
-
+import type { Node as RFNode, Edge as RFEdge } from "@xyflow/react";
import type { Doc } from "@/convex/_generated/dataModel";
+/**
+ * Convex Node β React Flow Node
+ *
+ * Convex speichert positionX/positionY als separate Felder,
+ * React Flow erwartet position: { x, y }.
+ */
export function convexNodeToRF(node: Doc<"nodes">): RFNode {
return {
id: node._id,
type: node.type,
- position: {
- x: node.positionX,
- y: node.positionY,
- },
+ position: { x: node.positionX, y: node.positionY },
data: {
- ...(typeof node.data === "object" && node.data !== null ? node.data : {}),
- status: node.status,
- statusMessage: node.statusMessage,
+ ...(node.data as Record),
+ // Status direkt in data durchreichen, damit Node-Komponenten darauf zugreifen kΓΆnnen
+ _status: node.status,
+ _statusMessage: node.statusMessage,
},
+ parentId: node.parentId ?? undefined,
+ zIndex: node.zIndex,
style: {
width: node.width,
height: node.height,
},
- zIndex: node.zIndex,
- parentId: node.parentId,
};
}
+/**
+ * Convex Edge β React Flow Edge
+ */
export function convexEdgeToRF(edge: Doc<"edges">): RFEdge {
return {
id: edge._id,
source: edge.sourceNodeId,
target: edge.targetNodeId,
- sourceHandle: edge.sourceHandle,
- targetHandle: edge.targetHandle,
+ sourceHandle: edge.sourceHandle ?? undefined,
+ targetHandle: edge.targetHandle ?? undefined,
};
}
+
+/**
+ * Default-GrΓΆΓen fΓΌr neue Nodes je nach Typ.
+ */
+export const NODE_DEFAULTS: Record<
+ string,
+ { width: number; height: number; data: Record }
+> = {
+ image: { width: 280, height: 200, data: {} },
+ text: { width: 256, height: 120, data: { content: "" } },
+ prompt: { width: 288, height: 140, data: { prompt: "" } },
+ "ai-image": { width: 280, height: 220, data: {} },
+ group: { width: 400, height: 300, data: { label: "Gruppe" } },
+ frame: {
+ width: 400,
+ height: 300,
+ data: { label: "Frame", resolution: "1080x1080" },
+ },
+ note: { width: 208, height: 100, data: { content: "" } },
+ compare: { width: 500, height: 220, data: {} },
+};
diff --git a/next.config.ts b/next.config.ts
index e9ffa30..38edbde 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,7 +1,20 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
- /* config options here */
+ images: {
+ remotePatterns: [
+ {
+ protocol: "https",
+ hostname: "*.convex.cloud",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "api.lemonspace.io",
+ pathname: "/api/storage/**",
+ },
+ ],
+ },
};
export default nextConfig;
diff --git a/tsconfig.json b/tsconfig.json
index 3a13f90..b6df22c 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -30,5 +30,5 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
- "exclude": ["node_modules"]
+ "exclude": ["node_modules", "implement"]
}