feat: implement Convex-synced canvas foundation

This commit is contained in:
Matthias
2026-03-25 14:21:19 +01:00
parent 66c4455033
commit 4d17936570
21 changed files with 2347 additions and 35 deletions

View File

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

View File

@@ -0,0 +1,37 @@
"use client";
import type { ReactNode } from "react";
interface BaseNodeWrapperProps {
selected?: boolean;
status?: "idle" | "executing" | "done" | "error";
children: ReactNode;
className?: string;
}
const statusClassMap: Record<NonNullable<BaseNodeWrapperProps["status"]>, string> = {
idle: "",
executing: "animate-pulse border-yellow-400",
done: "border-green-500",
error: "border-red-500",
};
export default function BaseNodeWrapper({
selected,
status = "idle",
children,
className = "",
}: BaseNodeWrapperProps) {
return (
<div
className={[
"rounded-xl border bg-card shadow-sm transition-shadow",
selected ? "ring-2 ring-primary shadow-md" : "",
statusClassMap[status],
className,
].join(" ")}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import BaseNodeWrapper from "./base-node-wrapper";
export type CompareNodeData = {
leftUrl?: string;
rightUrl?: string;
};
export type CompareNode = Node<CompareNodeData, "compare">;
export default function CompareNode({ data, selected }: NodeProps<CompareNode>) {
return (
<BaseNodeWrapper selected={selected} className="w-[500px] p-2">
<div className="mb-1 text-xs font-medium text-muted-foreground">Vergleich</div>
<div className="flex h-40 gap-2">
<div className="flex flex-1 items-center justify-center rounded bg-muted text-xs text-muted-foreground">
{data.leftUrl ? (
<img src={data.leftUrl} alt="Bild A" className="h-full w-full rounded object-cover" />
) : (
"Bild A"
)}
</div>
<div className="flex flex-1 items-center justify-center rounded bg-muted text-xs text-muted-foreground">
{data.rightUrl ? (
<img src={data.rightUrl} alt="Bild B" className="h-full w-full rounded object-cover" />
) : (
"Bild B"
)}
</div>
</div>
<Handle
type="target"
position={Position.Left}
id="left"
className="!h-3 !w-3 !border-2 !border-background !bg-primary"
style={{ top: "40%" }}
/>
<Handle
type="target"
position={Position.Left}
id="right"
className="!h-3 !w-3 !border-2 !border-background !bg-primary"
style={{ top: "60%" }}
/>
</BaseNodeWrapper>
);
}

View File

@@ -0,0 +1,28 @@
"use client";
import { type Node, type NodeProps } from "@xyflow/react";
import BaseNodeWrapper from "./base-node-wrapper";
export type FrameNodeData = {
label?: string;
exportWidth?: number;
exportHeight?: number;
};
export type FrameNode = Node<FrameNodeData, "frame">;
export default function FrameNode({ data, selected }: NodeProps<FrameNode>) {
const resolution =
data.exportWidth && data.exportHeight
? `${data.exportWidth}x${data.exportHeight}`
: undefined;
return (
<BaseNodeWrapper selected={selected} className="min-h-[200px] min-w-[300px] border-blue-500/30 p-3">
<div className="text-xs font-medium text-blue-500">
{data.label || "Frame"} {resolution ? `(${resolution})` : ""}
</div>
</BaseNodeWrapper>
);
}

View File

@@ -0,0 +1,15 @@
"use client";
import { type Node, type NodeProps } from "@xyflow/react";
import BaseNodeWrapper from "./base-node-wrapper";
export type GroupNode = Node<{ label?: string }, "group">;
export default function GroupNode({ data, selected }: NodeProps<GroupNode>) {
return (
<BaseNodeWrapper selected={selected} className="min-h-[150px] min-w-[200px] border-dashed p-3">
<div className="text-xs font-medium text-muted-foreground">{data.label || "Gruppe"}</div>
</BaseNodeWrapper>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import BaseNodeWrapper from "./base-node-wrapper";
export type ImageNodeData = {
storageId?: string;
url?: string;
originalFilename?: string;
};
export type ImageNode = Node<ImageNodeData, "image">;
export default function ImageNode({ data, selected }: NodeProps<ImageNode>) {
return (
<BaseNodeWrapper selected={selected} className="p-2">
<div className="mb-1 text-xs font-medium text-muted-foreground">Bild</div>
{data.url ? (
<img
src={data.url}
alt={data.originalFilename ?? "Bild"}
className="max-w-[280px] rounded-lg object-cover"
draggable={false}
/>
) : (
<div className="flex h-36 w-56 items-center justify-center rounded-lg border-2 border-dashed text-sm text-muted-foreground">
Bild hochladen oder URL einfuegen
</div>
)}
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !border-2 !border-background !bg-primary"
/>
</BaseNodeWrapper>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
import { type Node, type NodeProps } from "@xyflow/react";
import BaseNodeWrapper from "./base-node-wrapper";
export type NoteNodeData = {
content?: string;
};
export type NoteNode = Node<NoteNodeData, "note">;
export default function NoteNode({ data, selected }: NodeProps<NoteNode>) {
return (
<BaseNodeWrapper selected={selected} className="w-52 p-3">
<div className="mb-1 text-xs font-medium text-muted-foreground">Notiz</div>
<p className="whitespace-pre-wrap text-sm">{data.content || "Leere Notiz"}</p>
</BaseNodeWrapper>
);
}

View File

@@ -0,0 +1,29 @@
"use client";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import BaseNodeWrapper from "./base-node-wrapper";
export type PromptNodeData = {
content?: string;
model?: string;
};
export type PromptNode = Node<PromptNodeData, "prompt">;
export default function PromptNode({ data, selected }: NodeProps<PromptNode>) {
return (
<BaseNodeWrapper selected={selected} className="w-72 border-purple-500/30 p-3">
<div className="mb-1 text-xs font-medium text-purple-500">Prompt</div>
<p className="min-h-[2rem] whitespace-pre-wrap text-sm">{data.content || "Prompt eingeben..."}</p>
{data.model ? (
<div className="mt-2 text-xs text-muted-foreground">Modell: {data.model}</div>
) : null}
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !border-2 !border-background !bg-purple-500"
/>
</BaseNodeWrapper>
);
}

View File

@@ -0,0 +1,25 @@
"use client";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import BaseNodeWrapper from "./base-node-wrapper";
export type TextNodeData = {
content?: string;
};
export type TextNode = Node<TextNodeData, "text">;
export default function TextNode({ data, selected }: NodeProps<TextNode>) {
return (
<BaseNodeWrapper selected={selected} className="w-64 p-3">
<div className="mb-1 text-xs font-medium text-muted-foreground">Text</div>
<p className="min-h-[2rem] whitespace-pre-wrap text-sm">{data.content || "Text eingeben..."}</p>
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !border-2 !border-background !bg-primary"
/>
</BaseNodeWrapper>
);
}