From bce8d166e08c59ae3e07c6ee3950daec87179560 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 9 Apr 2026 09:07:21 -0700 Subject: [PATCH] Feature: Multifile upload (#1294) Notable behavior: - Mixing supported and/or unsupported file types will only keep files of file type that match the first supported file type in the list received from browser (e.g. PNG, OBJ, EXE, JPG, OBJ order selection of files will only show PNG, JPG for image upload) - Removing files until only one is left will result in the sidebar vanishing and only a single preview showing. More files can be added by selecting multiple files again through the file selection button - Failure to upload has been handled but not tested. It is expected any failed uploads could be retried with a "retry failed" button. Any individual fail can also be retried via a retry button upon hovering on the exclamation mark by the failed file's name - All multi-file uploads are queued async and likely wouldn't be ordered as shown in the preview list (first-come-first-serve) - Potentially deadlocks if a task never responds. Likely handled by backend timeouts. This deadlock state is only partial and not lossy as successfully uploaded files will appear in gallery even if the task is cancelled. Cancellation can be attempted at any point during the upload. Uploads shown in progress may be successful after cancellation depending on the internal upload progress. - TopBar upload images button now opens the image modal instead for consistency and same-component use. --------- Co-authored-by: bflatastic --- .../GlobalFileDropHandler.tsx | 90 ++- .../reusable/UploadModal3D/UploadFiles3D.tsx | 546 ++++++++++++++---- .../reusable/UploadModal3D/UploadModal3D.tsx | 16 +- .../utilities/loadPreviewOnCanvas.ts | 3 +- .../utilities/upload3DObjectsBatch.ts | 89 +++ .../UploadModalImage/UploadFilesImage.tsx | 391 +++++++++++-- .../UploadModalImage/UploadModalImage.tsx | 16 +- .../utilities/uploadImagesBatch.ts | 63 ++ .../UploadModalSplat/UploadFilesSplat.tsx | 544 +++++++++++++---- .../UploadModalSplat/UploadModalSplat.tsx | 16 +- .../signaled/TopBar/UploadImagesButton.tsx | 89 +-- .../artcraft/app/src/models/UploaderState.ts | 1 + .../app/src/pages/PageEnigma/PageEnigma.tsx | 2 +- frontend/apps/artcraft/package.json | 1 + .../src/lib/drag-and-drop-zone.tsx | 52 +- .../file-uploader/src/lib/file-uploader.tsx | 27 +- 16 files changed, 1543 insertions(+), 403 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/GlobalFileDropHandler/GlobalFileDropHandler.tsx b/frontend/apps/artcraft/app/src/components/GlobalFileDropHandler/GlobalFileDropHandler.tsx index c818031099..bf31fcc31e 100644 --- a/frontend/apps/artcraft/app/src/components/GlobalFileDropHandler/GlobalFileDropHandler.tsx +++ b/frontend/apps/artcraft/app/src/components/GlobalFileDropHandler/GlobalFileDropHandler.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { toast } from "react-hot-toast"; import { faCube, faImages, faUpRightAndDownLeftFromCenter } from "@fortawesome/pro-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { UploadModal3D } from "../reusable/UploadModal3D/UploadModal3D"; @@ -26,11 +27,11 @@ export function GlobalFileDropHandler() { const dragCounter = useRef(0); const [isDragging, setIsDragging] = useState(false); const [modalType, setModalType] = useState(null); - const [pendingFile, setPendingFile] = useState(null); + const [pendingFiles, setPendingFiles] = useState([]); const closeModal = () => { setModalType(null); - setPendingFile(null); + setPendingFiles([]); }; useEffect(() => { @@ -54,22 +55,44 @@ export function GlobalFileDropHandler() { setIsDragging(false); if (isAnyModalOpen()) return; - const path = payload.paths[0]; - if (!path) return; + if (payload.paths.length === 0) return; - const fileName = path.split(/[/\\]/).pop() ?? "file"; - const type = getModalTypeForFileName(fileName); - if (type === null) return; + // Find first path with a recognised extension + let modalKind: ModalType = null; + for (const p of payload.paths) { + modalKind = getModalTypeForFileName(p.split(/[/\\]/).pop() ?? ""); + if (modalKind) break; + } + if (!modalKind) return; + + // Keep only paths matching the detected modal type + const matchingPaths = payload.paths.filter((p) => { + const name = p.split(/[/\\]/).pop() ?? ""; + return getModalTypeForFileName(name) === modalKind; + }); + + const skippedCount = payload.paths.length - matchingPaths.length; + if (skippedCount > 0) { + toast( + `${skippedCount} file${skippedCount > 1 ? "s" : ""} skipped — unsupported or mixed types`, + { icon: "⚠️" } + ); + } try { - const assetUrl = convertFileSrc(path); - const response = await fetch(assetUrl); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const blob = await response.blob(); - const file = new File([blob], fileName); + const files = await Promise.all( + matchingPaths.map(async (path) => { + const fileName = path.split(/[/\\]/).pop() ?? "file"; + const assetUrl = convertFileSrc(path); + const response = await fetch(assetUrl); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + return new File([blob], fileName); + }) + ); await appWindow.setFocus(); - setModalType(type); - setPendingFile(file); + setModalType(modalKind); + setPendingFiles(files); } catch (err) { console.error("[DragDrop] file read failed:", err); } @@ -111,12 +134,33 @@ export function GlobalFileDropHandler() { setIsDragging(false); dragCounter.current = 0; if (isAnyModalOpen()) return; - const file = e.dataTransfer?.files[0]; - if (!file) return; - const type = getModalTypeForFileName(file.name); - if (type === null) return; - setModalType(type); - setPendingFile(file); + + const allFiles = Array.from(e.dataTransfer.files); + if (allFiles.length === 0) return; + + // Find first file with a recognised extension + let modalKind: ModalType = null; + for (const f of allFiles) { + modalKind = getModalTypeForFileName(f.name); + if (modalKind) break; + } + if (!modalKind) return; + + // Keep only files matching the detected modal type + const matchingFiles = allFiles.filter( + (f) => getModalTypeForFileName(f.name) === modalKind + ); + + const skippedCount = allFiles.length - matchingFiles.length; + if (skippedCount > 0) { + toast( + `${skippedCount} file${skippedCount > 1 ? "s" : ""} skipped — unsupported or mixed types`, + { icon: "⚠️" } + ); + } + + setModalType(modalKind); + setPendingFiles(matchingFiles); }; window.addEventListener("dragenter", handleDragEnter); window.addEventListener("dragleave", handleDragLeave); @@ -144,7 +188,7 @@ export function GlobalFileDropHandler() { )} 0 ? pendingFiles : undefined} onClose={closeModal} onSuccess={(_category: FilterEngineCategories) => closeModal()} title="Upload a 3D Model" @@ -152,7 +196,7 @@ export function GlobalFileDropHandler() { /> 0 ? pendingFiles : undefined} onClose={closeModal} onSuccess={() => closeModal()} title="Upload an Image" @@ -160,7 +204,7 @@ export function GlobalFileDropHandler() { /> 0 ? pendingFiles : undefined} onClose={closeModal} onSuccess={() => {}} title="Upload a Splat" 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..793e215c90 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 { Group as PanelGroup, Panel, Separator as PanelResizeHandle } from "react-resizable-panels"; 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; @@ -26,21 +49,39 @@ interface Props { export const UploadFiles3D = ({ fileTypes, engineCategory, - initialFile, + initialFiles, options, onClose, onUploadProgress, }: Props) => { const canvasRef = useRef(undefined); - const canvasCallbackRef = useCallback((node: HTMLCanvasElement) => { + const rendererRef = useRef(null); + const cameraRef = useRef(null); + const resizeObserverRef = useRef(null); + const canvasCallbackRef = useCallback((node: HTMLCanvasElement | null) => { + if (resizeObserverRef.current) { + resizeObserverRef.current.disconnect(); + resizeObserverRef.current = null; + } if (node !== null) { canvasRef.current = node; + const observer = new ResizeObserver(() => { + const renderer = rendererRef.current; + const camera = cameraRef.current; + if (!renderer || !camera) return; + const w = node.clientWidth; + const h = node.clientHeight; + if (w === 0 || h === 0) return; + renderer.setSize(w, h, false); + camera.aspect = w / h; + camera.updateProjectionMatrix(); + }); + observer.observe(node); + resizeObserverRef.current = observer; } }, []); const fileSubtypes = options?.fileSubtypes; - // const hasLength = options?.hasLength; - // const hasThumbnailUpload = options?.hasThumbnailUpload; const [subtype, setSubtype] = useState( fileSubtypes @@ -48,136 +89,445 @@ export const UploadFiles3D = ({ : undefined, ); - const [assetFile, setAssetFile] = useState<{ - value: File | null; - error?: string; - }>({ value: initialFile ?? null }); + const seedFiles = initialFiles ?? []; + 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, camera } = loadPreviewOnCanvas({ + file: currentFile, + canvas: canvasRef.current, + statusCallback: setPreviewStatus, + }); + rendererRef.current = renderer; + cameraRef.current = camera; + + 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; + }); + setFilesVersion((v) => v + 1); + }; + + 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} + fileIcon={faCube} + /> + + {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" && ( + + + + + )} + +
  • + ))} +
