Enhance canvas functionality by adding media preview capabilities and image upload handling. Introduce compressed image previews during uploads, improve media library integration, and implement retry logic for bridge edge creation. Update dashboard to display media previews and optimize image node handling.

This commit is contained in:
Matthias
2026-04-08 20:44:31 +02:00
parent a7eb2bc99c
commit b7f24223f2
43 changed files with 4064 additions and 148 deletions

View File

@@ -14,6 +14,10 @@ import { useTranslations } from "next-intl";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import BaseNodeWrapper from "./base-node-wrapper";
import {
MediaLibraryDialog,
type MediaLibraryItem,
} from "@/components/media/media-library-dialog";
import {
Dialog,
DialogContent,
@@ -21,9 +25,17 @@ import {
} from "@/components/ui/dialog";
import { toast } from "@/lib/toast";
import { computeMediaNodeSize } from "@/lib/canvas-utils";
import {
emitDashboardSnapshotCacheInvalidationSignal,
invalidateDashboardSnapshotForLastSignedInUser,
} from "@/lib/dashboard-snapshot-cache";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import { useMutation } from "convex/react";
import { Progress } from "@/components/ui/progress";
import {
createCompressedImagePreview,
getImageDimensions,
} from "@/components/canvas/canvas-media-utils";
const ALLOWED_IMAGE_TYPES = new Set([
"image/png",
@@ -34,45 +46,22 @@ const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
const OPTIMISTIC_NODE_PREFIX = "optimistic_";
type ImageNodeData = {
canvasId?: string;
storageId?: string;
previewStorageId?: string;
url?: string;
filename?: string;
mimeType?: string;
width?: number;
height?: number;
previewWidth?: number;
previewHeight?: number;
_status?: string;
_statusMessage?: string;
};
export type ImageNode = Node<ImageNodeData, "image">;
async function getImageDimensions(file: File): Promise<{ width: number; height: number }> {
return await new Promise((resolve, reject) => {
const objectUrl = URL.createObjectURL(file);
const image = new window.Image();
image.onload = () => {
const width = image.naturalWidth;
const height = image.naturalHeight;
URL.revokeObjectURL(objectUrl);
if (!width || !height) {
reject(new Error("Could not read image dimensions"));
return;
}
resolve({ width, height });
};
image.onerror = () => {
URL.revokeObjectURL(objectUrl);
reject(new Error("Could not decode image"));
};
image.src = objectUrl;
});
}
export default function ImageNode({
id,
data,
@@ -82,6 +71,7 @@ export default function ImageNode({
}: NodeProps<ImageNode>) {
const t = useTranslations('toasts');
const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
const registerUploadedImageMedia = useMutation(api.storage.registerUploadedImageMedia);
const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync();
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadPhase, setUploadPhase] = useState<"idle" | "uploading" | "syncing">("idle");
@@ -89,9 +79,48 @@ export default function ImageNode({
const [pendingUploadStorageId, setPendingUploadStorageId] = useState<string | null>(
null,
);
const [mediaLibraryPhase, setMediaLibraryPhase] = useState<
"idle" | "applying" | "syncing"
>("idle");
const [pendingMediaLibraryStorageId, setPendingMediaLibraryStorageId] = useState<
string | null
>(null);
const [isDragOver, setIsDragOver] = useState(false);
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
const [isMediaLibraryOpen, setIsMediaLibraryOpen] = useState(false);
const hasAutoSizedRef = useRef(false);
const canvasId = data.canvasId as Id<"canvases"> | undefined;
const isOptimisticNodeId =
typeof id === "string" && id.startsWith(OPTIMISTIC_NODE_PREFIX);
const isNodeStable = !isOptimisticNodeId;
const registerUploadInMediaLibrary = useCallback(
(args: {
storageId: string;
filename?: string;
mimeType?: string;
width?: number;
height?: number;
nodeId?: Id<"nodes">;
}) => {
if (!canvasId) {
return;
}
void registerUploadedImageMedia({
canvasId,
storageId: args.storageId as Id<"_storage">,
nodeId: args.nodeId,
filename: args.filename,
mimeType: args.mimeType,
width: args.width,
height: args.height,
}).catch((error: unknown) => {
console.warn("[ImageNode] registerUploadedImageMedia failed", error);
});
},
[canvasId, registerUploadedImageMedia],
);
const isPendingUploadSynced =
pendingUploadStorageId !== null &&
@@ -99,7 +128,35 @@ export default function ImageNode({
typeof data.url === "string" &&
data.url.length > 0;
const isWaitingForCanvasSync = pendingUploadStorageId !== null && !isPendingUploadSynced;
const isPendingMediaLibrarySynced =
pendingMediaLibraryStorageId !== null &&
data.storageId === pendingMediaLibraryStorageId &&
typeof data.url === "string" &&
data.url.length > 0;
const isWaitingForMediaLibrarySync =
pendingMediaLibraryStorageId !== null && !isPendingMediaLibrarySynced;
const isUploading = uploadPhase !== "idle" || isWaitingForCanvasSync;
const isApplyingMediaLibrary =
mediaLibraryPhase !== "idle" || isWaitingForMediaLibrarySync;
const isNodeLoading = isUploading || isApplyingMediaLibrary;
useEffect(() => {
if (!isPendingUploadSynced) {
return;
}
setPendingUploadStorageId(null);
setUploadPhase("idle");
}, [isPendingUploadSynced]);
useEffect(() => {
if (!isPendingMediaLibrarySynced) {
return;
}
setPendingMediaLibraryStorageId(null);
setMediaLibraryPhase("idle");
}, [isPendingMediaLibrarySynced]);
useEffect(() => {
if (typeof id === "string" && id.startsWith(OPTIMISTIC_NODE_PREFIX)) {
@@ -170,6 +227,13 @@ export default function ImageNode({
try {
let dimensions: { width: number; height: number } | undefined;
let previewUpload:
| {
previewStorageId: string;
previewWidth: number;
previewHeight: number;
}
| undefined;
try {
dimensions = await getImageDimensions(file);
} catch {
@@ -208,6 +272,30 @@ export default function ImageNode({
},
);
try {
const preview = await createCompressedImagePreview(file);
const previewUploadUrl = await generateUploadUrl();
const previewUploadResult = await fetch(previewUploadUrl, {
method: "POST",
headers: { "Content-Type": preview.blob.type || "image/webp" },
body: preview.blob,
});
if (!previewUploadResult.ok) {
throw new Error(`Preview upload failed: ${previewUploadResult.status}`);
}
const { storageId: previewStorageId } =
(await previewUploadResult.json()) as { storageId: string };
previewUpload = {
previewStorageId,
previewWidth: preview.width,
previewHeight: preview.height,
};
} catch (previewError) {
console.warn("[ImageNode] preview generation/upload failed", previewError);
}
setUploadProgress(100);
setPendingUploadStorageId(storageId);
setUploadPhase("syncing");
@@ -216,6 +304,7 @@ export default function ImageNode({
nodeId: id as Id<"nodes">,
data: {
storageId,
...(previewUpload ?? {}),
filename: file.name,
mimeType: file.type,
...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}),
@@ -235,6 +324,22 @@ export default function ImageNode({
});
}
const nodeIdForRegistration =
typeof id === "string" && !id.startsWith(OPTIMISTIC_NODE_PREFIX)
? (id as Id<"nodes">)
: undefined;
registerUploadInMediaLibrary({
storageId,
filename: file.name,
mimeType: file.type,
width: dimensions?.width,
height: dimensions?.height,
nodeId: nodeIdForRegistration,
});
invalidateDashboardSnapshotForLastSignedInUser();
emitDashboardSnapshotCacheInvalidationSignal();
toast.success(t('canvas.imageUploaded'));
setUploadPhase("idle");
} catch (err) {
@@ -254,16 +359,69 @@ export default function ImageNode({
isUploading,
queueNodeDataUpdate,
queueNodeResize,
registerUploadInMediaLibrary,
status.isOffline,
t,
],
);
const handlePickFromMediaLibrary = useCallback(
async (item: MediaLibraryItem) => {
if (isNodeLoading) {
return;
}
setMediaLibraryPhase("applying");
setPendingMediaLibraryStorageId(item.storageId);
try {
await queueNodeDataUpdate({
nodeId: id as Id<"nodes">,
data: {
storageId: item.storageId,
previewStorageId: item.previewStorageId,
filename: item.filename,
mimeType: item.mimeType,
width: item.width,
height: item.height,
previewWidth: item.previewWidth,
previewHeight: item.previewHeight,
},
});
setMediaLibraryPhase("syncing");
if (typeof item.width === "number" && typeof item.height === "number") {
const targetSize = computeMediaNodeSize("image", {
intrinsicWidth: item.width,
intrinsicHeight: item.height,
});
await queueNodeResize({
nodeId: id as Id<"nodes">,
width: targetSize.width,
height: targetSize.height,
});
}
setIsMediaLibraryOpen(false);
} catch (error) {
console.error("Failed to apply media library image", error);
setPendingMediaLibraryStorageId(null);
setMediaLibraryPhase("idle");
toast.error(
t('canvas.uploadFailed'),
error instanceof Error ? error.message : undefined,
);
}
},
[id, isNodeLoading, queueNodeDataUpdate, queueNodeResize, t],
);
const handleClick = useCallback(() => {
if (!data.url && !isUploading) {
if (!data.url && !isNodeLoading) {
fileInputRef.current?.click();
}
}, [data.url, isUploading]);
}, [data.url, isNodeLoading]);
const handleFileChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
@@ -294,26 +452,31 @@ export default function ImageNode({
e.stopPropagation();
setIsDragOver(false);
if (isUploading) return;
if (isNodeLoading) return;
const file = e.dataTransfer.files?.[0];
if (file && file.type.startsWith("image/")) {
uploadFile(file);
}
},
[isUploading, uploadFile]
[isNodeLoading, uploadFile]
);
const handleReplace = useCallback(() => {
if (isUploading) return;
if (isNodeLoading) return;
fileInputRef.current?.click();
}, [isUploading]);
}, [isNodeLoading]);
const showFilename = Boolean(data.filename && data.url);
const effectiveUploadProgress = isWaitingForCanvasSync ? 100 : uploadProgress;
const uploadingLabel =
isWaitingForCanvasSync
const effectiveUploadProgress = isUploading
? isWaitingForCanvasSync
? 100
: uploadProgress
: 100;
const uploadingLabel = isUploading
? isWaitingForCanvasSync
? "100% — wird synchronisiert…"
: "Wird hochgeladen…";
: "Wird hochgeladen…"
: "Bild wird uebernommen…";
return (
<>
@@ -347,18 +510,18 @@ export default function ImageNode({
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-muted-foreground">🖼 Bild</div>
{data.url && (
<button
onClick={handleReplace}
disabled={isUploading}
className="nodrag text-xs text-muted-foreground transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
>
Ersetzen
<button
onClick={handleReplace}
disabled={isNodeLoading}
className="nodrag text-xs text-muted-foreground transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
>
Ersetzen
</button>
)}
</div>
<div className="relative min-h-0 overflow-hidden rounded-lg bg-muted/30">
{isUploading ? (
{isNodeLoading ? (
<div className="flex h-full w-full items-center justify-center bg-muted">
<div className="flex flex-col items-center gap-2">
<span className="text-xs text-muted-foreground">{uploadingLabel}</span>
@@ -397,6 +560,21 @@ export default function ImageNode({
<span className="mb-1 text-lg">📁</span>
<span>Klicken oder hierhin ziehen</span>
<span className="mt-0.5 text-xs">PNG, JPG, WebP</span>
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (!isNodeStable) {
return;
}
setIsMediaLibraryOpen(true);
}}
disabled={isNodeLoading || !isNodeStable}
className="nodrag mt-3 inline-flex items-center rounded-md border border-border bg-background px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60"
>
{isNodeStable ? "Aus Mediathek" : "Mediathek wird vorbereitet..."}
</button>
</div>
)}
</div>
@@ -410,7 +588,7 @@ export default function ImageNode({
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isUploading}
disabled={isNodeLoading}
onChange={handleFileChange}
className="hidden"
/>
@@ -453,6 +631,13 @@ export default function ImageNode({
</div>
</DialogContent>
</Dialog>
<MediaLibraryDialog
open={isMediaLibraryOpen}
onOpenChange={setIsMediaLibraryOpen}
onPick={handlePickFromMediaLibrary}
pickCtaLabel="Uebernehmen"
/>
</>
);
}