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:
@@ -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);
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onload = async () => {
|
||||||
|
if (xhr.status < 200 || xhr.status >= 300) {
|
||||||
|
reject(new Error(`Upload failed: ${xhr.status}`));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { storageId } = (await result.json()) as { storageId: string };
|
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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
26
convex/ai.ts
26
convex/ai.ts
@@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user