feat: enhance canvas and layout components with new features and improvements

- Added remote image patterns to the Next.js configuration for enhanced image handling.
- Updated TypeScript configuration to exclude the 'implement' directory.
- Refactored layout component to fetch initial authentication token and pass it to Providers.
- Replaced CanvasToolbar with CanvasSidebar for improved UI layout and functionality.
- Enhanced Canvas component with new drag-and-drop file upload capabilities and batch node movement.
- Updated various node components to support new status handling and improved user interactions.
- Added debounced saving for note and prompt nodes to optimize performance.
This commit is contained in:
Matthias
2026-03-25 17:58:58 +01:00
parent d1834c5694
commit ca40f5cb13
27 changed files with 1363 additions and 207 deletions

View File

@@ -1,7 +1,7 @@
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import Canvas from "@/components/canvas/canvas"; import Canvas from "@/components/canvas/canvas";
import CanvasToolbar from "@/components/canvas/canvas-toolbar"; import CanvasSidebar from "@/components/canvas/canvas-sidebar";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import { fetchAuthQuery, isAuthenticated } from "@/lib/auth-server"; import { fetchAuthQuery, isAuthenticated } from "@/lib/auth-server";
@@ -48,9 +48,11 @@ export default async function CanvasPage({
} }
return ( return (
<div className="relative h-screen w-screen overflow-hidden"> <div className="flex h-screen w-screen overflow-hidden">
<CanvasToolbar canvasId={typedCanvasId} /> <CanvasSidebar />
<Canvas canvasId={typedCanvasId} /> <div className="flex-1">
<Canvas canvasId={typedCanvasId} />
</div>
</div> </div>
); );
} }

View File

