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:
@@ -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<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 (
|
||||
<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
|
||||
<BaseNodeWrapper selected={selected} status={data._status}>
|
||||
<div className="p-2">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-muted-foreground">🖼️ Bild</div>
|
||||
{data.url && (
|
||||
<button
|
||||
onClick={handleReplace}
|
||||
className="nodrag text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
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 ? (
|
||||
<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
|
||||
type="source"
|
||||
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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user