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 { 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<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [pendingUploadStorageId, setPendingUploadStorageId] = useState<string | null>(
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 && (
<button
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
</button>
@@ -304,8 +363,13 @@ export default function ImageNode({
{isUploading ? (
<div className="flex h-full w-full items-center justify-center 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>
<span className="text-xs text-muted-foreground">{uploadingLabel}</span>
<div className="w-40">
<Progress value={uploadProgress} className="h-1.5" />
</div>
<span className="text-[11px] text-muted-foreground">
{uploadProgress}%
</span>
</div>
</div>
) : 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"
/>