Files
lemonspace_app/components/canvas/nodes/image-node.tsx

652 lines
21 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
useState,
useCallback,
useEffect,
useRef,
type ChangeEvent,
type DragEvent,
} from "react";
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
import { Maximize2, X } from "lucide-react";
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,
DialogTitle,
} 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";
import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
const ALLOWED_IMAGE_TYPES = new Set([
"image/png",
"image/jpeg",
"image/webp",
]);
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">;
export default function ImageNode({
id,
data,
selected,
width,
height,
}: 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");
const [uploadProgress, setUploadProgress] = useState(0);
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 &&
data.storageId === pendingUploadStorageId &&
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)) {
return;
}
if (typeof data.width !== "number" || typeof data.height !== "number") {
return;
}
if (hasAutoSizedRef.current) return;
const targetSize = computeMediaNodeSize("image", {
intrinsicWidth: data.width,
intrinsicHeight: data.height,
});
const currentWidth = typeof width === "number" ? width : 0;
const currentHeight = typeof height === "number" ? height : 0;
const hasMeasuredSize = currentWidth > 0 && currentHeight > 0;
if (!hasMeasuredSize) {
return;
}
const isAtTargetSize = currentWidth === targetSize.width && currentHeight === targetSize.height;
const isAtDefaultSeedSize = currentWidth === 280 && currentHeight === 200;
const shouldRunInitialAutoSize = isAtDefaultSeedSize && !isAtTargetSize;
if (!shouldRunInitialAutoSize) {
hasAutoSizedRef.current = true;
return;
}
hasAutoSizedRef.current = true;
void queueNodeResize({
nodeId: id as Id<"nodes">,
width: targetSize.width,
height: targetSize.height,
});
}, [data.height, data.width, height, id, queueNodeResize, width]);
const uploadFile = useCallback(
async (file: File) => {
if (isUploading) return;
if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
toast.error(
t('canvas.uploadFailed'),
t('canvas.uploadFormatError', { format: file.type || file.name.split(".").pop() || "—" }),
);
return;
}
if (file.size > MAX_IMAGE_BYTES) {
toast.error(
t('canvas.uploadFailed'),
t('canvas.uploadSizeError', { maxMb: Math.round(MAX_IMAGE_BYTES / (1024 * 1024)) }),
);
return;
}
if (status.isOffline) {
toast.warning(
"Offline aktuell nicht unterstützt",
"Bild-Uploads benötigen eine aktive Verbindung.",
);
return;
}
setUploadPhase("uploading");
setUploadProgress(0);
setPendingUploadStorageId(null);
try {
let dimensions: { width: number; height: number } | undefined;
let previewUpload:
| {
previewStorageId: string;
previewWidth: number;
previewHeight: number;
}
| undefined;
try {
dimensions = await getImageDimensions(file);
} catch {
dimensions = undefined;
}
const uploadUrl = await generateUploadUrl();
const { storageId } = await new Promise<{ storageId: string }>(
(resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", uploadUrl, true);
xhr.setRequestHeader("Content-Type", file.type);
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable) return;
const percent = Math.round((event.loaded / event.total) * 100);
setUploadProgress(percent);
};
xhr.onload = async () => {
if (xhr.status < 200 || xhr.status >= 300) {
reject(new Error(`Upload failed: ${xhr.status}`));
return;
}
try {
const parsed = JSON.parse(xhr.responseText) as { storageId: string };
resolve(parsed);
} catch {
reject(new Error("Upload fehlgeschlagen"));
}
};
xhr.onerror = () => reject(new Error("Upload fehlgeschlagen"));
xhr.send(file);
},
);
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");
await queueNodeDataUpdate({
nodeId: id as Id<"nodes">,
data: preserveNodeFavorite(
{
storageId,
...(previewUpload ?? {}),
filename: file.name,
mimeType: file.type,
...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}),
},
data,
),
});
if (dimensions) {
const targetSize = computeMediaNodeSize("image", {
intrinsicWidth: dimensions.width,
intrinsicHeight: dimensions.height,
});
await queueNodeResize({
nodeId: id as Id<"nodes">,
width: targetSize.width,
height: targetSize.height,
});
}
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) {
console.error("Upload failed:", err);
setPendingUploadStorageId(null);
toast.error(
t('canvas.uploadFailed'),
err instanceof Error ? err.message : undefined,
);
setUploadProgress(0);
setUploadPhase("idle");
}
},
[
data,
generateUploadUrl,
id,
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: preserveNodeFavorite(
{
storageId: item.storageId,
previewStorageId: item.previewStorageId,
filename: item.filename,
mimeType: item.mimeType,
width: item.width,
height: item.height,
previewWidth: item.previewWidth,
previewHeight: item.previewHeight,
},
data,
),
});
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,
);
}
},
[data, id, isNodeLoading, queueNodeDataUpdate, queueNodeResize, t],
);
const handleClick = useCallback(() => {
if (!data.url && !isNodeLoading) {
fileInputRef.current?.click();
}
}, [data.url, isNodeLoading]);
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);
if (isNodeLoading) return;
const file = e.dataTransfer.files?.[0];
if (file && file.type.startsWith("image/")) {
uploadFile(file);
}
},
[isNodeLoading, uploadFile]
);
const handleReplace = useCallback(() => {
if (isNodeLoading) return;
fileInputRef.current?.click();
}, [isNodeLoading]);
const showFilename = Boolean(data.filename && data.url);
const effectiveUploadProgress = isUploading
? isWaitingForCanvasSync
? 100
: uploadProgress
: 100;
const uploadingLabel = isUploading
? isWaitingForCanvasSync
? "100% — wird synchronisiert…"
: "Wird hochgeladen…"
: "Bild wird uebernommen…";
return (
<>
<BaseNodeWrapper
nodeType="image"
selected={selected}
status={data._status}
toolbarActions={[
{
id: "fullscreen",
label: "Fullscreen",
icon: <Maximize2 size={14} />,
onClick: () => setIsFullscreenOpen(true),
disabled: !data.url,
},
]}
>
<Handle
type="target"
position={Position.Left}
className="h-3! w-3! bg-primary! border-2! border-background!"
/>
<div
className={`grid h-full min-h-0 w-full grid-cols-1 gap-y-1 p-2 ${
showFilename
? "grid-rows-[auto_minmax(0,1fr)_auto]"
: "grid-rows-[auto_minmax(0,1fr)]"
}`}
>
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-muted-foreground">🖼 Bild</div>
{data.url && (
<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">
{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>
<div className="w-40">
<Progress value={effectiveUploadProgress} className="h-1.5" />
</div>
<span className="text-[11px] text-muted-foreground">
{effectiveUploadProgress}%
</span>
</div>
</div>
) : data.url ? (
// eslint-disable-next-line @next/next/no-img-element -- Convex storage URL, volle Auflösung wie Asset-Node
<img
src={data.url}
alt={data.filename ?? "Bild"}
className="h-full w-full object-cover object-center"
draggable={false}
/>
) : (
<div
onClick={handleClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`
nodrag flex h-full w-full cursor-pointer flex-col items-center justify-center
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>
<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>
{showFilename ? (
<p className="min-h-0 truncate text-xs text-muted-foreground">{data.filename}</p>
) : null}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isNodeLoading}
onChange={handleFileChange}
className="hidden"
/>
<Handle
type="source"
position={Position.Right}
className="h-3! w-3! bg-primary! border-2! border-background!"
/>
</BaseNodeWrapper>
<Dialog open={isFullscreenOpen} onOpenChange={setIsFullscreenOpen}>
<DialogContent
className="inset-0 left-0 top-0 h-screen w-screen max-w-none -translate-x-0 -translate-y-0 place-items-center gap-0 rounded-none border-none bg-transparent p-0 ring-0 shadow-none sm:max-w-none"
showCloseButton={false}
>
<DialogTitle className="sr-only">{data.filename ?? "Bild"}</DialogTitle>
<button
type="button"
onClick={() => setIsFullscreenOpen(false)}
aria-label="Close image preview"
className="absolute right-6 top-6 z-50 inline-flex h-10 w-10 items-center justify-center rounded-full bg-black/20 text-white/90 transition-colors hover:bg-black/30"
>
<X className="h-5 w-5" />
</button>
<div className="flex h-full w-full items-center justify-center">
{data.url ? (
// eslint-disable-next-line @next/next/no-img-element -- Convex storage URL, volle Auflösung wie Asset-Node
<img
src={data.url}
alt={data.filename ?? "Bild"}
className="h-auto max-h-[80vh] w-auto max-w-[80vw] rounded-xl object-contain shadow-2xl"
draggable={false}
/>
) : (
<div className="rounded-lg bg-popover/95 px-4 py-3 text-sm text-muted-foreground shadow-lg">
Kein Bild verfügbar
</div>
)}
</div>
</DialogContent>
</Dialog>
<MediaLibraryDialog
open={isMediaLibraryOpen}
onOpenChange={setIsMediaLibraryOpen}
onPick={handlePickFromMediaLibrary}
pickCtaLabel="Uebernehmen"
/>
</>
);
}