Compare commits

...
Author SHA1 Message Date
bflatastic 442bf66d55 file upload icon update and focus outline removal on upload field when modal opens 2026-04-03 22:14:45 +07:00
Tanish Manku 9ead129bfc guard completedCount increment with completedIndices.has() check in batch uploads 2026-04-03 03:44:12 -07:00
Tanish Manku ecc4f800ab upload images button opens modal instead of direct file selector 2026-04-03 03:36:44 -07:00
Tanish Manku b42f457022 multi-file upload polish: resizable panes, canvas resize fix, bug fixes
- resizable sidebar/preview panes using react-resizable-panels (% units)
- canvas ResizeObserver: renderer + camera aspect update on panel resize
- global drag-and-drop: multi-file support, mixed/unsupported type toasting
- toast z-index raised above modal overlay
- remove file triggers preview reload when switching to single-file layout
- min-h-48 on 3D/splat canvas containers and canvas elements for correct initial sizing
- loadPreviewOnCanvas now returns camera alongside renderer
2026-04-03 03:28:43 -07:00
Tanish Manku cb3f7906a2 multi-file upload for image, 3D, and splat modals
- FileUploader standardised to File[] with multiple prop
- Per-file sidebar: idle X → uploading spinner → success ✓ → error ! with hover retry
- Carousel preview with left/right navigation
- Parallel batch uploads via Promise.all with live per-file status
- Single-file path preserves existing spinner UX
- Partial failure: Retry Failed button re-uploads only errored files
- Thumbnails keyed by File reference (not index) so removals don't corrupt the map
- FileList normalised via Array.from to fix file-picker multi-select
- Catch block in batch utilities only marks uncompleted files as error
2026-04-02 16:47:17 -07:00
16 changed files with 1543 additions and 403 deletions
@@ -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"
@@ -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
@@ -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 = ({
@@ -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);
};
@@ -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>
);
};
@@ -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
@@ -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);
};
@@ -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>
);
};
@@ -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
+1
View File
@@ -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>
);