From cb3f7906a290afbc4999ea490d87bca7f1eb4bb0 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 2 Apr 2026 16:47:17 -0700 Subject: [PATCH] multi-file upload for image, 3D, and splat modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FileUploader standardised to File[] with multiple prop - Per-file sidebar: idle X → uploading spinner → success ✓ → error ! with hover retry - Carousel preview with left/right navigation - Parallel batch uploads via Promise.all with live per-file status - Single-file path preserves existing spinner UX - Partial failure: Retry Failed button re-uploads only errored files - Thumbnails keyed by File reference (not index) so removals don't corrupt the map - FileList normalised via Array.from to fix file-picker multi-select - Catch block in batch utilities only marks uncompleted files as error --- .../reusable/UploadModal3D/UploadFiles3D.tsx | 507 ++++++++++++++---- .../reusable/UploadModal3D/UploadModal3D.tsx | 10 +- .../utilities/upload3DObjectsBatch.ts | 87 +++ .../UploadModalImage/UploadFilesImage.tsx | 377 +++++++++++-- .../UploadModalImage/UploadModalImage.tsx | 10 +- .../utilities/uploadImagesBatch.ts | 61 +++ .../UploadModalSplat/UploadFilesSplat.tsx | 505 +++++++++++++---- .../UploadModalSplat/UploadModalSplat.tsx | 10 +- .../artcraft/app/src/models/UploaderState.ts | 1 + .../src/lib/drag-and-drop-zone.tsx | 45 +- .../file-uploader/src/lib/file-uploader.tsx | 23 +- 11 files changed, 1359 insertions(+), 277 deletions(-) create mode 100644 frontend/apps/artcraft/app/src/components/reusable/UploadModal3D/utilities/upload3DObjectsBatch.ts create mode 100644 frontend/apps/artcraft/app/src/components/reusable/UploadModalImage/utilities/uploadImagesBatch.ts diff --git a/frontend/apps/artcraft/app/src/components/reusable/UploadModal3D/UploadFiles3D.tsx b/frontend/apps/artcraft/app/src/components/reusable/UploadModal3D/UploadFiles3D.tsx index 71fd250692..c08338809c 100644 --- a/frontend/apps/artcraft/app/src/components/reusable/UploadModal3D/UploadFiles3D.tsx +++ b/frontend/apps/artcraft/app/src/components/reusable/UploadModal3D/UploadFiles3D.tsx @@ -1,19 +1,42 @@ -import { useCallback, useEffect, useState, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ListDropdown } from "@storyteller/ui-list-dropdown"; import { Button } from "@storyteller/ui-button"; import { FileUploader } from "@storyteller/ui-file-uploader"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faCube, + faXmark, + faCheck, + faCircleExclamation, + faChevronLeft, + faChevronRight, + faRotateRight, + faSpinner, +} from "@fortawesome/pro-solid-svg-icons"; +import * as THREE from "three"; import { loadPreviewOnCanvas, snapshotCanvasAsThumbnail } from "./utilities"; import { upload3DObjects } from "./utilities/upload3DObjects"; -import { UploaderState } from "../../../models"; -import { FilterEngineCategories, MediaFileAnimationType } from "../../../enums"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faCube } from "@fortawesome/pro-solid-svg-icons"; +import { upload3DObjectsBatch } from "./utilities/upload3DObjectsBatch"; +import { FileEntryStatus } from "../UploadModalImage/utilities/uploadImagesBatch"; +import type { UploaderState } from "../../../models"; +import { + FilterEngineCategories, + MediaFileAnimationType, + UploaderStates, +} from "../../../enums"; + +interface FileEntry { + file: File; + status: FileEntryStatus; + errorMessage?: string; +} interface Props { title: string; fileTypes: string[]; engineCategory: FilterEngineCategories; initialFile?: File; + initialFiles?: File[]; options?: { fileSubtypes?: { [key: string]: string }[]; hasLength?: boolean; @@ -27,6 +50,7 @@ export const UploadFiles3D = ({ fileTypes, engineCategory, initialFile, + initialFiles, options, onClose, onUploadProgress, @@ -37,10 +61,9 @@ export const UploadFiles3D = ({ canvasRef.current = node; } }, []); + const rendererRef = useRef(null); const fileSubtypes = options?.fileSubtypes; - // const hasLength = options?.hasLength; - // const hasThumbnailUpload = options?.hasThumbnailUpload; const [subtype, setSubtype] = useState( fileSubtypes @@ -48,136 +71,434 @@ export const UploadFiles3D = ({ : undefined, ); - const [assetFile, setAssetFile] = useState<{ - value: File | null; - error?: string; - }>({ value: initialFile ?? null }); + const seedFiles = initialFiles ?? (initialFile ? [initialFile] : []); + const [fileEntries, setFileEntries] = useState( + seedFiles.map((f) => ({ file: f, status: "idle" })), + ); + const [previewIndex, setPreviewIndex] = useState(0); + // Incremented on every handleFilesChange so useEffect re-runs even when count stays the same + const [filesVersion, setFilesVersion] = useState(0); const [previewStatus, setPreviewStatus] = useState<{ type: string; message?: string; }>({ type: "init" }); + const [thumbnails, setThumbnails] = useState>(new Map()); + const [isUploading, setIsUploading] = useState(false); + const [overallProgress, setOverallProgress] = useState<{ + current: number; + total: number; + } | null>(null); + const [selectionError, setSelectionError] = useState(); - const [thumbnailFile, setThumbnailFile] = useState( - undefined, - ); - - const handleSubmit = () => { - if (!assetFile.value) { - setAssetFile((curr) => ({ - ...curr, - error: "Please select a file to upload.", - })); - return; + const disposeRenderer = () => { + if (rendererRef.current) { + rendererRef.current.setAnimationLoop(null); + rendererRef.current.dispose(); + rendererRef.current = null; } - - // Use the filename as the title - const title = assetFile.value.name.split(".")[0]; - - upload3DObjects({ - title: title, - assetFile: assetFile.value, - thumbnailSnapshot: thumbnailFile, - engineCategory: engineCategory, - animationType: subtype, - progressCallback: onUploadProgress, - }); }; + // Reload canvas preview whenever the current file or index changes. + // filesVersion ensures this re-runs when the same-count file set is replaced. useEffect(() => { - // Reset subtype when fileSubtypes set changes (e.g., toggling character) - if (!fileSubtypes || fileSubtypes.length === 0) { - setSubtype(undefined); - return; - } - const nextDefault = Object.values(fileSubtypes[0])[0] as - | MediaFileAnimationType - | undefined; - setSubtype(nextDefault); - }, [fileSubtypes]); + const currentFile = fileEntries[previewIndex]?.file; + if (!canvasRef.current || !currentFile) return; - useEffect(() => { - if (canvasRef.current && assetFile.value) { - loadPreviewOnCanvas({ - file: assetFile.value, - canvas: canvasRef.current, - statusCallback: (statusObject: { type: string; message?: string }) => { - setPreviewStatus(statusObject); - }, - }); - } - }, [assetFile.value]); + disposeRenderer(); + setPreviewStatus({ type: "init" }); + const { renderer } = loadPreviewOnCanvas({ + file: currentFile, + canvas: canvasRef.current, + statusCallback: setPreviewStatus, + }); + rendererRef.current = renderer; + + return disposeRenderer; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [previewIndex, filesVersion]); + + // Snapshot thumbnail when preview finishes loading useEffect(() => { if (previewStatus.type === "OK" && canvasRef.current) { snapshotCanvasAsThumbnail({ - targetNode: canvasRef.current!, - resultCallback: (snapshotBlob) => { - if (snapshotBlob) { - setThumbnailFile(snapshotBlob); + targetNode: canvasRef.current, + resultCallback: (blob) => { + const currentFile = fileEntries[previewIndex]?.file; + if (blob && currentFile) { + setThumbnails((prev) => new Map(prev).set(currentFile, blob)); } }, }); } - }, [previewStatus]); + }, [previewStatus, previewIndex]); + + // Reset subtype when fileSubtypes set changes (e.g. toggling character) + useEffect(() => { + if (!fileSubtypes || fileSubtypes.length === 0) { + setSubtype(undefined); + return; + } + setSubtype( + Object.values(fileSubtypes[0])[0] as MediaFileAnimationType | undefined, + ); + }, [fileSubtypes]); + + const updateFileStatus = ( + index: number, + status: FileEntryStatus, + errorMessage?: string, + ) => { + setFileEntries((prev) => + prev.map((entry, i) => + i === index ? { ...entry, status, errorMessage } : entry, + ), + ); + }; + + const removeFile = (index: number) => { + setFileEntries((prev) => { + const next = prev.filter((_, i) => i !== index); + setPreviewIndex((prevIdx) => + Math.min(prevIdx, Math.max(0, next.length - 1)), + ); + return next; + }); + }; + + const retrySingleFile = async (index: number) => { + const entry = fileEntries[index]; + if (!entry || entry.status === "uploading") return; + updateFileStatus(index, "uploading"); + await upload3DObjects({ + title: entry.file.name.slice(0, entry.file.name.lastIndexOf(".")), + assetFile: entry.file, + engineCategory, + animationType: subtype, + thumbnailSnapshot: thumbnails.get(entry.file), + progressCallback: (state) => { + if (state.status === UploaderStates.success) { + updateFileStatus(index, "success"); + } else if ( + state.status === UploaderStates.assetError || + state.status === UploaderStates.coverCreateError || + state.status === UploaderStates.coverSetError + ) { + updateFileStatus(index, "error", state.errorMessage); + } + }, + }); + }; + + const handleFilesChange = (files: File[]) => { + setFileEntries(files.map((f) => ({ file: f, status: "idle" }))); + setPreviewIndex(0); + setFilesVersion((v) => v + 1); + setThumbnails(new Map()); + setSelectionError(undefined); + setOverallProgress(null); + setIsUploading(false); + }; + + const handleSubmit = () => { + if (fileEntries.length === 0) { + setSelectionError("Please select a file to upload."); + return; + } + + // Only upload files that haven't already succeeded + const pendingEntries = fileEntries + .map((e, i) => ({ entry: e, originalIndex: i })) + .filter( + ({ entry }) => entry.status !== "success" && entry.file !== undefined, + ); + const files = pendingEntries.map(({ entry }) => entry.file!); + const originalIndices = pendingEntries.map( + ({ originalIndex }) => originalIndex, + ); + const pendingThumbnails = new Map( + files + .map((file) => [file, thumbnails.get(file) ?? undefined]) + .filter((pair): pair is [File, Blob] => pair[1] !== undefined), + ); + + if (files.length === 1 && fileEntries.length === 1) { + // Single file: use parent modal state machine (shows loading spinner) + upload3DObjects({ + title: files[0].name.slice(0, files[0].name.lastIndexOf(".")), + assetFile: files[0], + engineCategory, + animationType: subtype, + thumbnailSnapshot: thumbnails.get(files[0]), + progressCallback: onUploadProgress, + }); + return; + } + + // Multi-file: stay on form, show inline progress + setIsUploading(true); + setOverallProgress({ current: 0, total: files.length }); + + upload3DObjectsBatch({ + files, + thumbnails: pendingThumbnails, + engineCategory, + animationType: subtype, + onFileStatusChange: (batchIndex, status, errorMessage) => + updateFileStatus(originalIndices[batchIndex], status, errorMessage), + onOverallProgress: (completed, total) => + setOverallProgress({ current: completed, total }), + onComplete: (allSucceeded, anySucceeded) => { + setIsUploading(false); + if (allSucceeded) { + onUploadProgress({ status: UploaderStates.success }); + } else if (!anySucceeded) { + onUploadProgress({ + status: UploaderStates.assetError, + errorMessage: "All uploads failed.", + }); + } + }, + }); + }; + + const retryAllFailed = () => { + const failedIndices = fileEntries + .map((e, i) => (e.status === "error" ? i : -1)) + .filter((i) => i !== -1); + if (failedIndices.length === 0) return; + + const failedFiles = failedIndices + .map((i) => fileEntries[i].file!) + .filter((file) => file !== undefined); + const failedThumbnails = new Map( + failedFiles + .map((file): [File, Blob | undefined] => [ + file, + thumbnails.get(file) ?? undefined, + ]) + .filter((pair): pair is [File, Blob] => pair[1] !== undefined), + ); + + setIsUploading(true); + setOverallProgress({ current: 0, total: failedFiles.length }); + + upload3DObjectsBatch({ + files: failedFiles, + thumbnails: failedThumbnails, + engineCategory, + animationType: subtype, + onFileStatusChange: (batchIndex, status, errorMessage) => + updateFileStatus(failedIndices[batchIndex], status, errorMessage), + onOverallProgress: (completed, total) => + setOverallProgress({ current: completed, total }), + onComplete: (allSucceeded, anySucceeded) => { + setIsUploading(false); + if (allSucceeded) { + onUploadProgress({ status: UploaderStates.success }); + } else if (!anySucceeded) { + onUploadProgress({ + status: UploaderStates.assetError, + errorMessage: "All uploads failed.", + }); + } + }, + }); + }; + + const isMulti = fileEntries.length > 1; + const anyFailed = fileEntries.some((e) => e.status === "error"); + const anyUploading = fileEntries.some((e) => e.status === "uploading"); + const hasUploadStarted = fileEntries.some((e) => e.status !== "idle"); + const allDone = + fileEntries.length > 0 && + fileEntries.every((e) => e.status === "success" || e.status === "error"); + const currentFile = fileEntries[previewIndex]?.file; return ( - <> -
- { - setAssetFile({ - value: file, - }); - }} +
+ {fileSubtypes && fileSubtypes.length > 1 && ( + setSubtype(value as MediaFileAnimationType)} /> - {fileSubtypes && fileSubtypes.length > 1 && ( - setSubtype(value as MediaFileAnimationType)} - /> - )} - {assetFile.error && ( -
{assetFile.error}
- )} + )} + e.file)} + handleChange={handleFilesChange} + multiple={true} + /> + + {selectionError &&
{selectionError}
} + + {isMulti ? ( +
+ {/* File list sidebar */} +
    + {fileEntries.map((entry, i) => ( +
  • setPreviewIndex(i)} + > + + {entry.file.name.slice(0, entry.file.name.lastIndexOf("."))} + + + {entry.status === "idle" && ( + + )} + {entry.status === "uploading" && ( + + )} + {entry.status === "success" && ( + + )} + {entry.status === "error" && ( + + + + + )} + +
  • + ))} +
+ + {/* Canvas preview + carousel */} +
+
+ + {!currentFile && ( +
+ + Your model preview will appear here +
+ )} + {previewStatus.type.includes("Error") && ( +
+ {previewStatus.type} + {previewStatus.message &&
} + {previewStatus.message} +
+ )} +
+
+ + + {previewIndex + 1} / {fileEntries.length} + + +
+
+
+ ) : ( + /* Single-file layout: canvas takes full width (original behaviour) */
- {!assetFile.value && ( + {!currentFile && (
Your model preview will appear here
)} {previewStatus.type.includes("Error") && ( - <> -
- {previewStatus.type} - {previewStatus.message &&
} - {previewStatus.message} -
- +
+ {previewStatus.type} + {previewStatus.message &&
} + {previewStatus.message} +
)}
+ )} -
- + {!isUploading && !anyUploading && allDone && anyFailed && isMulti && ( + + )} + {/* Hide Upload once any upload has started; only Retry Failed or Close remains */} + {!hasUploadStarted && ( -
+ )}
- +
); }; diff --git a/frontend/apps/artcraft/app/src/components/reusable/UploadModal3D/UploadModal3D.tsx b/frontend/apps/artcraft/app/src/components/reusable/UploadModal3D/UploadModal3D.tsx index 3f3358052f..58a544faf3 100644 --- a/frontend/apps/artcraft/app/src/components/reusable/UploadModal3D/UploadModal3D.tsx +++ b/frontend/apps/artcraft/app/src/components/reusable/UploadModal3D/UploadModal3D.tsx @@ -114,13 +114,19 @@ export function UploadModal3D(props: Props) { ); case UploaderStates.uploadingAsset: case UploaderStates.uploadingCover: - case UploaderStates.settingCover: + case UploaderStates.settingCover: { + const p = uploaderState.uploadProgress; return ( <> -
Uploading...
+
+ {p && p.total > 1 + ? `Uploading ${p.current} / ${p.total}...` + : "Uploading..."} +
); + } case UploaderStates.success: { return ( ; + engineCategory: FilterEngineCategories; + animationType?: MediaFileAnimationType; + onFileStatusChange: ( + index: number, + status: FileEntryStatus, + errorMessage?: string, + ) => void; + onOverallProgress: (completed: number, total: number) => void; + onComplete: (allSucceeded: boolean, anySucceeded: boolean) => void; +}) => { + // JS is single-threaded; these counters are safe across concurrent async callbacks. + let completedCount = 0; + let successCount = 0; + const completedIndices = new Set(); + + files.forEach((_, i) => onFileStatusChange(i, "uploading")); + onOverallProgress(0, files.length); + + try { + await Promise.all( + files.map((file, i) => + upload3DObjects({ + title: file.name.slice(0, file.name.lastIndexOf(".")), + assetFile: file, + engineCategory, + animationType, + thumbnailSnapshot: thumbnails.get(file), + progressCallback: (state) => { + if (state.status === UploaderStates.success) { + successCount++; + onFileStatusChange(i, "success"); + } else if ( + state.status === UploaderStates.assetError || + state.status === UploaderStates.coverCreateError || + state.status === UploaderStates.coverSetError + ) { + onFileStatusChange(i, "error", state.errorMessage); + } + + if ( + state.status === UploaderStates.success || + state.status === UploaderStates.assetError || + state.status === UploaderStates.coverCreateError || + state.status === UploaderStates.coverSetError + ) { + completedIndices.add(i); + completedCount++; + onOverallProgress(completedCount, files.length); + } + }, + }), + ), + ); + } catch (err) { + // Only mark files that haven't already reached a terminal state + files.forEach((_, i) => { + if (!completedIndices.has(i)) { + onFileStatusChange( + i, + "error", + err instanceof Error ? err.message : "Unexpected error", + ); + } + }); + } + + onComplete(successCount === files.length, successCount > 0); +}; diff --git a/frontend/apps/artcraft/app/src/components/reusable/UploadModalImage/UploadFilesImage.tsx b/frontend/apps/artcraft/app/src/components/reusable/UploadModalImage/UploadFilesImage.tsx index 72ff7353ab..235ba0d9e6 100644 --- a/frontend/apps/artcraft/app/src/components/reusable/UploadModalImage/UploadFilesImage.tsx +++ b/frontend/apps/artcraft/app/src/components/reusable/UploadModalImage/UploadFilesImage.tsx @@ -1,13 +1,35 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Button } from "@storyteller/ui-button"; import { FileUploader } from "@storyteller/ui-file-uploader"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faXmark, + faCheck, + faCircleExclamation, + faChevronLeft, + faChevronRight, + faRotateRight, + faSpinner, +} from "@fortawesome/pro-solid-svg-icons"; import { uploadImage } from "./utilities/uploadImage"; -import { UploaderState } from "../../../models"; +import { + uploadImagesBatch, + FileEntryStatus, +} from "./utilities/uploadImagesBatch"; +import type { UploaderState } from "../../../models"; +import { UploaderStates } from "../../../enums"; + +interface FileEntry { + file: File; + status: FileEntryStatus; + errorMessage?: string; +} interface Props { title: string; fileTypes: string[]; initialFile?: File; + initialFiles?: File[]; onClose: () => void; onUploadProgress: (newState: UploaderState) => void; } @@ -15,69 +37,332 @@ interface Props { export const UploadFilesImage = ({ fileTypes, initialFile, + initialFiles, onClose, onUploadProgress, }: Props) => { - const [assetFile, setAssetFile] = useState<{ - value: File | null; - error?: string; - }>({ value: initialFile ?? null }); + const seedFiles = initialFiles ?? (initialFile ? [initialFile] : []); - const handleSubmit = () => { - if (!assetFile.value) { - setAssetFile((curr) => ({ - ...curr, - error: "Please select an image to upload.", - })); + const [fileEntries, setFileEntries] = useState( + seedFiles.map((f) => ({ file: f, status: "idle" })) + ); + const [previewIndex, setPreviewIndex] = useState(0); + const [previewUrl, setPreviewUrl] = useState(null); + // Incremented on every handleFilesChange so useEffect re-runs even when count stays the same + const [filesVersion, setFilesVersion] = useState(0); + const [isUploading, setIsUploading] = useState(false); + const [overallProgress, setOverallProgress] = useState<{ current: number; total: number } | null>(null); + const [selectionError, setSelectionError] = useState(); + + // Create and clean up the object URL for the current preview. + // filesVersion ensures this re-runs when the same-count file set is replaced. + useEffect(() => { + const currentFile = fileEntries[previewIndex]?.file; + if (!currentFile) { + setPreviewUrl(null); return; } + const url = URL.createObjectURL(currentFile); + setPreviewUrl(url); + return () => URL.revokeObjectURL(url); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [previewIndex, filesVersion]); - // Use the filename as the title - const title = assetFile.value.name.split(".")[0]; + const updateFileStatus = ( + index: number, + status: FileEntryStatus, + errorMessage?: string + ) => { + setFileEntries((prev) => + prev.map((entry, i) => + i === index ? { ...entry, status, errorMessage } : entry + ) + ); + }; - uploadImage({ - title: title, - assetFile: assetFile.value, - progressCallback: onUploadProgress, + const removeFile = (index: number) => { + setFileEntries((prev) => { + const next = prev.filter((_, i) => i !== index); + // Compute new index inside the updater so it's always based on the post-remove length + setPreviewIndex((prevIdx) => Math.min(prevIdx, Math.max(0, next.length - 1))); + return next; }); }; + const retrySingleFile = async (index: number) => { + const entry = fileEntries[index]; + if (!entry || entry.status === "uploading") return; + updateFileStatus(index, "uploading"); + await uploadImage({ + title: entry.file.name.slice(0, entry.file.name.lastIndexOf(".")), + assetFile: entry.file, + progressCallback: (state) => { + if (state.status === UploaderStates.success) { + updateFileStatus(index, "success"); + } else if (state.status === UploaderStates.assetError) { + updateFileStatus(index, "error", state.errorMessage); + } + }, + }); + }; + + const handleFilesChange = (files: File[]) => { + setFileEntries(files.map((f) => ({ file: f, status: "idle" }))); + setPreviewIndex(0); + setFilesVersion((v) => v + 1); + setSelectionError(undefined); + setOverallProgress(null); + setIsUploading(false); + }; + + const handleSubmit = () => { + if (fileEntries.length === 0) { + setSelectionError("Please select an image to upload."); + return; + } + + // Only upload files that haven't already succeeded + const files = fileEntries + .filter((e) => e.status !== "success") + .map((e) => e.file); + const originalIndices = fileEntries + .map((e, i) => (e.status !== "success" ? i : -1)) + .filter((i) => i !== -1); + + if (files.length === 1 && fileEntries.length === 1) { + // Single file: use parent modal state machine (shows loading spinner) + uploadImage({ + title: files[0].name.slice(0, files[0].name.lastIndexOf(".")), + assetFile: files[0], + progressCallback: onUploadProgress, + }); + return; + } + + // Multi-file: stay on form, show inline progress + setIsUploading(true); + setOverallProgress({ current: 0, total: files.length }); + + uploadImagesBatch({ + files, + onFileStatusChange: (batchIndex, status, errorMessage) => + updateFileStatus(originalIndices[batchIndex], status, errorMessage), + onOverallProgress: (completed, total) => + setOverallProgress({ current: completed, total }), + onComplete: (allSucceeded, anySucceeded) => { + setIsUploading(false); + if (allSucceeded) { + onUploadProgress({ status: UploaderStates.success }); + } else if (!anySucceeded) { + onUploadProgress({ + status: UploaderStates.assetError, + errorMessage: "All uploads failed.", + }); + } + // Partial failure: stay on form, sidebar shows which files are red + }, + }); + }; + + const retryAllFailed = () => { + const failedIndices = fileEntries + .map((e, i) => (e.status === "error" ? i : -1)) + .filter((i) => i !== -1); + if (failedIndices.length === 0) return; + + const failedFiles = failedIndices.map((i) => fileEntries[i].file); + + setIsUploading(true); + setOverallProgress({ current: 0, total: failedFiles.length }); + + uploadImagesBatch({ + files: failedFiles, + onFileStatusChange: (batchIndex, status, errorMessage) => + updateFileStatus(failedIndices[batchIndex], status, errorMessage), + onOverallProgress: (completed, total) => + setOverallProgress({ current: completed, total }), + onComplete: (allSucceeded, anySucceeded) => { + setIsUploading(false); + if (allSucceeded) { + onUploadProgress({ status: UploaderStates.success }); + } else if (!anySucceeded) { + onUploadProgress({ + status: UploaderStates.assetError, + errorMessage: "All uploads failed.", + }); + } + }, + }); + }; + + const isMulti = fileEntries.length > 1; + const anyFailed = fileEntries.some((e) => e.status === "error"); + const anyUploading = fileEntries.some((e) => e.status === "uploading"); + // Upload has started if any file has left the idle state + const hasUploadStarted = fileEntries.some((e) => e.status !== "idle"); + const allDone = + fileEntries.length > 0 && + fileEntries.every((e) => e.status === "success" || e.status === "error"); + return ( - <> -
- { - setAssetFile({ - value: file, - }); - }} - /> +
+ e.file)} + handleChange={handleFilesChange} + multiple={true} + /> - {assetFile.error && ( -
{assetFile.error}
- )} + {selectionError && ( +
{selectionError}
+ )} - {assetFile.value && ( -
- Preview + {fileEntries.length > 0 && ( + isMulti ? ( +
+ {/* File list sidebar */} +
    + {fileEntries.map((entry, i) => ( +
  • setPreviewIndex(i)} + > + + {entry.file.name.slice(0, entry.file.name.lastIndexOf("."))} + + + {entry.status === "idle" && ( + + )} + {entry.status === "uploading" && ( + + )} + {entry.status === "success" && ( + + )} + {entry.status === "error" && ( + + + + + )} + +
  • + ))} +
+ + {/* Preview + carousel navigation */} +
+
+ {previewUrl && ( + Preview + )} +
+
+ + + {previewIndex + 1} / {fileEntries.length} + + +
+
- )} + ) : ( + previewUrl && ( +
+ Preview +
+ ) + ) + )} -
- + {!isUploading && !anyUploading && allDone && anyFailed && isMulti && ( + - -
+ )}
- +
); }; diff --git a/frontend/apps/artcraft/app/src/components/reusable/UploadModalImage/UploadModalImage.tsx b/frontend/apps/artcraft/app/src/components/reusable/UploadModalImage/UploadModalImage.tsx index d035612c8d..ee31dff7a3 100644 --- a/frontend/apps/artcraft/app/src/components/reusable/UploadModalImage/UploadModalImage.tsx +++ b/frontend/apps/artcraft/app/src/components/reusable/UploadModalImage/UploadModalImage.tsx @@ -76,13 +76,19 @@ export function UploadModalImage(props: Props) { ); case UploaderStates.uploadingAsset: case UploaderStates.uploadingCover: - case UploaderStates.settingCover: + case UploaderStates.settingCover: { + const p = uploaderState.uploadProgress; return ( <> -
Uploading...
+
+ {p && p.total > 1 + ? `Uploading ${p.current} / ${p.total}...` + : "Uploading..."} +
); + } case UploaderStates.success: { return ( void; + onOverallProgress: (completed: number, total: number) => void; + onComplete: (allSucceeded: boolean, anySucceeded: boolean) => void; +}) => { + // JS is single-threaded; these counters are safe across concurrent async callbacks. + let completedCount = 0; + let successCount = 0; + const completedIndices = new Set(); + + files.forEach((_, i) => onFileStatusChange(i, "uploading")); + onOverallProgress(0, files.length); + + try { + await Promise.all( + files.map((file, i) => + uploadImage({ + title: file.name.slice(0, file.name.lastIndexOf(".")), + assetFile: file, + progressCallback: (state) => { + if (state.status === UploaderStates.success) { + successCount++; + onFileStatusChange(i, "success"); + } else if (state.status === UploaderStates.assetError) { + onFileStatusChange(i, "error", state.errorMessage); + } + + if ( + state.status === UploaderStates.success || + state.status === UploaderStates.assetError + ) { + completedIndices.add(i); + completedCount++; + onOverallProgress(completedCount, files.length); + } + }, + }) + ) + ); + } catch (err) { + // Only mark files that haven't already reached a terminal state + files.forEach((_, i) => { + if (!completedIndices.has(i)) { + onFileStatusChange(i, "error", err instanceof Error ? err.message : "Unexpected error"); + } + }); + } + + onComplete(successCount === files.length, successCount > 0); +}; diff --git a/frontend/apps/artcraft/app/src/components/reusable/UploadModalSplat/UploadFilesSplat.tsx b/frontend/apps/artcraft/app/src/components/reusable/UploadModalSplat/UploadFilesSplat.tsx index dda59ad79e..5994408a61 100644 --- a/frontend/apps/artcraft/app/src/components/reusable/UploadModalSplat/UploadFilesSplat.tsx +++ b/frontend/apps/artcraft/app/src/components/reusable/UploadModalSplat/UploadFilesSplat.tsx @@ -1,20 +1,38 @@ -import { useCallback, useEffect, useState, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ListDropdown } from "@storyteller/ui-list-dropdown"; import { Button } from "@storyteller/ui-button"; import { FileUploader } from "@storyteller/ui-file-uploader"; -import { UploaderState } from "../../../models"; -import { FilterEngineCategories, MediaFileAnimationType, UploaderStates } from "../../../enums"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faCube } from "@fortawesome/pro-solid-svg-icons"; -import { loadPreviewOnCanvas, snapshotCanvasAsThumbnail } from "../UploadModal3D/utilities"; +import { + faCube, + faXmark, + faCheck, + faCircleExclamation, + faChevronLeft, + faChevronRight, + faRotateRight, + faSpinner, +} from "@fortawesome/pro-solid-svg-icons"; import { WebGLRenderer } from "three"; +import { loadPreviewOnCanvas, snapshotCanvasAsThumbnail } from "../UploadModal3D/utilities"; import { upload3DObjects } from "../UploadModal3D/utilities/upload3DObjects"; +import { upload3DObjectsBatch } from "../UploadModal3D/utilities/upload3DObjectsBatch"; +import { FileEntryStatus } from "../UploadModalImage/utilities/uploadImagesBatch"; +import type { UploaderState } from "../../../models"; +import { FilterEngineCategories, MediaFileAnimationType, UploaderStates } from "../../../enums"; + +interface FileEntry { + file: File; + status: FileEntryStatus; + errorMessage?: string; +} interface Props { title: string; fileTypes: string[]; engineCategory: FilterEngineCategories; initialFile?: File; + initialFiles?: File[]; options?: { fileSubtypes?: { [key: string]: string }[]; hasLength?: boolean; @@ -22,17 +40,17 @@ interface Props { }; onClose: () => void; onUploadProgress: (newState: UploaderState) => void; - onLocalBytes: (bytes: ArrayBuffer, shouldFlip) => void; + onLocalBytes: (bytes: ArrayBuffer, shouldFlip: boolean) => void; } export const UploadFilesSplat = ({ fileTypes, engineCategory, initialFile, + initialFiles, options, onClose, onUploadProgress, - onLocalBytes, }: Props) => { const canvasRef = useRef(undefined); const canvasCallbackRef = useCallback((node: HTMLCanvasElement) => { @@ -40,167 +58,430 @@ export const UploadFilesSplat = ({ canvasRef.current = node; } }, []); + const rendererRef = useRef(null); const fileSubtypes = options?.fileSubtypes; - // const hasLength = options?.hasLength; - // const hasThumbnailUpload = options?.hasThumbnailUpload; const [subtype, setSubtype] = useState( fileSubtypes ? (Object.values(fileSubtypes[0])[0] as MediaFileAnimationType) - : undefined, + : undefined ); - const [assetFile, setAssetFile] = useState<{ - value: File | null; - error?: string; - }>({ value: initialFile ?? null }); + const seedFiles = initialFiles ?? (initialFile ? [initialFile] : []); - const [previewStatus, setPreviewStatus] = useState<{ - type: string; - message?: string; - }>({ type: "init" }); - - const [thumbnailFile, setThumbnailFile] = useState( - undefined, + const [fileEntries, setFileEntries] = useState( + seedFiles.map((f) => ({ file: f, status: "idle" })) ); + const [previewIndex, setPreviewIndex] = useState(0); + // Incremented on every handleFilesChange so useEffect re-runs even when count stays the same + const [filesVersion, setFilesVersion] = useState(0); + const [previewStatus, setPreviewStatus] = useState<{ type: string; message?: string }>({ type: "init" }); + const [thumbnails, setThumbnails] = useState>(new Map()); + const [isUploading, setIsUploading] = useState(false); + const [overallProgress, setOverallProgress] = useState<{ current: number; total: number } | null>(null); + const [selectionError, setSelectionError] = useState(); - const handleSubmit = () => { - if (!assetFile.value) { - setAssetFile((curr) => ({ - ...curr, - error: "Please select a file to upload.", - })); - return; + const disposeRenderer = () => { + if (rendererRef.current) { + rendererRef.current.setAnimationLoop(null); + rendererRef.current.dispose(); + rendererRef.current = null; } - - // Use the filename as the title - const title = assetFile.value.name.split(".")[0]; - - // const shouldFlip = title.search("ceramic") != -1; - // - // assetFile.value.arrayBuffer().then((arrayBuffer) => { - // onLocalBytes(arrayBuffer, shouldFlip); - // }).catch((error) => { - // console.error("Error reading file as ArrayBuffer:", error); - // }); - - upload3DObjects({ - title: title, - assetFile: assetFile.value, - thumbnailSnapshot: thumbnailFile, - engineCategory: engineCategory, - animationType: subtype, - progressCallback: onUploadProgress, - }); }; + // Reload canvas preview whenever the current file or index changes. + // filesVersion ensures this re-runs when the same-count file set is replaced. useEffect(() => { - // Reset subtype when fileSubtypes set changes (e.g., toggling character) - if (!fileSubtypes || fileSubtypes.length === 0) { - setSubtype(undefined); - return; - } - const nextDefault = Object.values(fileSubtypes[0])[0] as - | MediaFileAnimationType - | undefined; - setSubtype(nextDefault); - }, [fileSubtypes]); + const currentFile = fileEntries[previewIndex]?.file; + if (!canvasRef.current || !currentFile) return; - useEffect(() => { - let renderer: WebGLRenderer | null = null; + disposeRenderer(); + setPreviewStatus({ type: "init" }); - if (canvasRef.current && assetFile.value) { - renderer = loadPreviewOnCanvas({ - file: assetFile.value, - canvas: canvasRef.current, - statusCallback: (statusObject: { type: string; message?: string }) => { - setPreviewStatus(statusObject); - }, - }).renderer; - } + const { renderer } = loadPreviewOnCanvas({ + file: currentFile, + canvas: canvasRef.current, + statusCallback: setPreviewStatus, + }); + rendererRef.current = renderer; - return () => { - renderer?.setAnimationLoop(null); - renderer?.dispose(); - } - }, [assetFile.value]); + return disposeRenderer; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [previewIndex, filesVersion]); + // Snapshot thumbnail when preview finishes loading + // Defer one animation frame so the splat renderer has produced a visible frame useEffect(() => { if (previewStatus.type === "OK" && canvasRef.current) { const canvas = canvasRef.current; - // Defer one animation frame so the splat renderer has produced a visible frame + const currentFile = fileEntries[previewIndex]?.file; const rafId = requestAnimationFrame(() => { snapshotCanvasAsThumbnail({ targetNode: canvas, - resultCallback: (snapshotBlob) => { - if (snapshotBlob) { - setThumbnailFile(snapshotBlob); + resultCallback: (blob) => { + if (blob && currentFile) { + setThumbnails((prev) => new Map(prev).set(currentFile, blob)); } }, }); }); return () => cancelAnimationFrame(rafId); } - }, [previewStatus]); + }, [previewStatus, previewIndex]); + + // Reset subtype when fileSubtypes set changes + useEffect(() => { + if (!fileSubtypes || fileSubtypes.length === 0) { + setSubtype(undefined); + return; + } + setSubtype(Object.values(fileSubtypes[0])[0] as MediaFileAnimationType | undefined); + }, [fileSubtypes]); + + const updateFileStatus = ( + index: number, + status: FileEntryStatus, + errorMessage?: string + ) => { + setFileEntries((prev) => + prev.map((entry, i) => + i === index ? { ...entry, status, errorMessage } : entry + ) + ); + }; + + const removeFile = (index: number) => { + setFileEntries((prev) => { + const next = prev.filter((_, i) => i !== index); + setPreviewIndex((prevIdx) => Math.min(prevIdx, Math.max(0, next.length - 1))); + return next; + }); + }; + + const retrySingleFile = async (index: number) => { + const entry = fileEntries[index]; + if (!entry || entry.status === "uploading") return; + updateFileStatus(index, "uploading"); + await upload3DObjects({ + title: entry.file.name.slice(0, entry.file.name.lastIndexOf(".")), + assetFile: entry.file, + engineCategory, + animationType: subtype, + thumbnailSnapshot: thumbnails.get(entry.file), + progressCallback: (state) => { + if (state.status === UploaderStates.success) { + updateFileStatus(index, "success"); + } else if ( + state.status === UploaderStates.assetError || + state.status === UploaderStates.coverCreateError || + state.status === UploaderStates.coverSetError + ) { + updateFileStatus(index, "error", state.errorMessage); + } + }, + }); + }; + + const handleFilesChange = (files: File[]) => { + setFileEntries(files.map((f) => ({ file: f, status: "idle" }))); + setPreviewIndex(0); + setFilesVersion((v) => v + 1); + setThumbnails(new Map()); + setSelectionError(undefined); + setOverallProgress(null); + setIsUploading(false); + }; + + const handleSubmit = () => { + if (fileEntries.length === 0) { + setSelectionError("Please select a file to upload."); + return; + } + + // Only upload files that haven't already succeeded + const pendingEntries = fileEntries + .map((e, i) => ({ entry: e, originalIndex: i })) + .filter(({ entry }) => entry.status !== "success"); + const files = pendingEntries.map(({ entry }) => entry.file); + const originalIndices = pendingEntries.map(({ originalIndex }) => originalIndex); + const pendingThumbnails = new Map( + files + .map((file): [File, Blob | undefined] => [file, thumbnails.get(file)]) + .filter((pair): pair is [File, Blob] => pair[1] !== undefined) + ); + + if (files.length === 1 && fileEntries.length === 1) { + // Single file: use parent modal state machine (shows loading spinner) + upload3DObjects({ + title: files[0].name.slice(0, files[0].name.lastIndexOf(".")), + assetFile: files[0], + engineCategory, + animationType: subtype, + thumbnailSnapshot: thumbnails.get(files[0]), + progressCallback: onUploadProgress, + }); + return; + } + + // Multi-file: stay on form, show inline progress + setIsUploading(true); + setOverallProgress({ current: 0, total: files.length }); + + upload3DObjectsBatch({ + files, + thumbnails: pendingThumbnails, + engineCategory, + animationType: subtype, + onFileStatusChange: (batchIndex, status, errorMessage) => + updateFileStatus(originalIndices[batchIndex], status, errorMessage), + onOverallProgress: (completed, total) => + setOverallProgress({ current: completed, total }), + onComplete: (allSucceeded, anySucceeded) => { + setIsUploading(false); + if (allSucceeded) { + onUploadProgress({ status: UploaderStates.success }); + } else if (!anySucceeded) { + onUploadProgress({ + status: UploaderStates.assetError, + errorMessage: "All uploads failed.", + }); + } + }, + }); + }; + + const retryAllFailed = () => { + const failedIndices = fileEntries + .map((e, i) => (e.status === "error" ? i : -1)) + .filter((i) => i !== -1); + if (failedIndices.length === 0) return; + + const failedFiles = failedIndices + .map((i) => fileEntries[i].file) + .filter((file) => file !== undefined); + const failedThumbnails = new Map( + failedFiles + .map((file): [File, Blob | undefined] => [file, thumbnails.get(file) ?? undefined]) + .filter((pair): pair is [File, Blob] => pair[1] !== undefined) + ); + + setIsUploading(true); + setOverallProgress({ current: 0, total: failedFiles.length }); + + upload3DObjectsBatch({ + files: failedFiles, + thumbnails: failedThumbnails, + engineCategory, + animationType: subtype, + onFileStatusChange: (batchIndex, status, errorMessage) => + updateFileStatus(failedIndices[batchIndex], status, errorMessage), + onOverallProgress: (completed, total) => + setOverallProgress({ current: completed, total }), + onComplete: (allSucceeded, anySucceeded) => { + setIsUploading(false); + if (allSucceeded) { + onUploadProgress({ status: UploaderStates.success }); + } else if (!anySucceeded) { + onUploadProgress({ + status: UploaderStates.assetError, + errorMessage: "All uploads failed.", + }); + } + }, + }); + }; + + const isMulti = fileEntries.length > 1; + const anyFailed = fileEntries.some((e) => e.status === "error"); + const anyUploading = fileEntries.some((e) => e.status === "uploading"); + const hasUploadStarted = fileEntries.some((e) => e.status !== "idle"); + const allDone = + fileEntries.length > 0 && + fileEntries.every((e) => e.status === "success" || e.status === "error"); + const currentFile = fileEntries[previewIndex]?.file; return ( - <> -
- { - setAssetFile({ - value: file, - }); - }} +
+ {fileSubtypes && fileSubtypes.length > 1 && ( + setSubtype(value as MediaFileAnimationType)} /> - {fileSubtypes && fileSubtypes.length > 1 && ( - setSubtype(value as MediaFileAnimationType)} - /> - )} - {assetFile.error && ( -
{assetFile.error}
- )} + )} + e.file)} + handleChange={handleFilesChange} + multiple={true} + /> + + {selectionError && ( +
{selectionError}
+ )} + + {isMulti ? ( +
+ {/* File list sidebar */} +
    + {fileEntries.map((entry, i) => ( +
  • setPreviewIndex(i)} + > + + {entry.file.name.slice(0, entry.file.name.lastIndexOf("."))} + + + {entry.status === "idle" && ( + + )} + {entry.status === "uploading" && ( + + )} + {entry.status === "success" && ( + + )} + {entry.status === "error" && ( + + + + + )} + +
  • + ))} +
+ + {/* Canvas preview + carousel */} +
+
+ + {!currentFile && ( +
+ + Your model preview will appear here +
+ )} + {previewStatus.type.includes("Error") && ( +
+ {previewStatus.type} + {previewStatus.message &&
} + {previewStatus.message} +
+ )} +
+
+ + + {previewIndex + 1} / {fileEntries.length} + + +
+
+
+ ) : ( + /* Single-file layout: canvas takes full width (original behaviour) */
- {!assetFile.value && ( + {!currentFile && (
Your model preview will appear here
)} {previewStatus.type.includes("Error") && ( - <> -
- {previewStatus.type} - {previewStatus.message &&
} - {previewStatus.message} -
- +
+ {previewStatus.type} + {previewStatus.message &&
} + {previewStatus.message} +
)}
+ )} -
- + {!isUploading && !anyUploading && allDone && anyFailed && isMulti && ( + + )} + {/* Hide Upload once any upload has started; only Retry Failed or Close remains */} + {!hasUploadStarted && ( -
+ )}
- +
); }; diff --git a/frontend/apps/artcraft/app/src/components/reusable/UploadModalSplat/UploadModalSplat.tsx b/frontend/apps/artcraft/app/src/components/reusable/UploadModalSplat/UploadModalSplat.tsx index 80fb1ad659..9c3925f0ea 100644 --- a/frontend/apps/artcraft/app/src/components/reusable/UploadModalSplat/UploadModalSplat.tsx +++ b/frontend/apps/artcraft/app/src/components/reusable/UploadModalSplat/UploadModalSplat.tsx @@ -85,13 +85,19 @@ export function UploadModalSplat(props: Props) { ); case UploaderStates.uploadingAsset: case UploaderStates.uploadingCover: - case UploaderStates.settingCover: + case UploaderStates.settingCover: { + const p = uploaderState.uploadProgress; return ( <> -
Uploading...
+
+ {p && p.total > 1 + ? `Uploading ${p.current} / ${p.total}...` + : "Uploading..."} +
); + } case UploaderStates.success: { return ( { - if (!file) { + if (files.length === 0) { return (
@@ -25,24 +25,39 @@ export const DragAndDropZone = ({ ); } - const fileName = file.name.split(".")[0].toUpperCase(); - const fileSize = - file.size >= 1024 * 1024 - ? (file.size / 1024 / 1024).toFixed(2) + " MB" - : `${Math.floor(file.size / 1024)} KB`; + if (files.length === 1) { + const file = files[0]; + const fileName = file.name.split(".")[0].toUpperCase(); + const fileSize = + file.size >= 1024 * 1024 + ? (file.size / 1024 / 1024).toFixed(2) + " MB" + : `${Math.floor(file.size / 1024)} KB`; + + return ( +
+ +
+

+ {file.name.slice(0, file.name.lastIndexOf("."))} +

+

+ + {`${fileName} file size: ${fileSize} `} + + Change File +

+
+
+ ); + } return (
-

- {file.name.slice(0, file.name.lastIndexOf("."))} -

+

{files.length} files selected

- - {`${fileName} file size: ${fileSize} `} - - Change File + Change Files

diff --git a/frontend/libs/components/file-uploader/src/lib/file-uploader.tsx b/frontend/libs/components/file-uploader/src/lib/file-uploader.tsx index 55df05731e..4abcf162a5 100644 --- a/frontend/libs/components/file-uploader/src/lib/file-uploader.tsx +++ b/frontend/libs/components/file-uploader/src/lib/file-uploader.tsx @@ -4,20 +4,33 @@ import { FileUploader as DragDropFileUploader } from "react-drag-drop-files"; import { DragAndDropZone } from "./drag-and-drop-zone"; export const FileUploader = ({ - file, + files, fileTypes, handleChange, + multiple = false, }: { - file: File | undefined; - handleChange: (file: File) => void; + files: File[]; + handleChange: (files: File[]) => void; fileTypes: string[]; + multiple?: boolean; }) => ( { + if (Array.isArray(result)) { + handleChange(result); + } else if (result instanceof File) { + handleChange([result]); + } else { + // react-drag-drop-files may return a FileList from the file picker when multiple=true; + // Array.isArray returns false for FileList, so normalize via Array.from. + handleChange(Array.from(result as unknown as Iterable)); + } + }} name="file" maxSize={50} types={fileTypes} + multiple={multiple} > - + );