feat: implement Convex-synced canvas foundation
This commit is contained in:
67
components/canvas/nodes/ai-image-node.tsx
Normal file
67
components/canvas/nodes/ai-image-node.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
components/canvas/nodes/base-node-wrapper.tsx
Normal file
37
components/canvas/nodes/base-node-wrapper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
components/canvas/nodes/compare-node.tsx
Normal file
50
components/canvas/nodes/compare-node.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
components/canvas/nodes/frame-node.tsx
Normal file
28
components/canvas/nodes/frame-node.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
components/canvas/nodes/group-node.tsx
Normal file
15
components/canvas/nodes/group-node.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
components/canvas/nodes/image-node.tsx
Normal file
38
components/canvas/nodes/image-node.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
components/canvas/nodes/note-node.tsx
Normal file
20
components/canvas/nodes/note-node.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
components/canvas/nodes/prompt-node.tsx
Normal file
29
components/canvas/nodes/prompt-node.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
components/canvas/nodes/text-node.tsx
Normal file
25
components/canvas/nodes/text-node.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user