@@ -340,7 +340,7 @@ export default function DashboardPage() {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-muted-foreground" className="cursor-pointer text-muted-foreground"
type="button" type="button"
onClick={handleCreateWorkspace} onClick={handleCreateWorkspace}
disabled={isCreatingWorkspace || isSessionPending || !session?.user} disabled={isCreatingWorkspace || isSessionPending || !session?.user}
@@ -366,7 +366,7 @@ export default function DashboardPage() {
type="button" type="button"
onClick={() => router.push(`/canvas/${canvas._id}`)} onClick={() => router.push(`/canvas/${canvas._id}`)}
className={cn( className={cn(
"group flex items-center gap-4 rounded-xl border bg-card p-4 text-left shadow-sm shadow-foreground/3 transition-all", "group flex cursor-pointer 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",
)} )}
> >

View File

@@ -5,6 +5,7 @@ import { cn } from "@/lib/utils";
import { Providers } from "@/components/providers"; import { Providers } from "@/components/providers";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { InitUser } from "@/components/init-user"; import { InitUser } from "@/components/init-user";
import { getToken } from "@/lib/auth-server";
const manrope = Manrope({ subsets: ["latin"], variable: "--font-sans" }); const manrope = Manrope({ subsets: ["latin"], variable: "--font-sans" });
@@ -13,11 +14,13 @@ export const metadata: Metadata = {
description: "Generated by create next app", description: "Generated by create next app",
}; };
export default function RootLayout({ export default async function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const initialToken = await getToken();
return ( return (
<html <html
lang="de" lang="de"
@@ -25,7 +28,7 @@ export default function RootLayout({
className={cn("h-full", "antialiased", "font-sans", manrope.variable)} className={cn("h-full", "antialiased", "font-sans", manrope.variable)}
> >
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
<Providers> <Providers initialToken={initialToken}>
<InitUser /> <InitUser />
{children} {children}
<Toaster /> <Toaster />

View File

@@ -1,3 +1,71 @@
export default function CanvasSidebar() { "use client";
return null;
const nodeTemplates = [
{ type: "image", label: "Bild", icon: "🖼️", category: "Quelle" },
{ type: "text", label: "Text", icon: "📝", category: "Quelle" },
{ type: "prompt", label: "Prompt", icon: "✨", category: "Quelle" },
{ type: "note", label: "Notiz", icon: "📌", category: "Layout" },
{ type: "frame", label: "Frame", icon: "🖥️", category: "Layout" },
{ type: "group", label: "Gruppe", icon: "📁", category: "Layout" },
{ type: "compare", label: "Vergleich", icon: "🔀", category: "Layout" },
] as const;
const categories = [...new Set(nodeTemplates.map((template) => template.category))];
function SidebarItem({
type,
label,
icon,
}: {
type: string;
label: string;
icon: string;
}) {
const onDragStart = (event: React.DragEvent) => {
event.dataTransfer.setData("application/lemonspace-node-type", type);
event.dataTransfer.effectAllowed = "move";
};
return (
<div
draggable
onDragStart={onDragStart}
className="flex cursor-grab items-center gap-2 rounded-lg border bg-card px-3 py-2 text-sm transition-colors hover:bg-accent active:cursor-grabbing"
>
<span>{icon}</span>
<span>{label}</span>
</div>
);
}
export default function CanvasSidebar() {
return (
<aside className="flex w-56 flex-col border-r bg-background">
<div className="border-b px-4 py-3">
<h2 className="text-sm font-semibold">Nodes</h2>
<p className="text-xs text-muted-foreground">Auf den Canvas ziehen</p>
</div>
<div className="flex-1 overflow-y-auto p-3">
{categories.map((category) => (
<div key={category} className="mb-4">
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{category}
</h3>
<div className="flex flex-col gap-1.5">
{nodeTemplates
.filter((template) => template.category === category)
.map((template) => (
<SidebarItem
key={template.type}
type={template.type}
label={template.label}
icon={template.icon}
/>
))}
</div>
</div>
))}
</div>
</aside>
);
} }

View File

@@ -1,54 +1,80 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTheme } from "next-themes";
import { import {
ReactFlow, ReactFlow,
ReactFlowProvider,
Background, Background,
Controls, Controls,
MiniMap, MiniMap,
applyNodeChanges, applyNodeChanges,
applyEdgeChanges, applyEdgeChanges,
type Connection, useReactFlow,
type Edge as RFEdge,
type EdgeChange,
type Node as RFNode, type Node as RFNode,
type Edge as RFEdge,
type NodeChange, type NodeChange,
type EdgeChange,
type Connection,
BackgroundVariant, BackgroundVariant,
} from "@xyflow/react"; } from "@xyflow/react";
import "@xyflow/react/dist/style.css"; import "@xyflow/react/dist/style.css";
import { useMutation, useQuery } from "convex/react"; import { useConvexAuth, useMutation, useQuery } from "convex/react";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import { convexEdgeToRF, convexNodeToRF } from "@/lib/canvas-utils";
import { nodeTypes } from "./node-types"; import { nodeTypes } from "./node-types";
import { convexNodeToRF, convexEdgeToRF, NODE_DEFAULTS } from "@/lib/canvas-utils";
import CanvasToolbar from "@/components/canvas/canvas-toolbar";
interface CanvasProps { interface CanvasInnerProps {
canvasId: Id<"canvases">; canvasId: Id<"canvases">;
} }
export default function Canvas({ canvasId }: CanvasProps) { function CanvasInner({ canvasId }: CanvasInnerProps) {
const convexNodes = useQuery(api.nodes.list, { canvasId }); const { screenToFlowPosition } = useReactFlow();
const convexEdges = useQuery(api.edges.list, { canvasId }); const { resolvedTheme } = useTheme();
const { isLoading: isAuthLoading, isAuthenticated } = useConvexAuth();
const shouldSkipCanvasQueries = isAuthLoading || !isAuthenticated;
useEffect(() => {
if (process.env.NODE_ENV === "production") return;
if (!isAuthLoading && !isAuthenticated) {
console.warn("[Canvas debug] mounted without Convex auth", { canvasId });
}
}, [canvasId, isAuthLoading, isAuthenticated]);
// ─── Convex Realtime Queries ───────────────────────────────────
const convexNodes = useQuery(
api.nodes.list,
shouldSkipCanvasQueries ? "skip" : { canvasId },
);
const convexEdges = useQuery(
api.edges.list,
shouldSkipCanvasQueries ? "skip" : { canvasId },
);
// ─── Convex Mutations (exakte Signaturen aus nodes.ts / edges.ts) ──
const moveNode = useMutation(api.nodes.move); const moveNode = useMutation(api.nodes.move);
const createEdge = useMutation(api.edges.create); const batchMoveNodes = useMutation(api.nodes.batchMove);
const createNode = useMutation(api.nodes.create);
const removeNode = useMutation(api.nodes.remove); const removeNode = useMutation(api.nodes.remove);
const createEdge = useMutation(api.edges.create);
const removeEdge = useMutation(api.edges.remove); const removeEdge = useMutation(api.edges.remove);
// ─── Lokaler State (für flüssiges Dragging) ───────────────────
const [nodes, setNodes] = useState<RFNode[]>([]); const [nodes, setNodes] = useState<RFNode[]>([]);
const [edges, setEdges] = useState<RFEdge[]>([]); const [edges, setEdges] = useState<RFEdge[]>([]);
// Drag-Lock: während des Drags kein Convex-Override
const isDragging = useRef(false); const isDragging = useRef(false);
// ─── Convex → Lokaler State Sync ──────────────────────────────
useEffect(() => { useEffect(() => {
if (!convexNodes) return; if (!convexNodes || isDragging.current) return;
if (!isDragging.current) { // eslint-disable-next-line react-hooks/set-state-in-effect
// eslint-disable-next-line react-hooks/set-state-in-effect setNodes(convexNodes.map(convexNodeToRF));
setNodes(convexNodes.map(convexNodeToRF));
}
}, [convexNodes]); }, [convexNodes]);
useEffect(() => { useEffect(() => {
@@ -57,75 +83,136 @@ export default function Canvas({ canvasId }: CanvasProps) {
setEdges(convexEdges.map(convexEdgeToRF)); setEdges(convexEdges.map(convexEdgeToRF));
}, [convexEdges]); }, [convexEdges]);
// ─── Node Changes (Drag, Select, Remove) ─────────────────────
const onNodesChange = useCallback((changes: NodeChange[]) => { const onNodesChange = useCallback((changes: NodeChange[]) => {
setNodes((current) => applyNodeChanges(changes, current)); setNodes((nds) => applyNodeChanges(changes, nds));
}, []); }, []);
const onEdgesChange = useCallback((changes: EdgeChange[]) => { const onEdgesChange = useCallback((changes: EdgeChange[]) => {
setEdges((current) => applyEdgeChanges(changes, current)); setEdges((eds) => applyEdgeChanges(changes, eds));
}, []); }, []);
// ─── Drag Start → Lock ────────────────────────────────────────
const onNodeDragStart = useCallback(() => { const onNodeDragStart = useCallback(() => {
isDragging.current = true; isDragging.current = true;
}, []); }, []);
// ─── Drag Stop → Commit zu Convex ─────────────────────────────
const onNodeDragStop = useCallback( const onNodeDragStop = useCallback(
(_event: React.MouseEvent, node: RFNode) => { (_: React.MouseEvent, node: RFNode, draggedNodes: RFNode[]) => {
isDragging.current = false; isDragging.current = false;
void moveNode({
nodeId: node.id as Id<"nodes">, // Wenn mehrere Nodes gleichzeitig gedraggt wurden → batchMove
positionX: node.position.x, if (draggedNodes.length > 1) {
positionY: node.position.y, batchMoveNodes({
}); moves: draggedNodes.map((n) => ({
nodeId: n.id as Id<"nodes">,
positionX: n.position.x,
positionY: n.position.y,
})),
});
} else {
moveNode({
nodeId: node.id as Id<"nodes">,
positionX: node.position.x,
positionY: node.position.y,
});
}
}, },
[moveNode], [moveNode, batchMoveNodes],
); );
// ─── Neue Verbindung → Convex Edge ────────────────────────────
const onConnect = useCallback( const onConnect = useCallback(
(connection: Connection) => { (connection: Connection) => {
if (!connection.source || !connection.target) return; if (connection.source && connection.target) {
void createEdge({ createEdge({
canvasId, canvasId,
sourceNodeId: connection.source as Id<"nodes">, sourceNodeId: connection.source as Id<"nodes">,
targetNodeId: connection.target as Id<"nodes">, targetNodeId: connection.target as Id<"nodes">,
sourceHandle: connection.sourceHandle ?? undefined, sourceHandle: connection.sourceHandle ?? undefined,
targetHandle: connection.targetHandle ?? undefined, targetHandle: connection.targetHandle ?? undefined,
}); });
}
}, },
[canvasId, createEdge], [createEdge, canvasId],
); );
// ─── Node löschen → Convex ────────────────────────────────────
const onNodesDelete = useCallback( const onNodesDelete = useCallback(
(deletedNodes: RFNode[]) => { (deletedNodes: RFNode[]) => {
for (const node of deletedNodes) { for (const node of deletedNodes) {
void removeNode({ nodeId: node.id as Id<"nodes"> }); removeNode({ nodeId: node.id as Id<"nodes"> });
} }
}, },
[removeNode], [removeNode],
); );
// ─── Edge löschen → Convex ────────────────────────────────────
const onEdgesDelete = useCallback( const onEdgesDelete = useCallback(
(deletedEdges: RFEdge[]) => { (deletedEdges: RFEdge[]) => {
for (const edge of deletedEdges) { for (const edge of deletedEdges) {
void removeEdge({ edgeId: edge.id as Id<"edges"> }); removeEdge({ edgeId: edge.id as Id<"edges"> });
} }
}, },
[removeEdge], [removeEdge],
); );
const onDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}, []);
const onDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
const nodeType = event.dataTransfer.getData(
"application/lemonspace-node-type",
);
if (!nodeType) {
return;
}
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
const defaults = NODE_DEFAULTS[nodeType] ?? {
width: 200,
height: 100,
data: {},
};
createNode({
canvasId,
type: nodeType,
positionX: position.x,
positionY: position.y,
width: defaults.width,
height: defaults.height,
data: defaults.data,
});
},
[screenToFlowPosition, createNode, canvasId],
);
// ─── Loading State ────────────────────────────────────────────
if (convexNodes === undefined || convexEdges === undefined) { if (convexNodes === undefined || convexEdges === undefined) {
return ( return (
<div className="flex h-full w-full items-center justify-center bg-background"> <div className="flex h-full w-full items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" /> <div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="text-sm text-muted-foreground">Canvas laedt...</span> <span className="text-sm text-muted-foreground">Canvas lädt</span>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="h-full w-full"> <div className="relative h-full w-full">
<CanvasToolbar canvasId={canvasId} />
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
@@ -137,18 +224,21 @@ export default function Canvas({ canvasId }: CanvasProps) {
onConnect={onConnect} onConnect={onConnect}
onNodesDelete={onNodesDelete} onNodesDelete={onNodesDelete}
onEdgesDelete={onEdgesDelete} onEdgesDelete={onEdgesDelete}
onDragOver={onDragOver}
onDrop={onDrop}
fitView fitView
snapToGrid snapToGrid
snapGrid={[16, 16]} snapGrid={[16, 16]}
deleteKeyCode={["Backspace", "Delete"]} deleteKeyCode={["Backspace", "Delete"]}
multiSelectionKeyCode="Shift" multiSelectionKeyCode="Shift"
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
className="bg-background" className="bg-background"
> >
<Background variant={BackgroundVariant.Dots} gap={16} size={1} /> <Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<Controls className="rounded-lg! border! bg-card! shadow-sm!" /> <Controls className="bg-card! border! shadow-sm! rounded-lg!" />
<MiniMap <MiniMap
className="rounded-lg! border! bg-card! shadow-sm!" className="bg-card! border! shadow-sm! rounded-lg!"
nodeColor="#6366f1" nodeColor="#6366f1"
maskColor="rgba(0, 0, 0, 0.1)" maskColor="rgba(0, 0, 0, 0.1)"
/> />
@@ -156,3 +246,15 @@ export default function Canvas({ canvasId }: CanvasProps) {
</div> </div>
); );
} }
interface CanvasProps {
canvasId: Id<"canvases">;
}
export default function Canvas({ canvasId }: CanvasProps) {
return (
<ReactFlowProvider>
<CanvasInner canvasId={canvasId} />
</ReactFlowProvider>
);
}

View File

@@ -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 ImageNode from "./nodes/image-node";
import NoteNode from "./nodes/note-node";
import PromptNode from "./nodes/prompt-node";
import TextNode from "./nodes/text-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, image: ImageNode,
text: TextNode, text: TextNode,
prompt: PromptNode, prompt: PromptNode,
@@ -18,4 +23,4 @@ export const nodeTypes: NodeTypes = {
frame: FrameNode, frame: FrameNode,
note: NoteNode, note: NoteNode,
compare: CompareNode, compare: CompareNode,
}; } as const;

View File

@@ -1,66 +1,78 @@
"use client"; "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"; import BaseNodeWrapper from "./base-node-wrapper";
export type AiImageNodeData = { type AiImageNodeData = {
url?: string; url?: string;
prompt?: string; prompt?: string;
model?: string; model?: string;
status?: "idle" | "executing" | "done" | "error"; _status?: string;
errorMessage?: string; _statusMessage?: string;
}; };
export type AiImageNode = Node<AiImageNodeData, "ai-image">; export type AiImageNode = Node<AiImageNodeData, "ai-image">;
export default function AiImageNode({ data, selected }: NodeProps<AiImageNode>) { export default function AiImageNode({
const status = data.status ?? "idle"; data,
selected,
}: NodeProps<AiImageNode>) {
const status = data._status ?? "idle";
return ( return (
<BaseNodeWrapper selected={selected} status={status} className="p-2"> <BaseNodeWrapper
<div className="mb-1 text-xs font-medium text-emerald-500">KI-Bild</div> selected={selected}
status={status}
{status === "executing" ? ( statusMessage={data._statusMessage}
<div className="flex h-36 w-56 items-center justify-center rounded-lg bg-muted"> >
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" /> <div className="p-2">
<div className="text-xs font-medium text-emerald-500 mb-1">
🤖 KI-Bild
</div> </div>
) : null}
{status === "done" && data.url ? ( {status === "executing" && (
<img <div className="flex h-36 w-56 items-center justify-center rounded-lg bg-muted">
src={data.url} <div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
alt={data.prompt ?? "KI-generiertes Bild"} </div>
className="max-w-[280px] rounded-lg object-cover" )}
draggable={false}
/>
) : null}
{status === "error" ? ( {status === "done" && data.url && (
<div className="flex h-36 w-56 items-center justify-center rounded-lg bg-red-50 text-sm text-red-600 dark:bg-red-950/20"> <img
{data.errorMessage ?? "Fehler bei der Generierung"} src={data.url}
</div> alt={data.prompt ?? "KI-generiertes Bild"}
) : null} className="rounded-lg object-cover max-w-[260px]"
draggable={false}
/>
)}
{status === "idle" ? ( {status === "error" && (
<div className="flex h-36 w-56 items-center justify-center rounded-lg border-2 border-dashed text-sm text-muted-foreground"> <div className="flex h-36 w-56 items-center justify-center rounded-lg bg-red-50 dark:bg-red-950/20 text-sm text-red-600">
Prompt verbinden {data._statusMessage ?? "Fehler bei der Generierung"}
</div> </div>
) : null} )}
{data.prompt && status === "done" ? ( {status === "idle" && (
<p className="mt-1 max-w-[280px] truncate text-xs text-muted-foreground">{data.prompt}</p> <div className="flex h-36 w-56 items-center justify-center rounded-lg border-2 border-dashed text-sm text-muted-foreground">
) : null} Prompt verbinden
</div>
)}
{data.prompt && status === "done" && (
<p className="mt-1 text-xs text-muted-foreground truncate max-w-[260px]">
{data.prompt}
</p>
)}
</div>
<Handle <Handle
type="target" type="target"
position={Position.Left} position={Position.Left}
className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500" className="!h-3 !w-3 !bg-emerald-500 !border-2 !border-background"
/> />
<Handle <Handle
type="source" type="source"
position={Position.Right} position={Position.Right}
className="!h-3 !w-3 !border-2 !border-background !bg-primary" className="!h-3 !w-3 !bg-primary !border-2 !border-background"
/> />
</BaseNodeWrapper> </BaseNodeWrapper>
); );

View File

@@ -4,34 +4,43 @@ import type { ReactNode } from "react";
interface BaseNodeWrapperProps { interface BaseNodeWrapperProps {
selected?: boolean; selected?: boolean;
status?: "idle" | "executing" | "done" | "error"; status?: string;
statusMessage?: string;
children: ReactNode; children: ReactNode;
className?: string; className?: string;
} }
const statusClassMap: Record<NonNullable<BaseNodeWrapperProps["status"]>, string> = {
idle: "",
executing: "animate-pulse border-yellow-400",
done: "border-green-500",
error: "border-red-500",
};
export default function BaseNodeWrapper({ export default function BaseNodeWrapper({
selected, selected,
status = "idle", status = "idle",
statusMessage,
children, children,
className = "", className = "",
}: BaseNodeWrapperProps) { }: BaseNodeWrapperProps) {
const statusStyles: Record<string, string> = {
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 ( return (
<div <div
className={[ className={`
"rounded-xl border bg-card shadow-sm transition-shadow", rounded-xl border bg-card shadow-sm transition-shadow
selected ? "ring-2 ring-primary shadow-md" : "", ${selected ? "ring-2 ring-primary shadow-md" : ""}
statusClassMap[status], ${statusStyles[status] ?? ""}
className, ${className}
].join(" ")} `}
> >
{children} {children}
{status === "error" && statusMessage && (
<div className="px-3 pb-2 text-xs text-red-500 truncate">
{statusMessage}
</div>
)}
</div> </div>
); );
} }

View File

@@ -1,33 +1,57 @@
"use client"; "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"; import BaseNodeWrapper from "./base-node-wrapper";
export type CompareNodeData = { type CompareNodeData = {
leftUrl?: string; leftUrl?: string;
rightUrl?: string; rightUrl?: string;
_status?: string;
}; };
export type CompareNode = Node<CompareNodeData, "compare">; export type CompareNode = Node<CompareNodeData, "compare">;
export default function CompareNode({ data, selected }: NodeProps<CompareNode>) { export default function CompareNode({
data,
selected,
}: NodeProps<CompareNode>) {
return ( return (
<BaseNodeWrapper selected={selected} className="w-[500px] p-2"> <BaseNodeWrapper selected={selected} className="w-[500px] p-2">
<div className="mb-1 text-xs font-medium text-muted-foreground">Vergleich</div> <div className="text-xs font-medium text-muted-foreground mb-1">
🔀 Vergleich
</div>
<div className="flex h-40 gap-2"> <div className="flex h-40 gap-2">
<div className="flex flex-1 items-center justify-center rounded bg-muted text-xs text-muted-foreground"> <div className="relative flex min-h-0 flex-1 overflow-hidden rounded bg-muted">
{data.leftUrl ? ( {data.leftUrl ? (
<img src={data.leftUrl} alt="Bild A" className="h-full w-full rounded object-cover" /> <Image
src={data.leftUrl}
alt="Vergleich Bild A"
fill
className="object-cover"
sizes="250px"
draggable={false}
/>
) : ( ) : (
"Bild A" <div className="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
Bild A
</div>
)} )}
</div> </div>
<div className="flex flex-1 items-center justify-center rounded bg-muted text-xs text-muted-foreground"> <div className="relative flex min-h-0 flex-1 overflow-hidden rounded bg-muted">
{data.rightUrl ? ( {data.rightUrl ? (
<img src={data.rightUrl} alt="Bild B" className="h-full w-full rounded object-cover" /> <Image
src={data.rightUrl}
alt="Vergleich Bild B"
fill
className="object-cover"
sizes="250px"
draggable={false}
/>
) : ( ) : (
"Bild B" <div className="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
Bild B
</div>
)} )}
</div> </div>
</div> </div>
@@ -35,14 +59,14 @@ export default function CompareNode({ data, selected }: NodeProps<CompareNode>)
type="target" type="target"
position={Position.Left} position={Position.Left}
id="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%" }} style={{ top: "40%" }}
/> />
<Handle <Handle
type="target" type="target"
position={Position.Left} position={Position.Left}
id="right" id="right"
className="!h-3 !w-3 !border-2 !border-background !bg-primary" className="h-3! w-3! bg-primary! border-2! border-background!"
style={{ top: "60%" }} style={{ top: "60%" }}
/> />
</BaseNodeWrapper> </BaseNodeWrapper>

View File

@@ -1,28 +1,72 @@
"use client"; "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"; import BaseNodeWrapper from "./base-node-wrapper";
export type FrameNodeData = { type FrameNodeData = {
label?: string; label?: string;
exportWidth?: number; resolution?: string;
exportHeight?: number; _status?: string;
_statusMessage?: string;
}; };
export type FrameNode = Node<FrameNodeData, "frame">; export type FrameNode = Node<FrameNodeData, "frame">;
export default function FrameNode({ data, selected }: NodeProps<FrameNode>) { export default function FrameNode({ id, data, selected }: NodeProps<FrameNode>) {
const resolution = const updateData = useMutation(api.nodes.updateData);
data.exportWidth && data.exportHeight const [editingLabel, setEditingLabel] = useState<string | null>(null);
? `${data.exportWidth}x${data.exportHeight}`
: undefined; 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 ( return (
<BaseNodeWrapper selected={selected} className="min-h-[200px] min-w-[300px] border-blue-500/30 p-3"> <BaseNodeWrapper
<div className="text-xs font-medium text-blue-500"> selected={selected}
{data.label || "Frame"} {resolution ? `(${resolution})` : ""} className="min-w-[300px] min-h-[200px] p-3 border-blue-500/30"
</div> >
{isEditing ? (
<input
value={editingLabel}
onChange={(e) => 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"
/>
) : (
<div
onDoubleClick={handleDoubleClick}
className="text-xs font-medium text-blue-500 cursor-text"
>
🖥 {displayLabel}{" "}
{data.resolution && (
<span className="text-muted-foreground">({data.resolution})</span>
)}
</div>
)}
</BaseNodeWrapper> </BaseNodeWrapper>
); );
} }

View File

@@ -1,15 +1,68 @@
"use client"; "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"; 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<GroupNodeData, "group">;
export default function GroupNode({ id, data, selected }: NodeProps<GroupNode>) {
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<GroupNode>) {
return ( return (
<BaseNodeWrapper selected={selected} className="min-h-[150px] min-w-[200px] border-dashed p-3"> <BaseNodeWrapper
<div className="text-xs font-medium text-muted-foreground">{data.label || "Gruppe"}</div> selected={selected}
className="min-w-[200px] min-h-[150px] p-3 border-dashed"
>
{isEditing ? (
<input
value={label}
onChange={(e) => 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"
/>
) : (
<div
onDoubleClick={() => setIsEditing(true)}
className="text-xs font-medium text-muted-foreground cursor-text"
>
📁 {label}
</div>
)}
</BaseNodeWrapper> </BaseNodeWrapper>
); );
} }

View File

@@ -1,37 +1,194 @@
"use client"; "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"; import BaseNodeWrapper from "./base-node-wrapper";
export type ImageNodeData = { type ImageNodeData = {
storageId?: string; storageId?: string;
url?: string; url?: string;
originalFilename?: string; filename?: string;
mimeType?: string;
_status?: string;
_statusMessage?: string;
}; };
export type ImageNode = Node<ImageNodeData, "image">; export type ImageNode = Node<ImageNodeData, "image">;
export default function ImageNode({ data, selected }: NodeProps<ImageNode>) { export default function ImageNode({ id, data, selected }: NodeProps<ImageNode>) {
const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
const updateData = useMutation(api.nodes.updateData);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 ( return (
<BaseNodeWrapper selected={selected} className="p-2"> <BaseNodeWrapper selected={selected} status={data._status}>
<div className="mb-1 text-xs font-medium text-muted-foreground">Bild</div> <div className="p-2">
{data.url ? ( <div className="mb-1 flex items-center justify-between">
<img <div className="text-xs font-medium text-muted-foreground">🖼 Bild</div>
src={data.url} {data.url && (
alt={data.originalFilename ?? "Bild"} <button
className="max-w-[280px] rounded-lg object-cover" onClick={handleReplace}
draggable={false} className="nodrag text-xs text-muted-foreground transition-colors hover:text-foreground"
/> >
) : ( Ersetzen
<div className="flex h-36 w-56 items-center justify-center rounded-lg border-2 border-dashed text-sm text-muted-foreground"> </button>
Bild hochladen oder URL einfuegen )}
</div> </div>
)}
{isUploading ? (
<div className="flex h-36 w-56 items-center justify-center rounded-lg bg-muted">
<div className="flex flex-col items-center gap-2">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="text-xs text-muted-foreground">Wird hochgeladen...</span>
</div>
</div>
) : data.url ? (
<div className="relative h-36 w-56 overflow-hidden rounded-lg">
<Image
src={data.url}
alt={data.filename ?? "Bild"}
fill
className="object-cover"
sizes="224px"
draggable={false}
/>
</div>
) : (
<div
onClick={handleClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`
nodrag flex h-36 w-56 cursor-pointer flex-col items-center justify-center
rounded-lg border-2 border-dashed text-sm transition-colors
${
isDragOver
? "border-primary bg-primary/5 text-primary"
: "text-muted-foreground hover:border-primary/50 hover:text-foreground"
}
`}
>
<span className="mb-1 text-lg">📁</span>
<span>Klicken oder hierhin ziehen</span>
<span className="mt-0.5 text-xs">PNG, JPG, WebP</span>
</div>
)}
{data.filename && data.url && (
<p className="mt-1 max-w-[260px] truncate text-xs text-muted-foreground">
{data.filename}
</p>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleFileChange}
className="hidden"
/>
<Handle <Handle
type="source" type="source"
position={Position.Right} position={Position.Right}
className="!h-3 !w-3 !border-2 !border-background !bg-primary" className="h-3! w-3! bg-primary! border-2! border-background!"
/> />
</BaseNodeWrapper> </BaseNodeWrapper>
); );

View File

@@ -1,20 +1,83 @@
"use client"; "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"; import BaseNodeWrapper from "./base-node-wrapper";
export type NoteNodeData = { type NoteNodeData = {
content?: string; content?: string;
_status?: string;
_statusMessage?: string;
}; };
export type NoteNode = Node<NoteNodeData, "note">; export type NoteNode = Node<NoteNodeData, "note">;
export default function NoteNode({ data, selected }: NodeProps<NoteNode>) { export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
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<HTMLTextAreaElement>) => {
const newContent = e.target.value;
setContent(newContent);
saveContent(newContent);
},
[saveContent],
);
return ( return (
<BaseNodeWrapper selected={selected} className="w-52 p-3"> <BaseNodeWrapper selected={selected} className="w-52 p-3">
<div className="mb-1 text-xs font-medium text-muted-foreground">Notiz</div> <div className="text-xs font-medium text-muted-foreground mb-1">
<p className="whitespace-pre-wrap text-sm">{data.content || "Leere Notiz"}</p> 📌 Notiz
</div>
{isEditing ? (
<textarea
value={content}
onChange={handleChange}
onBlur={() => setIsEditing(false)}
autoFocus
className="nodrag nowheel w-full resize-none rounded-md border-0 bg-transparent p-0 text-sm outline-none focus:ring-0 min-h-[2rem]"
placeholder="Notiz eingeben…"
rows={3}
/>
) : (
<div
onDoubleClick={() => setIsEditing(true)}
className="min-h-[2rem] cursor-text text-sm whitespace-pre-wrap"
>
{content || (
<span className="text-muted-foreground">
Doppelklick zum Bearbeiten
</span>
)}
</div>
)}
</BaseNodeWrapper> </BaseNodeWrapper>
); );
} }

View File

@@ -1,28 +1,103 @@
"use client"; "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"; import BaseNodeWrapper from "./base-node-wrapper";
export type PromptNodeData = { type PromptNodeData = {
content?: string; prompt?: string;
model?: string; model?: string;
_status?: string;
_statusMessage?: string;
}; };
export type PromptNode = Node<PromptNodeData, "prompt">; export type PromptNode = Node<PromptNodeData, "prompt">;
export default function PromptNode({ data, selected }: NodeProps<PromptNode>) { export default function PromptNode({
id,
data,
selected,
}: NodeProps<PromptNode>) {
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<HTMLTextAreaElement>) => {
const newPrompt = e.target.value;
setPrompt(newPrompt);
savePrompt(newPrompt);
},
[savePrompt],
);
return ( return (
<BaseNodeWrapper selected={selected} className="w-72 border-purple-500/30 p-3"> <BaseNodeWrapper
<div className="mb-1 text-xs font-medium text-purple-500">Prompt</div> selected={selected}
<p className="min-h-[2rem] whitespace-pre-wrap text-sm">{data.content || "Prompt eingeben..."}</p> status={data._status}
{data.model ? ( className="border-purple-500/30"
<div className="mt-2 text-xs text-muted-foreground">Modell: {data.model}</div> >
) : null} <div className="w-72 p-3">
<div className="text-xs font-medium text-purple-500 mb-1">
Prompt
</div>
{isEditing ? (
<textarea
value={prompt}
onChange={handleChange}
onBlur={() => setIsEditing(false)}
autoFocus
className="nodrag nowheel w-full resize-none rounded-md border-0 bg-transparent p-0 text-sm outline-none focus:ring-0 min-h-[3rem]"
placeholder="Prompt eingeben…"
rows={4}
/>
) : (
<div
onDoubleClick={() => setIsEditing(true)}
className="min-h-[2rem] cursor-text text-sm whitespace-pre-wrap"
>
{prompt || (
<span className="text-muted-foreground">
Doppelklick zum Bearbeiten
</span>
)}
</div>
)}
{data.model && (
<div className="mt-2 text-xs text-muted-foreground">
Modell: {data.model}
</div>
)}
</div>
<Handle <Handle
type="source" type="source"
position={Position.Right} position={Position.Right}
className="!h-3 !w-3 !border-2 !border-background !bg-purple-500" className="!h-3 !w-3 !bg-purple-500 !border-2 !border-background"
/> />
</BaseNodeWrapper> </BaseNodeWrapper>
); );

View File

@@ -1,24 +1,91 @@
"use client"; "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"; import BaseNodeWrapper from "./base-node-wrapper";
export type TextNodeData = { type TextNodeData = {
content?: string; content?: string;
_status?: string;
_statusMessage?: string;
}; };
export type TextNode = Node<TextNodeData, "text">; export type TextNode = Node<TextNodeData, "text">;
export default function TextNode({ data, selected }: NodeProps<TextNode>) { export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
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<HTMLTextAreaElement>) => {
const newContent = e.target.value;
setContent(newContent);
saveContent(newContent);
},
[saveContent],
);
return ( return (
<BaseNodeWrapper selected={selected} className="w-64 p-3"> <BaseNodeWrapper selected={selected} status={data._status}>
<div className="mb-1 text-xs font-medium text-muted-foreground">Text</div> <div className="w-64 p-3">
<p className="min-h-[2rem] whitespace-pre-wrap text-sm">{data.content || "Text eingeben..."}</p> <div className="text-xs font-medium text-muted-foreground mb-1">
📝 Text
</div>
{isEditing ? (
<textarea
value={content}
onChange={handleChange}
onBlur={() => setIsEditing(false)}
autoFocus
className="nodrag nowheel w-full resize-none rounded-md border-0 bg-transparent p-0 text-sm outline-none focus:ring-0 min-h-[3rem]"
placeholder="Text eingeben…"
rows={3}
/>
) : (
<div
onDoubleClick={() => setIsEditing(true)}
className="min-h-[2rem] cursor-text text-sm whitespace-pre-wrap"
>
{content || (
<span className="text-muted-foreground">
Doppelklick zum Bearbeiten
</span>
)}
</div>
)}
</div>
<Handle <Handle
type="source" type="source"
position={Position.Right} position={Position.Right}
className="!h-3 !w-3 !border-2 !border-background !bg-primary" className="!h-3 !w-3 !bg-primary !border-2 !border-background"
/> />
</BaseNodeWrapper> </BaseNodeWrapper>
); );

View File

@@ -15,6 +15,7 @@ import type * as edges from "../edges.js";
import type * as helpers from "../helpers.js"; import type * as helpers from "../helpers.js";
import type * as http from "../http.js"; import type * as http from "../http.js";
import type * as nodes from "../nodes.js"; import type * as nodes from "../nodes.js";
import type * as storage from "../storage.js";
import type { import type {
ApiFromModules, ApiFromModules,
@@ -30,6 +31,7 @@ declare const fullApi: ApiFromModules<{
helpers: typeof helpers; helpers: typeof helpers;
http: typeof http; http: typeof http;
nodes: typeof nodes; nodes: typeof nodes;
storage: typeof storage;
}>; }>;
/** /**

View File

@@ -17,10 +17,14 @@ export async function requireAuth(
): Promise<AuthUser> { ): Promise<AuthUser> {
const user = await authComponent.safeGetAuthUser(ctx); const user = await authComponent.safeGetAuthUser(ctx);
if (!user) { if (!user) {
console.error("[requireAuth] safeGetAuthUser returned null");
throw new Error("Unauthenticated"); throw new Error("Unauthenticated");
} }
const userId = user.userId ?? String(user._id); const userId = user.userId ?? String(user._id);
if (!userId) { if (!userId) {
console.error("[requireAuth] safeGetAuthUser returned user without userId", {
userRecordId: String(user._id),
});
throw new Error("Unauthenticated"); throw new Error("Unauthenticated");
} }
return { ...user, userId }; return { ...user, userId };

View File

@@ -22,6 +22,18 @@ async function getCanvasOrThrow(
return canvas; 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 // Queries
// ============================================================================ // ============================================================================
@@ -35,10 +47,29 @@ export const list = query({
const user = await requireAuth(ctx); const user = await requireAuth(ctx);
await getCanvasOrThrow(ctx, canvasId, user.userId); await getCanvasOrThrow(ctx, canvasId, user.userId);
return await ctx.db const nodes = await ctx.db
.query("nodes") .query("nodes")
.withIndex("by_canvas", (q) => q.eq("canvasId", canvasId)) .withIndex("by_canvas", (q) => q.eq("canvasId", canvasId))
.collect(); .collect();
return Promise.all(
nodes.map(async (node) => {
const data = node.data as Record<string, unknown> | 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); const node = await ctx.db.get(nodeId);
if (!node) return null; 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; return node;
}, },
}); });
@@ -67,7 +102,10 @@ export const listByType = query({
}, },
handler: async (ctx, { canvasId, type }) => { handler: async (ctx, { canvasId, type }) => {
const user = await requireAuth(ctx); 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 return await ctx.db
.query("nodes") .query("nodes")

10
convex/storage.ts Normal file
View File

@@ -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();
},
});

View File

@@ -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<Args extends unknown[]>(
callback: (...args: Args) => void,
delay: number,
): (...args: Args) => void {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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;
}

103
implement/README.md Normal file
View File

@@ -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)

187
implement/image-node.tsx Normal file
View File

@@ -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<ImageNodeData, "image">;
export default function ImageNode({ id, data, selected }: NodeProps<ImageNode>) {
const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
const updateData = useMutation(api.nodes.updateData);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<BaseNodeWrapper selected={selected} status={data._status}>
<div className="p-2">
<div className="flex items-center justify-between mb-1">
<div className="text-xs font-medium text-muted-foreground">
🖼 Bild
</div>
{data.url && (
<button
onClick={handleReplace}
className="nodrag text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Ersetzen
</button>
)}
</div>
{isUploading ? (
<div className="flex h-36 w-56 items-center justify-center rounded-lg bg-muted">
<div className="flex flex-col items-center gap-2">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="text-xs text-muted-foreground">
Wird hochgeladen
</span>
</div>
</div>
) : data.url ? (
<img
src={data.url}
alt={data.filename ?? "Bild"}
className="rounded-lg object-cover max-w-[260px]"
draggable={false}
/>
) : (
<div
onClick={handleClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`
nodrag flex h-36 w-56 cursor-pointer flex-col items-center justify-center
rounded-lg border-2 border-dashed text-sm transition-colors
${isDragOver ? "border-primary bg-primary/5 text-primary" : "text-muted-foreground hover:border-primary/50 hover:text-foreground"}
`}
>
<span className="text-lg mb-1">📁</span>
<span>Klicken oder hierhin ziehen</span>
<span className="text-xs mt-0.5">PNG, JPG, WebP</span>
</div>
)}
{data.filename && data.url && (
<p className="mt-1 text-xs text-muted-foreground truncate max-w-[260px]">
{data.filename}
</p>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleFileChange}
className="hidden"
/>
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !bg-primary !border-2 !border-background"
/>
</BaseNodeWrapper>
);
}

View File

@@ -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<string, unknown> | undefined;
if (data?.storageId) {
const url = await ctx.storage.getUrl(
data.storageId as Id<"_storage">
);
return {
...node,
data: { ...data, url: url ?? undefined },
};
}
return node;
})
);
},
});

14
implement/storage.ts Normal file
View File

@@ -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();
},
});

View File

@@ -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"; 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 { export function convexNodeToRF(node: Doc<"nodes">): RFNode {
return { return {
id: node._id, id: node._id,
type: node.type, type: node.type,
position: { position: { x: node.positionX, y: node.positionY },
x: node.positionX,
y: node.positionY,
},
data: { data: {
...(typeof node.data === "object" && node.data !== null ? node.data : {}), ...(node.data as Record<string, unknown>),
status: node.status, // Status direkt in data durchreichen, damit Node-Komponenten darauf zugreifen können
statusMessage: node.statusMessage, _status: node.status,
_statusMessage: node.statusMessage,
}, },
parentId: node.parentId ?? undefined,
zIndex: node.zIndex,
style: { style: {
width: node.width, width: node.width,
height: node.height, height: node.height,
}, },
zIndex: node.zIndex,
parentId: node.parentId,
}; };
} }
/**
* Convex Edge → React Flow Edge
*/
export function convexEdgeToRF(edge: Doc<"edges">): RFEdge { export function convexEdgeToRF(edge: Doc<"edges">): RFEdge {
return { return {
id: edge._id, id: edge._id,
source: edge.sourceNodeId, source: edge.sourceNodeId,
target: edge.targetNodeId, target: edge.targetNodeId,
sourceHandle: edge.sourceHandle, sourceHandle: edge.sourceHandle ?? undefined,
targetHandle: edge.targetHandle, 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<string, unknown> }
> = {
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: {} },
};

View File

@@ -1,7 +1,20 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { 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; export default nextConfig;

View File

@@ -30,5 +30,5 @@
".next/dev/types/**/*.ts", ".next/dev/types/**/*.ts",
"**/*.mts" "**/*.mts"
], ],
"exclude": ["node_modules"] "exclude": ["node_modules", "implement"]
} }