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
This commit is contained in:
Matthias
2026-04-01 20:18:01 +02:00
parent 8988428fc9
commit 3926940c5a
3 changed files with 112 additions and 22 deletions

View File

@@ -23,6 +23,7 @@ import { toast } from "@/lib/toast";
import { computeMediaNodeSize } from "@/lib/canvas-utils"; import { computeMediaNodeSize } from "@/lib/canvas-utils";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import { useMutation } from "convex/react"; import { useMutation } from "convex/react";
import { Progress } from "@/components/ui/progress";
const ALLOWED_IMAGE_TYPES = new Set([ const ALLOWED_IMAGE_TYPES = new Set([
"image/png", "image/png",
@@ -84,10 +85,28 @@ export default function ImageNode({
const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync(); const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [pendingUploadStorageId, setPendingUploadStorageId] = useState<string | null>(
null,
);
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false); const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
const hasAutoSizedRef = useRef(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(() => { useEffect(() => {
if (typeof id === "string" && id.startsWith(OPTIMISTIC_NODE_PREFIX)) { if (typeof id === "string" && id.startsWith(OPTIMISTIC_NODE_PREFIX)) {
return; return;
@@ -128,6 +147,7 @@ export default function ImageNode({
const uploadFile = useCallback( const uploadFile = useCallback(
async (file: File) => { async (file: File) => {
if (isUploading) return;
if (!ALLOWED_IMAGE_TYPES.has(file.type)) { if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
toast.error( toast.error(
t('canvas.uploadFailed'), t('canvas.uploadFailed'),
@@ -151,6 +171,8 @@ export default function ImageNode({
} }
setIsUploading(true); setIsUploading(true);
setUploadProgress(0);
setPendingUploadStorageId(null);
try { try {
let dimensions: { width: number; height: number } | undefined; let dimensions: { width: number; height: number } | undefined;
@@ -161,17 +183,39 @@ export default function ImageNode({
} }
const uploadUrl = await generateUploadUrl(); const uploadUrl = await generateUploadUrl();
const result = await fetch(uploadUrl, { const { storageId } = await new Promise<{ storageId: string }>(
method: "POST", (resolve, reject) => {
headers: { "Content-Type": file.type }, const xhr = new XMLHttpRequest();
body: file, xhr.open("POST", uploadUrl, true);
}); xhr.setRequestHeader("Content-Type", file.type);
if (!result.ok) { xhr.upload.onprogress = (event) => {
throw new Error("Upload failed"); 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({ await queueNodeDataUpdate({
nodeId: id as Id<"nodes">, nodeId: id as Id<"nodes">,
@@ -199,15 +243,23 @@ export default function ImageNode({
toast.success(t('canvas.imageUploaded')); toast.success(t('canvas.imageUploaded'));
} catch (err) { } catch (err) {
console.error("Upload failed:", err); console.error("Upload failed:", err);
setPendingUploadStorageId(null);
toast.error( toast.error(
t('canvas.uploadFailed'), t('canvas.uploadFailed'),
err instanceof Error ? err.message : undefined, err instanceof Error ? err.message : undefined,
); );
} finally { setUploadProgress(0);
setIsUploading(false); setIsUploading(false);
} }
}, },
[generateUploadUrl, id, queueNodeDataUpdate, queueNodeResize, status.isOffline] [
generateUploadUrl,
id,
isUploading,
queueNodeDataUpdate,
queueNodeResize,
status.isOffline,
],
); );
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
@@ -245,19 +297,25 @@ export default function ImageNode({
e.stopPropagation(); e.stopPropagation();
setIsDragOver(false); setIsDragOver(false);
if (isUploading) return;
const file = e.dataTransfer.files?.[0]; const file = e.dataTransfer.files?.[0];
if (file && file.type.startsWith("image/")) { if (file && file.type.startsWith("image/")) {
uploadFile(file); uploadFile(file);
} }
}, },
[uploadFile] [isUploading, uploadFile]
); );
const handleReplace = useCallback(() => { const handleReplace = useCallback(() => {
if (isUploading) return;
fileInputRef.current?.click(); fileInputRef.current?.click();
}, []); }, [isUploading]);
const showFilename = Boolean(data.filename && data.url); const showFilename = Boolean(data.filename && data.url);
const uploadingLabel =
uploadProgress === 100 && pendingUploadStorageId
? "100% — wird synchronisiert…"
: "Wird hochgeladen…";
return ( return (
<> <>
@@ -293,7 +351,8 @@ export default function ImageNode({
{data.url && ( {data.url && (
<button <button
onClick={handleReplace} onClick={handleReplace}
className="nodrag text-xs text-muted-foreground transition-colors hover:text-foreground" disabled={isUploading}
className="nodrag text-xs text-muted-foreground transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
> >
Ersetzen Ersetzen
</button> </button>
@@ -304,8 +363,13 @@ export default function ImageNode({
{isUploading ? ( {isUploading ? (
<div className="flex h-full w-full items-center justify-center bg-muted"> <div className="flex h-full w-full items-center justify-center bg-muted">
<div className="flex flex-col items-center gap-2"> <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">{uploadingLabel}</span>
<span className="text-xs text-muted-foreground">Wird hochgeladen...</span> <div className="w-40">
<Progress value={uploadProgress} className="h-1.5" />
</div>
<span className="text-[11px] text-muted-foreground">
{uploadProgress}%
</span>
</div> </div>
</div> </div>
) : data.url ? ( ) : data.url ? (
@@ -348,6 +412,7 @@ export default function ImageNode({
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="image/png,image/jpeg,image/webp" accept="image/png,image/jpeg,image/webp"
disabled={isUploading}
onChange={handleFileChange} onChange={handleFileChange}
className="hidden" className="hidden"
/> />

View File

@@ -346,8 +346,16 @@ export const processImageGeneration = internalAction({
aspectRatio: v.optional(v.string()), aspectRatio: v.optional(v.string()),
reservationId: v.optional(v.id("creditTransactions")), reservationId: v.optional(v.id("creditTransactions")),
shouldDecrementConcurrency: v.boolean(), shouldDecrementConcurrency: v.boolean(),
userId: v.string(),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
console.info("[processImageGeneration] start", {
nodeId: args.nodeId,
reservationId: args.reservationId ?? null,
shouldDecrementConcurrency: args.shouldDecrementConcurrency,
userId: args.userId,
});
let retryCount = 0; let retryCount = 0;
try { try {
@@ -394,7 +402,9 @@ export const processImageGeneration = internalAction({
}); });
} finally { } finally {
if (args.shouldDecrementConcurrency) { 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()), aspectRatio: v.optional(v.string()),
}, },
handler: async (ctx, args) => { 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 = const internalCreditsEnabled =
process.env.INTERNAL_CREDITS_ENABLED === "true"; process.env.INTERNAL_CREDITS_ENABLED === "true";
@@ -455,6 +474,7 @@ export const generateImage = action({
aspectRatio: args.aspectRatio, aspectRatio: args.aspectRatio,
reservationId: reservationId ?? undefined, reservationId: reservationId ?? undefined,
shouldDecrementConcurrency: usageIncremented, shouldDecrementConcurrency: usageIncremented,
userId,
}); });
backgroundJobScheduled = true; backgroundJobScheduled = true;
return { queued: true as const, nodeId: args.nodeId }; return { queued: true as const, nodeId: args.nodeId };
@@ -478,7 +498,9 @@ export const generateImage = action({
throw error; throw error;
} finally { } finally {
if (usageIncremented && !backgroundJobScheduled) { if (usageIncremented && !backgroundJobScheduled) {
await ctx.runMutation(internal.credits.decrementConcurrency, {}); await ctx.runMutation(internal.credits.decrementConcurrency, {
userId,
});
} }
} }
}, },

View File

@@ -833,15 +833,18 @@ export const incrementUsage = internalMutation({
* (commit/release übernehmen das bei aktivierten Credits). * (commit/release übernehmen das bei aktivierten Credits).
*/ */
export const decrementConcurrency = internalMutation({ export const decrementConcurrency = internalMutation({
args: {}, args: {
handler: async (ctx) => { userId: v.optional(v.string()),
const user = await requireAuth(ctx); },
handler: async (ctx, args) => {
const resolvedUserId =
args.userId ?? (await requireAuth(ctx)).userId;
const today = new Date().toISOString().split("T")[0]; const today = new Date().toISOString().split("T")[0];
const usage = await ctx.db const usage = await ctx.db
.query("dailyUsage") .query("dailyUsage")
.withIndex("by_user_date", (q) => .withIndex("by_user_date", (q) =>
q.eq("userId", user.userId).eq("date", today) q.eq("userId", resolvedUserId).eq("date", today)
) )
.unique(); .unique();