mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
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 <bombayapirat@gmail.com>
This commit is contained in:
co-authored by
bflatastic
parent
5fc27a5153
commit
bce8d166e0
+67
-23
@@ -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<ModalType>(null);
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||
|
||||
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() {
|
||||
)}
|
||||
<UploadModal3D
|
||||
isOpen={modalType === "3d"}
|
||||
initialFile={pendingFile ?? undefined}
|
||||
initialFiles={pendingFiles.length > 0 ? pendingFiles : undefined}
|
||||
onClose={closeModal}
|
||||
onSuccess={(_category: FilterEngineCategories) => closeModal()}
|
||||
title="Upload a 3D Model"
|
||||
@@ -152,7 +196,7 @@ export function GlobalFileDropHandler() {
|
||||
/>
|
||||
<UploadModalImage
|
||||
isOpen={modalType === "image"}
|
||||
initialFile={pendingFile ?? undefined}
|
||||
initialFiles={pendingFiles.length > 0 ? pendingFiles : undefined}
|
||||
onClose={closeModal}
|
||||
onSuccess={() => closeModal()}
|
||||
title="Upload an Image"
|
||||
@@ -160,7 +204,7 @@ export function GlobalFileDropHandler() {
|
||||
/>
|
||||
<UploadModalSplat
|
||||
isOpen={modalType === "splat"}
|
||||
initialFile={pendingFile ?? undefined}
|
||||
initialFiles={pendingFiles.length > 0 ? pendingFiles : undefined}
|
||||
onClose={closeModal}
|
||||
onSuccess={() => {}}
|
||||
title="Upload a Splat"
|
||||
|
||||
+448
-98
@@ -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<HTMLCanvasElement | undefined>(undefined);
|
||||
const canvasCallbackRef = useCallback((node: HTMLCanvasElement) => {
|
||||
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
|
||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(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<MediaFileAnimationType | undefined>(
|
||||
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<FileEntry[]>(
|
||||
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<Map<File, Blob>>(new Map());
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [overallProgress, setOverallProgress] = useState<{
|
||||
current: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
const [selectionError, setSelectionError] = useState<string | undefined>();
|
||||
|
||||
const [thumbnailFile, setThumbnailFile] = useState<Blob | undefined>(
|
||||
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<File, Blob>(
|
||||
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<File, Blob>(
|
||||
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 (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<FileUploader
|
||||
fileTypes={fileTypes}
|
||||
file={assetFile.value ?? undefined}
|
||||
handleChange={(file: File) => {
|
||||
setAssetFile({
|
||||
value: file,
|
||||
});
|
||||
}}
|
||||
<div className="flex flex-col gap-3">
|
||||
{fileSubtypes && fileSubtypes.length > 1 && (
|
||||
<ListDropdown
|
||||
list={fileSubtypes}
|
||||
onSelect={(value) => setSubtype(value as MediaFileAnimationType)}
|
||||
/>
|
||||
{fileSubtypes && fileSubtypes.length > 1 && (
|
||||
<ListDropdown
|
||||
list={fileSubtypes}
|
||||
onSelect={(value) => setSubtype(value as MediaFileAnimationType)}
|
||||
/>
|
||||
)}
|
||||
{assetFile.error && (
|
||||
<h6 className="z-10 text-red">{assetFile.error}</h6>
|
||||
)}
|
||||
)}
|
||||
|
||||
<div className="relative m-auto w-full overflow-hidden rounded-lg bg-brand-secondary">
|
||||
<FileUploader
|
||||
fileTypes={fileTypes}
|
||||
files={fileEntries.map((e) => e.file)}
|
||||
handleChange={handleFilesChange}
|
||||
multiple={true}
|
||||
fileIcon={faCube}
|
||||
/>
|
||||
|
||||
{selectionError && <h6 className="z-10 text-red">{selectionError}</h6>}
|
||||
|
||||
{isMulti ? (
|
||||
<PanelGroup orientation="horizontal">
|
||||
{/* File list sidebar */}
|
||||
<Panel defaultSize="33%" minSize="20%">
|
||||
<ul className="flex h-full flex-col gap-1 overflow-y-auto rounded-lg bg-brand-secondary p-2">
|
||||
{fileEntries.map((entry, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className={`group flex cursor-pointer items-center justify-between gap-1.5 rounded px-2 py-1 text-sm transition-colors ${
|
||||
i === previewIndex ? "bg-white/10" : "hover:bg-white/5"
|
||||
}`}
|
||||
onClick={() => setPreviewIndex(i)}
|
||||
>
|
||||
<span className="flex-1 truncate" title={entry.file.name}>
|
||||
{entry.file.name.slice(0, entry.file.name.lastIndexOf("."))}
|
||||
</span>
|
||||
<span className="shrink-0">
|
||||
{entry.status === "idle" && (
|
||||
<button
|
||||
className="opacity-40 transition-opacity hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(i);
|
||||
}}
|
||||
title="Remove"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
)}
|
||||
{entry.status === "uploading" && (
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinner}
|
||||
className="animate-spin opacity-60"
|
||||
/>
|
||||
)}
|
||||
{entry.status === "success" && (
|
||||
<FontAwesomeIcon
|
||||
icon={faCheck}
|
||||
className="text-green-400"
|
||||
/>
|
||||
)}
|
||||
{entry.status === "error" && (
|
||||
<span className="flex items-center gap-1">
|
||||
<FontAwesomeIcon
|
||||
icon={faCircleExclamation}
|
||||
className="text-red-400"
|
||||
/>
|
||||
<button
|
||||
className="hidden items-center text-xs text-white/60 transition-colors hover:text-white group-hover:inline-flex"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
retrySingleFile(i);
|
||||
}}
|
||||
title="Retry"
|
||||
>
|
||||
<FontAwesomeIcon icon={faRotateRight} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
|
||||
<PanelResizeHandle className="flex w-4 items-center justify-center" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<div className="h-8 w-1 rounded-full bg-white/20 transition-colors hover:bg-white/40" />
|
||||
</PanelResizeHandle>
|
||||
|
||||
{/* Canvas preview + carousel */}
|
||||
<Panel defaultSize="67%" minSize="25%">
|
||||
<div className="flex h-full flex-col gap-2">
|
||||
<div className="relative w-full min-h-48 overflow-hidden rounded-lg bg-brand-secondary">
|
||||
<canvas
|
||||
className="pointer-events-none h-full min-h-48 !w-full"
|
||||
ref={canvasCallbackRef}
|
||||
/>
|
||||
{!currentFile && (
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 flex w-full items-center justify-center gap-2.5 text-center opacity-50">
|
||||
<FontAwesomeIcon icon={faCube} />
|
||||
Your model preview will appear here
|
||||
</h6>
|
||||
)}
|
||||
{previewStatus.type.includes("Error") && (
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 w-full text-center">
|
||||
{previewStatus.type}
|
||||
{previewStatus.message && <br />}
|
||||
{previewStatus.message}
|
||||
</h6>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setPreviewIndex((p) => Math.max(0, p - 1))}
|
||||
disabled={previewIndex === 0}
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronLeft} />
|
||||
</Button>
|
||||
<span className="text-sm opacity-60">
|
||||
{previewIndex + 1} / {fileEntries.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setPreviewIndex((p) =>
|
||||
Math.min(fileEntries.length - 1, p + 1),
|
||||
)
|
||||
}
|
||||
disabled={previewIndex === fileEntries.length - 1}
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
) : (
|
||||
/* Single-file layout: canvas takes full width (original behaviour) */
|
||||
<div className="relative m-auto w-full min-h-48 overflow-hidden rounded-lg bg-brand-secondary">
|
||||
<canvas
|
||||
className="pointer-events-none h-full !w-full"
|
||||
className="pointer-events-none h-full min-h-48 !w-full"
|
||||
ref={canvasCallbackRef}
|
||||
/>
|
||||
{!assetFile.value && (
|
||||
{!currentFile && (
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 flex w-full items-center justify-center gap-2.5 text-center opacity-50">
|
||||
<FontAwesomeIcon icon={faCube} />
|
||||
Your model preview will appear here
|
||||
</h6>
|
||||
)}
|
||||
{previewStatus.type.includes("Error") && (
|
||||
<>
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 w-full text-center">
|
||||
{previewStatus.type}
|
||||
{previewStatus.message && <br />}
|
||||
{previewStatus.message}
|
||||
</h6>
|
||||
</>
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 w-full text-center">
|
||||
{previewStatus.type}
|
||||
{previewStatus.message && <br />}
|
||||
{previewStatus.message}
|
||||
</h6>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
{(isUploading || anyUploading) && overallProgress && isMulti && (
|
||||
<p className="text-center text-sm opacity-60">
|
||||
Uploading {overallProgress.current} / {overallProgress.total}...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isUploading && !anyUploading && allDone && anyFailed && isMulti && (
|
||||
<p className="text-center text-sm text-red-400">
|
||||
{fileEntries.filter((e) => e.status === "error").length} file(s)
|
||||
failed to upload.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{!isUploading && !anyUploading && allDone && anyFailed && isMulti && (
|
||||
<Button variant="secondary" onClick={retryAllFailed}>
|
||||
Retry Failed
|
||||
</Button>
|
||||
)}
|
||||
{/* Hide Upload once any upload has started; only Retry Failed or Close remains */}
|
||||
{!hasUploadStarted && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={!assetFile.value}
|
||||
disabled={fileEntries.length === 0}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<UploaderState>(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 (
|
||||
<>
|
||||
<LoadingDots className="mb-1 bg-transparent" />
|
||||
<div className="w-100 text-center opacity-50">Uploading...</div>
|
||||
<div className="w-100 text-center opacity-50">
|
||||
{p && p.total > 1
|
||||
? `Uploading ${p.current} / ${p.total}...`
|
||||
: "Uploading..."}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case UploaderStates.success: {
|
||||
return (
|
||||
<UploadSuccess
|
||||
|
||||
+2
-1
@@ -15,6 +15,7 @@ interface LoaderInterface {
|
||||
|
||||
interface PreviewReturn {
|
||||
renderer: THREE.WebGLRenderer;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
}
|
||||
|
||||
export const loadPreviewOnCanvas = ({
|
||||
@@ -115,7 +116,7 @@ export const loadPreviewOnCanvas = ({
|
||||
};
|
||||
renderer.setAnimationLoop(animate);
|
||||
|
||||
return { renderer };
|
||||
return { renderer, camera };
|
||||
};
|
||||
|
||||
const glbLoader = ({
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
FilterEngineCategories,
|
||||
MediaFileAnimationType,
|
||||
UploaderStates,
|
||||
} from "~/enums";
|
||||
import { upload3DObjects } from "./upload3DObjects";
|
||||
import { FileEntryStatus } from "../../UploadModalImage/utilities/uploadImagesBatch";
|
||||
|
||||
export const upload3DObjectsBatch = async ({
|
||||
files,
|
||||
thumbnails,
|
||||
engineCategory,
|
||||
animationType,
|
||||
onFileStatusChange,
|
||||
onOverallProgress,
|
||||
onComplete,
|
||||
}: {
|
||||
files: File[];
|
||||
thumbnails: Map<File, Blob>;
|
||||
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<number>();
|
||||
|
||||
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);
|
||||
};
|
||||
+342
-49
@@ -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<FileEntry[]>(
|
||||
seedFiles.map((f) => ({ file: f, status: "idle" }))
|
||||
);
|
||||
const [previewIndex, setPreviewIndex] = useState(0);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(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<string | undefined>();
|
||||
|
||||
// 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 (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<FileUploader
|
||||
fileTypes={fileTypes}
|
||||
file={assetFile.value ?? undefined}
|
||||
handleChange={(file: File) => {
|
||||
setAssetFile({
|
||||
value: file,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-3">
|
||||
<FileUploader
|
||||
fileTypes={fileTypes}
|
||||
files={fileEntries.map((e) => e.file)}
|
||||
handleChange={handleFilesChange}
|
||||
multiple={true}
|
||||
/>
|
||||
|
||||
{assetFile.error && (
|
||||
<h6 className="z-10 text-red">{assetFile.error}</h6>
|
||||
)}
|
||||
{selectionError && (
|
||||
<h6 className="z-10 text-red">{selectionError}</h6>
|
||||
)}
|
||||
|
||||
{assetFile.value && (
|
||||
<div className="relative m-auto flex aspect-square w-full items-center justify-center overflow-hidden rounded-lg bg-brand-secondary">
|
||||
<img
|
||||
alt="Preview"
|
||||
className="m-auto max-h-full max-w-full object-contain"
|
||||
src={URL.createObjectURL(assetFile.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{fileEntries.length > 0 && (
|
||||
isMulti ? (
|
||||
<PanelGroup orientation="horizontal">
|
||||
{/* File list sidebar */}
|
||||
<Panel defaultSize="33%" minSize="20%">
|
||||
<ul className="flex h-full flex-col gap-1 overflow-y-auto rounded-lg bg-brand-secondary p-2">
|
||||
{fileEntries.map((entry, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className={`group flex items-center justify-between gap-1.5 rounded px-2 py-1 cursor-pointer text-sm transition-colors ${
|
||||
i === previewIndex ? "bg-white/10" : "hover:bg-white/5"
|
||||
}`}
|
||||
onClick={() => setPreviewIndex(i)}
|
||||
>
|
||||
<span className="truncate flex-1" title={entry.file.name}>
|
||||
{entry.file.name.slice(0, entry.file.name.lastIndexOf("."))}
|
||||
</span>
|
||||
<span className="shrink-0">
|
||||
{entry.status === "idle" && (
|
||||
<button
|
||||
className="opacity-40 hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(i);
|
||||
}}
|
||||
title="Remove"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
)}
|
||||
{entry.status === "uploading" && (
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinner}
|
||||
className="animate-spin opacity-60"
|
||||
/>
|
||||
)}
|
||||
{entry.status === "success" && (
|
||||
<FontAwesomeIcon
|
||||
icon={faCheck}
|
||||
className="text-green-400"
|
||||
/>
|
||||
)}
|
||||
{entry.status === "error" && (
|
||||
<span className="flex items-center gap-1">
|
||||
<FontAwesomeIcon
|
||||
icon={faCircleExclamation}
|
||||
className="text-red-400"
|
||||
/>
|
||||
<button
|
||||
className="hidden group-hover:inline-flex items-center text-xs text-white/60 hover:text-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
retrySingleFile(i);
|
||||
}}
|
||||
title="Retry"
|
||||
>
|
||||
<FontAwesomeIcon icon={faRotateRight} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
<PanelResizeHandle className="flex w-4 items-center justify-center" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<div className="h-8 w-1 rounded-full bg-white/20 transition-colors hover:bg-white/40" />
|
||||
</PanelResizeHandle>
|
||||
|
||||
{/* Preview + carousel navigation */}
|
||||
<Panel defaultSize="67%" minSize="25%">
|
||||
<div className="flex h-full flex-col gap-2">
|
||||
<div className="relative aspect-square w-full overflow-hidden rounded-lg bg-brand-secondary flex items-center justify-center">
|
||||
{previewUrl && (
|
||||
<img
|
||||
key={previewIndex}
|
||||
alt="Preview"
|
||||
className="m-auto max-h-full max-w-full object-contain"
|
||||
src={previewUrl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setPreviewIndex((p) => Math.max(0, p - 1))}
|
||||
disabled={previewIndex === 0}
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronLeft} />
|
||||
</Button>
|
||||
<span className="text-sm opacity-60">
|
||||
{previewIndex + 1} / {fileEntries.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setPreviewIndex((p) =>
|
||||
Math.min(fileEntries.length - 1, p + 1)
|
||||
)
|
||||
}
|
||||
disabled={previewIndex === fileEntries.length - 1}
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
) : (
|
||||
previewUrl && (
|
||||
<div className="relative m-auto flex aspect-square w-full items-center justify-center overflow-hidden rounded-lg bg-brand-secondary">
|
||||
<img
|
||||
alt="Preview"
|
||||
className="m-auto max-h-full max-w-full object-contain"
|
||||
src={previewUrl}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
)}
|
||||
|
||||
{(isUploading || anyUploading) && overallProgress && isMulti && (
|
||||
<p className="text-center text-sm opacity-60">
|
||||
Uploading {overallProgress.current} / {overallProgress.total}...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isUploading && !anyUploading && allDone && anyFailed && isMulti && (
|
||||
<p className="text-center text-sm text-red-400">
|
||||
{fileEntries.filter((e) => e.status === "error").length} file(s) failed to upload.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{!isUploading && !anyUploading && allDone && anyFailed && isMulti && (
|
||||
<Button variant="secondary" onClick={retryAllFailed}>
|
||||
Retry Failed
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSubmit}>
|
||||
)}
|
||||
{/* Hide Upload once any upload has started; only Retry Failed or Close remains */}
|
||||
{!hasUploadStarted && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={fileEntries.length === 0}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+11
-5
@@ -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<UploaderState>(initialUploaderState);
|
||||
|
||||
@@ -68,7 +68,7 @@ export function UploadModalImage(props: Props) {
|
||||
<UploadFilesImage
|
||||
title={title}
|
||||
fileTypes={imageFileTypes}
|
||||
initialFile={initialFile}
|
||||
initialFiles={initialFiles}
|
||||
onClose={onClose}
|
||||
onUploadProgress={updateUploaderState}
|
||||
/>
|
||||
@@ -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 (
|
||||
<>
|
||||
<LoadingDots className="mb-1 bg-transparent" />
|
||||
<div className="w-100 text-center opacity-50">Uploading...</div>
|
||||
<div className="w-100 text-center opacity-50">
|
||||
{p && p.total > 1
|
||||
? `Uploading ${p.current} / ${p.total}...`
|
||||
: "Uploading..."}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case UploaderStates.success: {
|
||||
return (
|
||||
<UploadSuccess
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { UploaderStates } from "~/enums";
|
||||
import { uploadImage } from "./uploadImage";
|
||||
|
||||
export type FileEntryStatus = "idle" | "uploading" | "success" | "error";
|
||||
|
||||
export const uploadImagesBatch = async ({
|
||||
files,
|
||||
onFileStatusChange,
|
||||
onOverallProgress,
|
||||
onComplete,
|
||||
}: {
|
||||
files: File[];
|
||||
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<number>();
|
||||
|
||||
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);
|
||||
};
|
||||
+427
-117
@@ -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<HTMLCanvasElement | undefined>(undefined);
|
||||
const canvasCallbackRef = useCallback((node: HTMLCanvasElement) => {
|
||||
const rendererRef = useRef<WebGLRenderer | null>(null);
|
||||
const cameraRef = useRef<PerspectiveCamera | null>(null);
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(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<MediaFileAnimationType | undefined>(
|
||||
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<Blob | undefined>(
|
||||
undefined,
|
||||
const [fileEntries, setFileEntries] = useState<FileEntry[]>(
|
||||
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<Map<File, Blob>>(new Map());
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [overallProgress, setOverallProgress] = useState<{ current: number; total: number } | null>(null);
|
||||
const [selectionError, setSelectionError] = useState<string | 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];
|
||||
|
||||
// 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<File, Blob>(
|
||||
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<File, Blob>(
|
||||
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 (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<FileUploader
|
||||
fileTypes={fileTypes}
|
||||
file={assetFile.value ?? undefined}
|
||||
handleChange={(file: File) => {
|
||||
setAssetFile({
|
||||
value: file,
|
||||
});
|
||||
}}
|
||||
<div className="flex flex-col gap-3">
|
||||
{fileSubtypes && fileSubtypes.length > 1 && (
|
||||
<ListDropdown
|
||||
list={fileSubtypes}
|
||||
onSelect={(value) => setSubtype(value as MediaFileAnimationType)}
|
||||
/>
|
||||
{fileSubtypes && fileSubtypes.length > 1 && (
|
||||
<ListDropdown
|
||||
list={fileSubtypes}
|
||||
onSelect={(value) => setSubtype(value as MediaFileAnimationType)}
|
||||
/>
|
||||
)}
|
||||
{assetFile.error && (
|
||||
<h6 className="z-10 text-red">{assetFile.error}</h6>
|
||||
)}
|
||||
)}
|
||||
|
||||
<div className="relative m-auto w-full overflow-hidden rounded-lg bg-brand-secondary">
|
||||
<FileUploader
|
||||
fileTypes={fileTypes}
|
||||
files={fileEntries.map((e) => e.file)}
|
||||
handleChange={handleFilesChange}
|
||||
multiple={true}
|
||||
fileIcon={faCube}
|
||||
/>
|
||||
|
||||
{selectionError && (
|
||||
<h6 className="z-10 text-red">{selectionError}</h6>
|
||||
)}
|
||||
|
||||
{isMulti ? (
|
||||
<PanelGroup orientation="horizontal">
|
||||
{/* File list sidebar */}
|
||||
<Panel defaultSize="33%" minSize="20%">
|
||||
<ul className="flex h-full flex-col gap-1 overflow-y-auto rounded-lg bg-brand-secondary p-2">
|
||||
{fileEntries.map((entry, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className={`group flex items-center justify-between gap-1.5 rounded px-2 py-1 cursor-pointer text-sm transition-colors ${
|
||||
i === previewIndex ? "bg-white/10" : "hover:bg-white/5"
|
||||
}`}
|
||||
onClick={() => setPreviewIndex(i)}
|
||||
>
|
||||
<span className="truncate flex-1" title={entry.file.name}>
|
||||
{entry.file.name.slice(0, entry.file.name.lastIndexOf("."))}
|
||||
</span>
|
||||
<span className="shrink-0">
|
||||
{entry.status === "idle" && (
|
||||
<button
|
||||
className="opacity-40 hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(i);
|
||||
}}
|
||||
title="Remove"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
)}
|
||||
{entry.status === "uploading" && (
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinner}
|
||||
className="animate-spin opacity-60"
|
||||
/>
|
||||
)}
|
||||
{entry.status === "success" && (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-green-400" />
|
||||
)}
|
||||
{entry.status === "error" && (
|
||||
<span className="flex items-center gap-1">
|
||||
<FontAwesomeIcon
|
||||
icon={faCircleExclamation}
|
||||
className="text-red-400"
|
||||
/>
|
||||
<button
|
||||
className="hidden group-hover:inline-flex items-center text-xs text-white/60 hover:text-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
retrySingleFile(i);
|
||||
}}
|
||||
title="Retry"
|
||||
>
|
||||
<FontAwesomeIcon icon={faRotateRight} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
|
||||
<PanelResizeHandle className="flex w-4 items-center justify-center" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<div className="h-8 w-1 rounded-full bg-white/20 transition-colors hover:bg-white/40" />
|
||||
</PanelResizeHandle>
|
||||
|
||||
{/* Canvas preview + carousel */}
|
||||
<Panel defaultSize="67%" minSize="25%">
|
||||
<div className="flex h-full flex-col gap-2">
|
||||
<div className="relative w-full min-h-48 overflow-hidden rounded-lg bg-brand-secondary">
|
||||
<canvas
|
||||
className="pointer-events-none h-full min-h-48 !w-full"
|
||||
ref={canvasCallbackRef}
|
||||
/>
|
||||
{!currentFile && (
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 flex w-full items-center justify-center gap-2.5 text-center opacity-50">
|
||||
<FontAwesomeIcon icon={faCube} />
|
||||
Your model preview will appear here
|
||||
</h6>
|
||||
)}
|
||||
{previewStatus.type.includes("Error") && (
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 w-full text-center">
|
||||
{previewStatus.type}
|
||||
{previewStatus.message && <br />}
|
||||
{previewStatus.message}
|
||||
</h6>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setPreviewIndex((p) => Math.max(0, p - 1))}
|
||||
disabled={previewIndex === 0}
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronLeft} />
|
||||
</Button>
|
||||
<span className="text-sm opacity-60">
|
||||
{previewIndex + 1} / {fileEntries.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setPreviewIndex((p) =>
|
||||
Math.min(fileEntries.length - 1, p + 1)
|
||||
)
|
||||
}
|
||||
disabled={previewIndex === fileEntries.length - 1}
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
) : (
|
||||
/* Single-file layout: canvas takes full width (original behaviour) */
|
||||
<div className="relative m-auto w-full min-h-48 overflow-hidden rounded-lg bg-brand-secondary">
|
||||
<canvas
|
||||
className="pointer-events-none h-full !w-full"
|
||||
className="pointer-events-none h-full min-h-48 !w-full"
|
||||
ref={canvasCallbackRef}
|
||||
/>
|
||||
{!assetFile.value && (
|
||||
{!currentFile && (
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 flex w-full items-center justify-center gap-2.5 text-center opacity-50">
|
||||
<FontAwesomeIcon icon={faCube} />
|
||||
Your model preview will appear here
|
||||
</h6>
|
||||
)}
|
||||
{previewStatus.type.includes("Error") && (
|
||||
<>
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 w-full text-center">
|
||||
{previewStatus.type}
|
||||
{previewStatus.message && <br />}
|
||||
{previewStatus.message}
|
||||
</h6>
|
||||
</>
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 w-full text-center">
|
||||
{previewStatus.type}
|
||||
{previewStatus.message && <br />}
|
||||
{previewStatus.message}
|
||||
</h6>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
{(isUploading || anyUploading) && overallProgress && isMulti && (
|
||||
<p className="text-center text-sm opacity-60">
|
||||
Uploading {overallProgress.current} / {overallProgress.total}...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isUploading && !anyUploading && allDone && anyFailed && isMulti && (
|
||||
<p className="text-center text-sm text-red-400">
|
||||
{fileEntries.filter((e) => e.status === "error").length} file(s) failed to upload.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{!isUploading && !anyUploading && allDone && anyFailed && isMulti && (
|
||||
<Button variant="secondary" onClick={retryAllFailed}>
|
||||
Retry Failed
|
||||
</Button>
|
||||
)}
|
||||
{/* Hide Upload once any upload has started; only Retry Failed or Close remains */}
|
||||
{!hasUploadStarted && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={!assetFile.value}
|
||||
disabled={fileEntries.length === 0}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+11
-5
@@ -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<UploaderState>(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 (
|
||||
<>
|
||||
<LoadingDots className="mb-1 bg-transparent" />
|
||||
<div className="w-100 text-center opacity-50">Uploading...</div>
|
||||
<div className="w-100 text-center opacity-50">
|
||||
{p && p.total > 1
|
||||
? `Uploading ${p.current} / ${p.total}...`
|
||||
: "Uploading..."}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case UploaderStates.success: {
|
||||
return (
|
||||
<UploadSuccess
|
||||
|
||||
@@ -1,98 +1,33 @@
|
||||
import { useRef } from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { faUpload } from "@fortawesome/pro-solid-svg-icons";
|
||||
import toast from "react-hot-toast";
|
||||
import { MediaUploadApi } from "@storyteller/api";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { EIntermediateFile } from "~/enums/EIntermediateFile";
|
||||
import { faUpload, faImages } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { UploadModalImage } from "../../reusable/UploadModalImage/UploadModalImage";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const UploadImagesButton = ({ className }: Props) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleClick = () => fileInputRef.current?.click();
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,image/jpeg,image/png"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Tooltip content="Upload images" position="bottom" delay={300}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={faUpload}
|
||||
className={className || "h-[38px] w-[38px]"}
|
||||
onClick={handleClick}
|
||||
onClick={() => setIsOpen(true)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<UploadModalImage
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
onSuccess={() => setIsOpen(false)}
|
||||
title="Upload an Image"
|
||||
titleIcon={faImages}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface UploaderState {
|
||||
status: UploaderStates;
|
||||
errorMessage?: string;
|
||||
data?: string;
|
||||
uploadProgress?: { current: number; total: number };
|
||||
}
|
||||
|
||||
export const initialUploaderState = {
|
||||
|
||||
@@ -150,7 +150,7 @@ export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
|
||||
<ErrorDialog />
|
||||
|
||||
<EditorLoadingBar />
|
||||
<Toaster offsetTop={70} offsetRight={12} />
|
||||
<Toaster offsetTop={70} offsetRight={12} zIndex={9999} />
|
||||
|
||||
{currentReminderModalProps && (
|
||||
<ActionReminderModal
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-drag-drop-files": "^2.3.10",
|
||||
"react-resizable-panels": "*",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-image-crop": "^11.0.5",
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { faFileArrowUp, faFileAudio } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { faFileArrowUp, faFileImage } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
export const DragAndDropZone = ({
|
||||
file,
|
||||
files,
|
||||
fileTypes,
|
||||
fileIcon = faFileImage,
|
||||
}: {
|
||||
file?: File;
|
||||
files: File[];
|
||||
fileTypes: string[];
|
||||
fileIcon?: IconDefinition;
|
||||
}) => {
|
||||
if (!file) {
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<div className="flex cursor-pointer items-center gap-3.5 rounded-lg border border-dashed border-[#363636] bg-brand-secondary p-3">
|
||||
<FontAwesomeIcon icon={faFileArrowUp} className="text-4xl" />
|
||||
@@ -25,24 +28,39 @@ export const DragAndDropZone = ({
|
||||
);
|
||||
}
|
||||
|
||||
const fileName = file.name.split(".")[0].toUpperCase();
|
||||
const fileSize =
|
||||
file.size >= 1024 * 1024
|
||||
? (file.size / 1024 / 1024).toFixed(2) + " MB"
|
||||
: `${Math.floor(file.size / 1024)} KB`;
|
||||
if (files.length === 1) {
|
||||
const file = files[0];
|
||||
const fileName = file.name.split(".")[0].toUpperCase();
|
||||
const fileSize =
|
||||
file.size >= 1024 * 1024
|
||||
? (file.size / 1024 / 1024).toFixed(2) + " MB"
|
||||
: `${Math.floor(file.size / 1024)} KB`;
|
||||
|
||||
return (
|
||||
<div className="flex cursor-pointer items-center justify-between gap-3.5 rounded-lg border border-dashed border-[#363636] bg-brand-secondary p-3">
|
||||
<FontAwesomeIcon icon={fileIcon} className="text-4xl" />
|
||||
<div className="flex grow flex-col gap-0">
|
||||
<p className="font-medium">
|
||||
{file.name.slice(0, file.name.lastIndexOf("."))}
|
||||
</p>
|
||||
<p className="flex items-center gap-2 text-sm font-normal">
|
||||
<span className="opacity-50">
|
||||
{`${fileName} file size: ${fileSize} `}
|
||||
</span>
|
||||
<u className="transition-all hover:text-white/80">Change File</u>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex cursor-pointer items-center justify-between gap-3.5 rounded-lg border border-dashed border-[#363636] bg-brand-secondary p-3">
|
||||
<FontAwesomeIcon icon={faFileAudio} className="text-4xl" />
|
||||
<FontAwesomeIcon icon={fileIcon} className="text-4xl" />
|
||||
<div className="flex grow flex-col gap-0">
|
||||
<p className="font-medium">
|
||||
{file.name.slice(0, file.name.lastIndexOf("."))}
|
||||
</p>
|
||||
<p className="font-medium">{files.length} files selected</p>
|
||||
<p className="flex items-center gap-2 text-sm font-normal">
|
||||
<span className="opacity-50">
|
||||
{`${fileName} file size: ${fileSize} `}
|
||||
</span>
|
||||
<u className="transition-all hover:text-white/80">Change File</u>
|
||||
<u className="transition-all hover:text-white/80">Change Files</u>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,40 @@
|
||||
import { FileUploader as DragDropFileUploader } from "react-drag-drop-files";
|
||||
// Usage refer to https://github.com/KarimMokhtar/react-drag-drop-files
|
||||
|
||||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { DragAndDropZone } from "./drag-and-drop-zone";
|
||||
|
||||
export const FileUploader = ({
|
||||
file,
|
||||
files,
|
||||
fileTypes,
|
||||
handleChange,
|
||||
multiple = false,
|
||||
fileIcon,
|
||||
}: {
|
||||
file: File | undefined;
|
||||
handleChange: (file: File) => void;
|
||||
files: File[];
|
||||
handleChange: (files: File[]) => void;
|
||||
fileTypes: string[];
|
||||
multiple?: boolean;
|
||||
fileIcon?: IconDefinition;
|
||||
}) => (
|
||||
<DragDropFileUploader
|
||||
handleChange={handleChange}
|
||||
handleChange={(result: File | File[]) => {
|
||||
if (Array.isArray(result)) {
|
||||
handleChange(result);
|
||||
} else if (result instanceof File) {
|
||||
handleChange([result]);
|
||||
} else {
|
||||
// react-drag-drop-files may return a FileList from the file picker when multiple=true;
|
||||
// Array.isArray returns false for FileList, so normalize via Array.from.
|
||||
handleChange(Array.from(result as unknown as Iterable<File>));
|
||||
}
|
||||
}}
|
||||
name="file"
|
||||
maxSize={50}
|
||||
types={fileTypes}
|
||||
multiple={multiple}
|
||||
classes="!outline-none"
|
||||
>
|
||||
<DragAndDropZone file={file} fileTypes={fileTypes} />
|
||||
<DragAndDropZone files={files} fileTypes={fileTypes} fileIcon={fileIcon} />
|
||||
</DragDropFileUploader>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user