+
+ + e.stopPropagation()}> +
+ + + {/* 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..d72ebfb6d0 100644 --- a/frontend/apps/artcraft/app/src/components/reusable/UploadModal3D/UploadModal3D.tsx +++ b/frontend/apps/artcraft/app/src/components/reusable/UploadModal3D/UploadModal3D.tsx @@ -22,7 +22,7 @@ interface Props { isOpen: boolean; title: string; titleIcon: IconDefinition; - initialFile?: File; + initialFiles?: File[]; options?: { fileSubtypes?: { [key: string]: string }[]; hasLength?: boolean; @@ -33,7 +33,7 @@ interface Props { const objectFileTypes = Object.values(OBJECT_FILE_TYPE); export function UploadModal3D(props: Props) { - const { isOpen, onClose, onSuccess, title, titleIcon, initialFile, options } = props; + const { isOpen, onClose, onSuccess, title, titleIcon, initialFiles, options } = props; const [uploaderState, setUploaderState] = useState(initialUploaderState); const [isCharacter, setIsCharacter] = useState(false); @@ -102,7 +102,7 @@ export function UploadModal3D(props: Props) { title={title} engineCategory={selectedCategory} fileTypes={objectFileTypes} - initialFile={initialFile} + initialFiles={initialFiles} options={{ ...(options ?? {}), fileSubtypes: characterAnimationOptions, @@ -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 + ) { + if (!completedIndices.has(i)) { + 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..88f7b2e8c3 100644 --- a/frontend/apps/artcraft/app/src/components/reusable/UploadModalImage/UploadFilesImage.tsx +++ b/frontend/apps/artcraft/app/src/components/reusable/UploadModalImage/UploadFilesImage.tsx @@ -1,83 +1,376 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { Group as PanelGroup, Panel, Separator as PanelResizeHandle } from "react-resizable-panels"; 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; } export const UploadFilesImage = ({ fileTypes, - initialFile, + initialFiles, onClose, onUploadProgress, }: Props) => { - const [assetFile, setAssetFile] = useState<{ - value: File | null; - error?: string; - }>({ value: initialFile ?? null }); + const seedFiles = initialFiles ?? []; - 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; + }); + setFilesVersion((v) => v + 1); + }; + + 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" && ( + + + + + )} + +
  • + ))} +
+
-
- + + {previewIndex + 1} / {fileEntries.length} + + +
+
+ + + ) : ( + previewUrl && ( +
+ Preview +
+ ) + ) + )} + + {(isUploading || anyUploading) && overallProgress && isMulti && ( +

+ Uploading {overallProgress.current} / {overallProgress.total}... +

+ )} + + {!isUploading && !anyUploading && allDone && anyFailed && isMulti && ( +

+ {fileEntries.filter((e) => e.status === "error").length} file(s) failed to upload. +

+ )} + +
+ + {!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..df25115a24 100644 --- a/frontend/apps/artcraft/app/src/components/reusable/UploadModalImage/UploadModalImage.tsx +++ b/frontend/apps/artcraft/app/src/components/reusable/UploadModalImage/UploadModalImage.tsx @@ -21,13 +21,13 @@ interface Props { isOpen: boolean; title: string; titleIcon: IconDefinition; - initialFile?: File; + initialFiles?: File[]; } const imageFileTypes = Object.values(IMAGEPLANE_FILE_TYPE); export function UploadModalImage(props: Props) { - const { isOpen, onClose, onSuccess, title, titleIcon, initialFile } = props; + const { isOpen, onClose, onSuccess, title, titleIcon, initialFiles } = props; const [uploaderState, setUploaderState] = useState(initialUploaderState); @@ -68,7 +68,7 @@ export function UploadModalImage(props: Props) { @@ -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 + ) { + if (!completedIndices.has(i)) { + 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..ee2d797bd3 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 { Group as PanelGroup, Panel, Separator as PanelResizeHandle } from "react-resizable-panels"; 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 { + faCube, + faXmark, + faCheck, + faCircleExclamation, + faChevronLeft, + faChevronRight, + faRotateRight, + faSpinner, +} from "@fortawesome/pro-solid-svg-icons"; +import { WebGLRenderer, PerspectiveCamera } from "three"; import { loadPreviewOnCanvas, snapshotCanvasAsThumbnail } from "../UploadModal3D/utilities"; -import { WebGLRenderer } from "three"; 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,185 +40,477 @@ 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) => { + const rendererRef = useRef(null); + const cameraRef = useRef(null); + const resizeObserverRef = useRef(null); + const canvasCallbackRef = useCallback((node: HTMLCanvasElement | null) => { + if (resizeObserverRef.current) { + resizeObserverRef.current.disconnect(); + resizeObserverRef.current = null; + } if (node !== null) { canvasRef.current = node; + const observer = new ResizeObserver(() => { + const renderer = rendererRef.current; + const camera = cameraRef.current; + if (!renderer || !camera) return; + const w = node.clientWidth; + const h = node.clientHeight; + if (w === 0 || h === 0) return; + renderer.setSize(w, h, false); + camera.aspect = w / h; + camera.updateProjectionMatrix(); + }); + observer.observe(node); + resizeObserverRef.current = observer; } }, []); 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 ?? []; - 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, camera } = loadPreviewOnCanvas({ + file: currentFile, + canvas: canvasRef.current, + statusCallback: setPreviewStatus, + }); + rendererRef.current = renderer; + cameraRef.current = camera; - 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; + }); + setFilesVersion((v) => v + 1); + }; + + 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} + fileIcon={faCube} + /> + + {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" && ( + + + + + )} + +
  • + ))} +
+
+ + e.stopPropagation()}> +
+ + + {/* 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..8e92ea7785 100644 --- a/frontend/apps/artcraft/app/src/components/reusable/UploadModalSplat/UploadModalSplat.tsx +++ b/frontend/apps/artcraft/app/src/components/reusable/UploadModalSplat/UploadModalSplat.tsx @@ -22,7 +22,7 @@ interface Props { isOpen: boolean; title: string; titleIcon: IconDefinition; - initialFile?: File; + initialFiles?: File[]; options?: { fileSubtypes?: { [key: string]: string }[]; hasLength?: boolean; @@ -34,7 +34,7 @@ const splatFileTypes = Object.values(SPLAT_FILE_TYPE); export function UploadModalSplat(props: Props) { const selectedCategory = FilterEngineCategories.SPLAT; - const { isOpen, onClose, onSuccess, title, titleIcon, initialFile, options } = props; + const { isOpen, onClose, onSuccess, title, titleIcon, initialFiles, options } = props; const [uploaderState, setUploaderState] = useState(initialUploaderState); @@ -73,7 +73,7 @@ export function UploadModalSplat(props: Props) { title={title} engineCategory={selectedCategory} fileTypes={splatFileTypes} - initialFile={initialFile} + initialFiles={initialFiles} options={{ ...(options ?? {}), }} @@ -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 ( { - const fileInputRef = useRef(null); - - const handleClick = () => fileInputRef.current?.click(); - - const handleChange = (e: React.ChangeEvent) => { - const files = e.target.files; - if (!files || files.length === 0) return; - - const filesArr = Array.from(files); - const allowedMimes = new Set(["image/jpeg", "image/png"]); - const allowedExts = new Set([".jpg", ".jpeg", ".png"]); - - const isAllowed = (file: File) => { - if (allowedMimes.has(file.type)) return true; - const dot = file.name.lastIndexOf("."); - const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : ""; - return allowedExts.has(ext); - }; - - const allowedFiles = filesArr.filter(isAllowed); - const rejected = filesArr.length - allowedFiles.length; - - if (allowedFiles.length === 0) { - toast.error("Only JPG and PNG images are supported"); - e.target.value = ""; - return; - } - - if (rejected > 0) { - toast.error( - `Skipped ${rejected} unsupported ${rejected === 1 ? "image" : "images"}`, - ); - } - - const count = allowedFiles.length; - const mediaUploadApi = new MediaUploadApi(); - - const uploads = allowedFiles.map((file) => - mediaUploadApi - .UploadImage({ - blob: file, - fileName: file.name, - uuid: uuidv4(), - maybe_title: file.name, - is_intermediate_system_file: EIntermediateFile.false, - }) - .then((res) => { - if (!res?.success) { - throw new Error(res?.errorMessage || "Upload failed"); - } - return res.data; - }), - ); - - toast.promise(Promise.all(uploads), { - loading: `Uploading ${count} ${count === 1 ? "image" : "images"}...`, - success: `Uploaded ${count} ${count === 1 ? "image" : "images"}`, - error: `Failed to upload ${count} ${count === 1 ? "image" : "images"}`, - }); - - e.target.value = ""; - }; + const [isOpen, setIsOpen] = useState(false); return ( <> -