From 3926940c5a4224377d9efc50634bc2f248aac339 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 1 Apr 2026 20:18:01 +0200 Subject: [PATCH] Show upload progress and fix credit concurrency user scope - Replace image upload POST with XHR progress tracking - Keep upload state until Convex sync completes - Pass owner userId through image generation to decrement concurrency correctly --- components/canvas/nodes/image-node.tsx | 97 +++++++++++++++++++++----- convex/ai.ts | 26 ++++++- convex/credits.ts | 11 +-- 3 files changed, 112 insertions(+), 22 deletions(-) diff --git a/components/canvas/nodes/image-node.tsx b/components/canvas/nodes/image-node.tsx index c6f6617..16911a4 100644 --- a/components/canvas/nodes/image-node.tsx +++ b/components/canvas/nodes/image-node.tsx @@ -23,6 +23,7 @@ import { toast } from "@/lib/toast"; import { computeMediaNodeSize } from "@/lib/canvas-utils"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import { useMutation } from "convex/react"; +import { Progress } from "@/components/ui/progress"; const ALLOWED_IMAGE_TYPES = new Set([ "image/png", @@ -84,10 +85,28 @@ export default function ImageNode({ const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync(); const fileInputRef = useRef(null); const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [pendingUploadStorageId, setPendingUploadStorageId] = useState( + null, + ); const [isDragOver, setIsDragOver] = useState(false); const [isFullscreenOpen, setIsFullscreenOpen] = useState(false); const hasAutoSizedRef = useRef(false); + useEffect(() => { + if (!isUploading || !pendingUploadStorageId) return; + + if ( + data.storageId === pendingUploadStorageId && + typeof data.url === "string" && + data.url.length > 0 + ) { + setIsUploading(false); + setPendingUploadStorageId(null); + setUploadProgress(0); + } + }, [data.storageId, data.url, isUploading, pendingUploadStorageId]); + useEffect(() => { if (typeof id === "string" && id.startsWith(OPTIMISTIC_NODE_PREFIX)) { return; @@ -128,6 +147,7 @@ export default function ImageNode({ const uploadFile = useCallback( async (file: File) => { + if (isUploading) return; if (!ALLOWED_IMAGE_TYPES.has(file.type)) { toast.error( t('canvas.uploadFailed'), @@ -151,6 +171,8 @@ export default function ImageNode({ } setIsUploading(true); + setUploadProgress(0); + setPendingUploadStorageId(null); try { let dimensions: { width: number; height: number } | undefined; @@ -161,17 +183,39 @@ export default function ImageNode({ } const uploadUrl = await generateUploadUrl(); - const result = await fetch(uploadUrl, { - method: "POST", - headers: { "Content-Type": file.type }, - body: file, - }); + const { storageId } = await new Promise<{ storageId: string }>( + (resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", uploadUrl, true); + xhr.setRequestHeader("Content-Type", file.type); - if (!result.ok) { - throw new Error("Upload failed"); - } + xhr.upload.onprogress = (event) => { + if (!event.lengthComputable) return; + const percent = Math.round((event.loaded / event.total) * 100); + setUploadProgress(percent); + }; - const { storageId } = (await result.json()) as { storageId: string }; + 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); + }, + ); + + setUploadProgress(100); + setPendingUploadStorageId(storageId); await queueNodeDataUpdate({ nodeId: id as Id<"nodes">, @@ -199,15 +243,23 @@ export default function ImageNode({ toast.success(t('canvas.imageUploaded')); } catch (err) { console.error("Upload failed:", err); + setPendingUploadStorageId(null); toast.error( t('canvas.uploadFailed'), err instanceof Error ? err.message : undefined, ); - } finally { + setUploadProgress(0); setIsUploading(false); } }, - [generateUploadUrl, id, queueNodeDataUpdate, queueNodeResize, status.isOffline] + [ + generateUploadUrl, + id, + isUploading, + queueNodeDataUpdate, + queueNodeResize, + status.isOffline, + ], ); const handleClick = useCallback(() => { @@ -245,19 +297,25 @@ export default function ImageNode({ e.stopPropagation(); setIsDragOver(false); + if (isUploading) return; const file = e.dataTransfer.files?.[0]; if (file && file.type.startsWith("image/")) { uploadFile(file); } }, - [uploadFile] + [isUploading, uploadFile] ); const handleReplace = useCallback(() => { + if (isUploading) return; fileInputRef.current?.click(); - }, []); + }, [isUploading]); const showFilename = Boolean(data.filename && data.url); + const uploadingLabel = + uploadProgress === 100 && pendingUploadStorageId + ? "100% — wird synchronisiert…" + : "Wird hochgeladen…"; return ( <> @@ -293,7 +351,8 @@ export default function ImageNode({ {data.url && ( @@ -304,8 +363,13 @@ export default function ImageNode({ {isUploading ? (
-
- Wird hochgeladen... + {uploadingLabel} +
+ +
+ + {uploadProgress}% +
) : data.url ? ( @@ -348,6 +412,7 @@ export default function ImageNode({ ref={fileInputRef} type="file" accept="image/png,image/jpeg,image/webp" + disabled={isUploading} onChange={handleFileChange} className="hidden" /> diff --git a/convex/ai.ts b/convex/ai.ts index 8a4b44c..50101cb 100644 --- a/convex/ai.ts +++ b/convex/ai.ts @@ -346,8 +346,16 @@ export const processImageGeneration = internalAction({ aspectRatio: v.optional(v.string()), reservationId: v.optional(v.id("creditTransactions")), shouldDecrementConcurrency: v.boolean(), + userId: v.string(), }, handler: async (ctx, args) => { + console.info("[processImageGeneration] start", { + nodeId: args.nodeId, + reservationId: args.reservationId ?? null, + shouldDecrementConcurrency: args.shouldDecrementConcurrency, + userId: args.userId, + }); + let retryCount = 0; try { @@ -394,7 +402,9 @@ export const processImageGeneration = internalAction({ }); } finally { if (args.shouldDecrementConcurrency) { - await ctx.runMutation(internal.credits.decrementConcurrency, {}); + await ctx.runMutation(internal.credits.decrementConcurrency, { + userId: args.userId, + }); } } }, @@ -411,6 +421,15 @@ export const generateImage = action({ aspectRatio: v.optional(v.string()), }, handler: async (ctx, args) => { + const canvas = await ctx.runQuery(api.canvases.get, { + canvasId: args.canvasId, + }); + if (!canvas) { + throw new Error("Canvas not found"); + } + + const userId = canvas.ownerId; + const internalCreditsEnabled = process.env.INTERNAL_CREDITS_ENABLED === "true"; @@ -455,6 +474,7 @@ export const generateImage = action({ aspectRatio: args.aspectRatio, reservationId: reservationId ?? undefined, shouldDecrementConcurrency: usageIncremented, + userId, }); backgroundJobScheduled = true; return { queued: true as const, nodeId: args.nodeId }; @@ -478,7 +498,9 @@ export const generateImage = action({ throw error; } finally { if (usageIncremented && !backgroundJobScheduled) { - await ctx.runMutation(internal.credits.decrementConcurrency, {}); + await ctx.runMutation(internal.credits.decrementConcurrency, { + userId, + }); } } }, diff --git a/convex/credits.ts b/convex/credits.ts index d2d29ac..4153288 100644 --- a/convex/credits.ts +++ b/convex/credits.ts @@ -833,15 +833,18 @@ export const incrementUsage = internalMutation({ * (commit/release übernehmen das bei aktivierten Credits). */ export const decrementConcurrency = internalMutation({ - args: {}, - handler: async (ctx) => { - const user = await requireAuth(ctx); + args: { + userId: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const resolvedUserId = + args.userId ?? (await requireAuth(ctx)).userId; const today = new Date().toISOString().split("T")[0]; const usage = await ctx.db .query("dailyUsage") .withIndex("by_user_date", (q) => - q.eq("userId", user.userId).eq("date", today) + q.eq("userId", resolvedUserId).eq("date", today) ) .unique();