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 ( <> -