diff --git a/src/frontend/src/components/layout/DockPanelContent.tsx b/src/frontend/src/components/layout/DockPanelContent.tsx index da228681..4566ffb9 100644 --- a/src/frontend/src/components/layout/DockPanelContent.tsx +++ b/src/frontend/src/components/layout/DockPanelContent.tsx @@ -212,8 +212,21 @@ export function DockPanelContent({ onTabSelect={setActiveTab} /> - {/* Content Area */} -
+ {/* Content Area + * + * The .dock-content div accepts tab drops anywhere on the panel side. + * Tab-drag handlers gate on `draggedTab` so file drags pass through + * untouched, and inner UploadDropZone elements call stopPropagation + * so file drops never reach this listener — the two systems coexist + * via MIME-type isolation. + */} +
{activeTabData ? ( { @@ -522,10 +511,11 @@ export function EnvironmentMapList() { ) return ( -
)} -
+ ) } diff --git a/src/frontend/src/features/models/components/ModelGrid/ModelGrid.css b/src/frontend/src/features/models/components/ModelGrid/ModelGrid.css index 18fd5243..bbcb0872 100644 --- a/src/frontend/src/features/models/components/ModelGrid/ModelGrid.css +++ b/src/frontend/src/features/models/components/ModelGrid/ModelGrid.css @@ -5,23 +5,10 @@ flex-direction: column; gap: 1rem; padding: 1rem; - border: 2px dashed transparent; border-radius: 8px; - transition: all 0.3s ease; overflow: auto; } -.model-grid-container:hover:not(.drag-over) { - border-color: #3182ce; - background: rgba(49, 130, 206, 0.05); -} - -.model-grid-container.drag-over { - border-color: #3182ce; - background: rgba(49, 130, 206, 0.1); - transform: scale(1.02); -} - /* Search and Filter Controls */ .model-grid-controls { display: flex; @@ -537,16 +524,6 @@ font-weight: 500; } -/* Tab content specific styles */ -.model-list-tab .model-grid-container { - border: none; - background: transparent; -} - -.model-list-tab .model-grid-container:hover { - background: transparent; -} - /* Responsive adjustments — keep slider in control, but cap the desktop minimum down a notch on narrower windows. */ @media (max-width: 1200px) { diff --git a/src/frontend/src/features/models/components/ModelGrid/ModelGrid.tsx b/src/frontend/src/features/models/components/ModelGrid/ModelGrid.tsx index 52208fc5..72daddee 100644 --- a/src/frontend/src/features/models/components/ModelGrid/ModelGrid.tsx +++ b/src/frontend/src/features/models/components/ModelGrid/ModelGrid.tsx @@ -14,6 +14,7 @@ import { import { type GridComponents, VirtuosoGrid } from 'react-virtuoso' import { useTabContext } from '@/hooks/useTabContext' +import { UploadDropZone } from '@/shared/components/UploadDropZone' import { ThumbnailDisplay } from '@/shared/thumbnail' import { DEFAULT_MODEL_LIST_VIEW_STATE, @@ -152,10 +153,6 @@ export function ModelGrid({ uploading, uploadProgress, uploadMultipleFiles, - onDrop, - onDragOver, - onDragEnter, - onDragLeave, searchQuery, setSearchQuery, selectedCategoryKeys, @@ -425,13 +422,12 @@ export function ModelGrid({ } return ( -
{ + void uploadMultipleFiles(files) + }} > @@ -628,6 +624,6 @@ export function ModelGrid({ onModelsAdded={() => fetchModels()} /> )} -
+ ) } diff --git a/src/frontend/src/features/models/components/ModelGrid/useModelGrid.ts b/src/frontend/src/features/models/components/ModelGrid/useModelGrid.ts index f746ffb1..d9ca2be7 100644 --- a/src/frontend/src/features/models/components/ModelGrid/useModelGrid.ts +++ b/src/frontend/src/features/models/components/ModelGrid/useModelGrid.ts @@ -95,12 +95,6 @@ export function useModelGrid({ uploadProgress: upload.uploadProgress, uploadMultipleFiles: upload.uploadMultipleFiles, - // Drag and drop - onDrop: upload.onDrop, - onDragOver: upload.onDragOver, - onDragEnter: upload.onDragEnter, - onDragLeave: upload.onDragLeave, - // Search & Filters isSearchOpen: filters.isSearchOpen, setIsSearchOpen: filters.setIsSearchOpen, diff --git a/src/frontend/src/features/models/components/ModelGrid/useModelUpload.ts b/src/frontend/src/features/models/components/ModelGrid/useModelUpload.ts index 040f017d..d128a548 100644 --- a/src/frontend/src/features/models/components/ModelGrid/useModelUpload.ts +++ b/src/frontend/src/features/models/components/ModelGrid/useModelUpload.ts @@ -3,7 +3,7 @@ import { type RefObject, useCallback } from 'react' import { addModelToPack } from '@/features/pack/api/packApi' import { addModelToProject } from '@/features/project/api/projectApi' -import { useDragAndDrop, useFileUpload } from '@/shared/hooks/useFileUpload' +import { useFileUpload } from '@/shared/hooks/useFileUpload' import { useBlenderEnabledStore } from '@/stores/blenderEnabledStore' interface UseModelUploadOptions { @@ -67,17 +67,9 @@ export function useModelUpload({ [blenderEnabled, uploadMultipleFiles] ) - const { onDrop, onDragOver, onDragEnter, onDragLeave } = useDragAndDrop( - filteredUploadMultipleFiles - ) - return { uploading, uploadProgress, uploadMultipleFiles: filteredUploadMultipleFiles, - onDrop, - onDragOver, - onDragEnter, - onDragLeave, } } diff --git a/src/frontend/src/features/sounds/components/SoundList.tsx b/src/frontend/src/features/sounds/components/SoundList.tsx index 34f89faf..92d19cb9 100644 --- a/src/frontend/src/features/sounds/components/SoundList.tsx +++ b/src/frontend/src/features/sounds/components/SoundList.tsx @@ -22,6 +22,7 @@ import { useSoundListData } from '@/features/sounds/hooks/useSoundListData' import { useSoundMutations } from '@/features/sounds/hooks/useSoundMutations' import { useSoundUpload } from '@/features/sounds/hooks/useSoundUpload' import { CardWidthSlider } from '@/shared/components/CardWidthSlider' +import { UploadDropZone } from '@/shared/components/UploadDropZone' import { soundCategoryFormSchema } from '@/shared/validation/formSchemas' import { useCardWidthStore } from '@/stores/cardWidthStore' import { type SoundCategoryDto, type SoundDto } from '@/types' @@ -130,14 +131,7 @@ export function SoundList() { setContextMenuTarget, }) - const { - onDrop, - onDragOver, - onDragEnter, - onDragLeave, - fileInputRef, - handleFileDrop, - } = useSoundUpload({ + const { fileInputRef, handleFileDrop } = useSoundUpload({ showToast, activeCategoryId, loadSounds: invalidateSounds, @@ -482,12 +476,9 @@ export function SoundList() { } return ( -
@@ -616,6 +607,6 @@ export function SoundList() { } }} /> -
+ ) } diff --git a/src/frontend/src/features/sounds/hooks/useSoundUpload.ts b/src/frontend/src/features/sounds/hooks/useSoundUpload.ts index 003b1c6c..98365274 100644 --- a/src/frontend/src/features/sounds/hooks/useSoundUpload.ts +++ b/src/frontend/src/features/sounds/hooks/useSoundUpload.ts @@ -2,7 +2,6 @@ import { useRef } from 'react' import { createSoundWithFile } from '@/features/sounds/api/soundApi' import { useUploadProgress } from '@/hooks/useUploadProgress' -import { useDragAndDrop } from '@/shared/hooks/useFileUpload' import { decodeAudio, extractPeaks } from '@/utils/audioUtils' const UNASSIGNED_CATEGORY_ID = -1 @@ -129,14 +128,7 @@ export function useSoundUpload({ loadSounds() } - const { onDrop, onDragOver, onDragEnter, onDragLeave } = - useDragAndDrop(handleFileDrop) - return { - onDrop, - onDragOver, - onDragEnter, - onDragLeave, fileInputRef, handleFileDrop, } diff --git a/src/frontend/src/features/sprite/components/SpriteList.tsx b/src/frontend/src/features/sprite/components/SpriteList.tsx index eadd145c..28985151 100644 --- a/src/frontend/src/features/sprite/components/SpriteList.tsx +++ b/src/frontend/src/features/sprite/components/SpriteList.tsx @@ -26,7 +26,7 @@ import { useSpriteMutations } from '@/features/sprite/hooks/useSpriteMutations' import { useSpriteUpload } from '@/features/sprite/hooks/useSpriteUpload' import { useUploadProgress } from '@/hooks/useUploadProgress' import { CardWidthSlider } from '@/shared/components/CardWidthSlider' -import { useDragAndDrop } from '@/shared/hooks/useFileUpload' +import { UploadDropZone } from '@/shared/components/UploadDropZone' import { spriteCategoryFormSchema, spriteRenameFormSchema, @@ -155,9 +155,6 @@ export function SpriteList() { toast, }) - const { onDrop, onDragOver, onDragEnter, onDragLeave } = - useDragAndDrop(handleFileDrop) - // ── Category Dialog Handlers ──────────────────────────────────────── const openCreateCategoryDialog = () => { setEditingCategory(null) @@ -491,12 +488,9 @@ export function SpriteList() { } return ( -
@@ -759,7 +753,7 @@ export function SpriteList() { if (e.target.files) handleFileDrop(e.target.files) }} /> -
+ ) } diff --git a/src/frontend/src/features/texture-set/components/TextureSetGrid.css b/src/frontend/src/features/texture-set/components/TextureSetGrid.css index 959d376d..8d596661 100644 --- a/src/frontend/src/features/texture-set/components/TextureSetGrid.css +++ b/src/frontend/src/features/texture-set/components/TextureSetGrid.css @@ -5,23 +5,10 @@ flex-direction: column; gap: 1.5rem; padding: 1rem; - border: 2px dashed transparent; border-radius: 8px; - transition: all 0.3s ease; overflow: auto; } -.texture-set-grid-container:hover:not(.drag-over) { - border-color: #3182ce; - background: rgba(49, 130, 206, 0.05); -} - -.texture-set-grid-container.drag-over { - border-color: #3182ce; - background: rgba(49, 130, 206, 0.1); - transform: scale(1.02); -} - /* Search and Filter Controls */ .texture-set-grid-controls { display: flex; @@ -252,17 +239,6 @@ cursor: pointer; } -.texture-set-grid-empty:hover:not(.drag-over) { - border-color: var(--primary-color); - background: rgba(var(--primary-color-rgb), 0.05); -} - -.texture-set-grid-empty.drag-over { - border-color: var(--primary-color); - background: rgba(var(--primary-color-rgb), 0.1); - transform: scale(1.02); -} - .texture-set-grid-empty .pi { font-size: 4rem; color: var(--text-color-secondary); diff --git a/src/frontend/src/features/texture-set/components/TextureSetGrid.tsx b/src/frontend/src/features/texture-set/components/TextureSetGrid.tsx index e5729bd9..328a4f0c 100644 --- a/src/frontend/src/features/texture-set/components/TextureSetGrid.tsx +++ b/src/frontend/src/features/texture-set/components/TextureSetGrid.tsx @@ -45,10 +45,6 @@ interface TextureSetGridProps { textureSets: TextureSetDto[] loading?: boolean onTextureSetSelect: (textureSet: TextureSetDto) => void - onDrop: (e: React.DragEvent) => void - onDragOver: (e: React.DragEvent) => void - onDragEnter: (e: React.DragEvent) => void - onDragLeave: (e: React.DragEvent) => void onTextureSetRecycled?: (textureSetId: number) => void onTextureSetUpdated?: () => void } @@ -57,10 +53,6 @@ export function TextureSetGrid({ textureSets, loading = false, onTextureSetSelect, - onDrop, - onDragOver, - onDragEnter, - onDragLeave, onTextureSetRecycled, onTextureSetUpdated, }: TextureSetGridProps) { @@ -455,13 +447,7 @@ export function TextureSetGrid({ // Empty state (no texture sets at all) if (textureSets.length === 0) { return ( -
+

No Texture Sets

Drag and drop texture files here to create new sets

@@ -473,13 +459,7 @@ export function TextureSetGrid({ } return ( -
+
diff --git a/src/frontend/src/features/texture-set/components/TextureSetList.tsx b/src/frontend/src/features/texture-set/components/TextureSetList.tsx index 8f2fe60d..64ec6ff7 100644 --- a/src/frontend/src/features/texture-set/components/TextureSetList.tsx +++ b/src/frontend/src/features/texture-set/components/TextureSetList.tsx @@ -20,7 +20,7 @@ import { import { CreateTextureSetDialog } from '@/features/texture-set/dialogs/CreateTextureSetDialog' import { useTabContext } from '@/hooks/useTabContext' import { useUploadProgress } from '@/hooks/useUploadProgress' -import { useDragAndDrop } from '@/shared/hooks/useFileUpload' +import { UploadDropZone } from '@/shared/components/UploadDropZone' import { type TextureSetDto, TextureSetKind } from '@/types' import { TextureSetGrid } from './TextureSetGrid' @@ -227,12 +227,11 @@ export function TextureSetList() { invalidateTextureSets() } - // Use drag and drop hook - const { onDrop, onDragOver, onDragEnter, onDragLeave } = - useDragAndDrop(handleFileDrop) - return ( -
+ @@ -309,10 +308,6 @@ export function TextureSetList() { textureSets={textureSets} loading={loading} onTextureSetSelect={handleViewDetails} - onDrop={onDrop} - onDragOver={onDragOver} - onDragEnter={onDragEnter} - onDragLeave={onDragLeave} onTextureSetRecycled={handleTextureSetRecycled} onTextureSetUpdated={invalidateTextureSets} /> @@ -346,6 +341,6 @@ export function TextureSetList() { onSubmit={handleCreateTextureSet} /> )} -
+ ) } diff --git a/src/frontend/src/mocks/dynamic-demo/shared.ts b/src/frontend/src/mocks/dynamic-demo/shared.ts index c0b5f869..efb4c245 100644 --- a/src/frontend/src/mocks/dynamic-demo/shared.ts +++ b/src/frontend/src/mocks/dynamic-demo/shared.ts @@ -33,6 +33,7 @@ import { storeThumbnail, } from '../db/demoDb' import { + generateEnvironmentMapThumbnail, generateExrChannelPreview, generateHdrChannelPreview, generateImageChannelPreview, @@ -159,7 +160,7 @@ export const seedFileAssets: Record = { 402: 'texture_albedo.png', 501: 'test-tone.wav', 601: 'hdri/potsdamer_platz_1k.hdr', - 602: 'global texture/normal.exr', + 602: 'hdri/potsdamer_platz_1k.hdr', } export function paginate(items: T[], page: number, pageSize: number) { @@ -687,16 +688,33 @@ export function generateVersionThumbnailAsync( export function generateEnvironmentMapThumbnailAsync( envMapId: number, fileBlob: Blob, + fileName: string, + variantId?: number +) { + generateEnvironmentMapThumbnail(fileBlob, fileName) + .then(preview => { + const writes: Promise[] = [ + storeThumbnail(`envMapPreview:${envMapId}`, preview), + ] + if (variantId) { + writes.push( + storeThumbnail(`envMapVariantPreview:${variantId}`, preview) + ) + } + return Promise.all(writes) + }) + .catch(() => {}) +} + +export function generateEnvironmentMapVariantThumbnailAsync( + variantId: number, + fileBlob: Blob, fileName: string ) { - const lower = fileName.toLowerCase() - const gen = lower.endsWith('.hdr') - ? generateHdrChannelPreview(fileBlob, 'rgb') - : lower.endsWith('.exr') - ? generateExrChannelPreview(fileBlob, 'rgb') - : generateImageChannelPreview(fileBlob, 'rgb') - gen - .then(preview => storeThumbnail(`envMapPreview:${envMapId}`, preview)) + generateEnvironmentMapThumbnail(fileBlob, fileName) + .then(preview => + storeThumbnail(`envMapVariantPreview:${variantId}`, preview) + ) .catch(() => {}) } @@ -1177,13 +1195,35 @@ export async function prewarmSeedThumbnails(): Promise { */ export async function prewarmSeedEnvironmentMapThumbnails(): Promise { const seedItems = [ - { envMapId: 1, fileId: 601, fileName: 'potsdamer_platz_1k.hdr' }, + { + envMapId: 1, + variantId: 1, + fileId: 601, + fileName: 'potsdamer_platz_1k.hdr', + isPreviewVariant: true, + }, + { + envMapId: 1, + variantId: 2, + fileId: 601, + fileName: 'potsdamer_platz_1k.hdr', + isPreviewVariant: false, + }, ] - for (const { envMapId, fileId, fileName } of seedItems) { - const cacheKey = `envMapPreview:${envMapId}` - const existing = await getThumbnail(cacheKey) - if (existing) continue + for (const { + envMapId, + variantId, + fileId, + fileName, + isPreviewVariant, + } of seedItems) { + const variantKey = `envMapVariantPreview:${variantId}` + const mapKey = `envMapPreview:${envMapId}` + + const existingVariant = await getThumbnail(variantKey) + const existingMap = isPreviewVariant ? await getThumbnail(mapKey) : null + if (existingVariant && (!isPreviewVariant || existingMap)) continue const seedPath = seedFileAssets[fileId] if (!seedPath) continue @@ -1195,17 +1235,11 @@ export async function prewarmSeedEnvironmentMapThumbnails(): Promise { if (!response.ok) continue const blob = await response.blob() - let preview: Blob - - if (fileName.endsWith('.hdr')) { - preview = await generateHdrChannelPreview(blob, 'rgb') - } else if (fileName.endsWith('.exr')) { - preview = await generateExrChannelPreview(blob, 'rgb') - } else { - preview = await generateImageChannelPreview(blob, 'rgb') + const preview = await generateEnvironmentMapThumbnail(blob, fileName) + await storeThumbnail(variantKey, preview) + if (isPreviewVariant) { + await storeThumbnail(mapKey, preview) } - - await storeThumbnail(cacheKey, preview) } catch { // Silently ignore — preview requests will still generate on demand. } diff --git a/src/frontend/src/mocks/dynamicDemoHandlers.ts b/src/frontend/src/mocks/dynamicDemoHandlers.ts index 9b024b6a..ee48137e 100644 --- a/src/frontend/src/mocks/dynamicDemoHandlers.ts +++ b/src/frontend/src/mocks/dynamicDemoHandlers.ts @@ -16,6 +16,7 @@ import { enrichModel, fetchStaticAsset, generateEnvironmentMapThumbnailAsync, + generateEnvironmentMapVariantThumbnailAsync, generateExrChannelPreview, generateHdrChannelPreview, generateImageChannelPreview, @@ -2070,11 +2071,20 @@ export const dynamicDemoHandlers = [ http.get( '*/environment-maps/:id/variants/:variantId/preview', async ({ params }) => { + const variantId = Number(params.variantId) + + const cached = await getThumbnail(`envMapVariantPreview:${variantId}`) + if (cached) { + return new HttpResponse(cached, { + headers: { 'Content-Type': cached.type || 'image/png' }, + }) + } + const environmentMap = await getById('environmentMaps', Number(params.id)) if (!environmentMap) return new HttpResponse(null, { status: 404 }) const variant = (environmentMap.variants ?? []).find( - item => item.id === Number(params.variantId) && !item.isDeleted + item => item.id === variantId && !item.isDeleted ) if (!variant) return new HttpResponse(null, { status: 404 }) @@ -2273,7 +2283,8 @@ export const dynamicDemoHandlers = [ generateEnvironmentMapThumbnailAsync( environmentMapId, thumbnailSource, - thumbnailSource.name + thumbnailSource.name, + variantId ) } @@ -2436,6 +2447,16 @@ export const dynamicDemoHandlers = [ syncEnvironmentMapDerivedFields(environmentMap) await put('environmentMaps', environmentMap) + const variantThumbnailSource = + file ?? (cubeFaceFiles ? cubeFaceFiles.px : null) + if (variantThumbnailSource) { + generateEnvironmentMapVariantThumbnailAsync( + variantId, + variantThumbnailSource, + variantThumbnailSource.name + ) + } + return HttpResponse.json({ variantId, fileId, @@ -2479,22 +2500,42 @@ export const dynamicDemoHandlers = [ await put('environmentMaps', environmentMap) await removeThumbnail(`envMapPreview:${environmentMap.id}`) - if (environmentMap.previewFileId) { - const source = await loadEnvironmentMapPreviewBlob( - environmentMap.previewFileId - ) - if (source) { + + const activeVariants = (environmentMap.variants ?? []).filter( + variant => !variant.isDeleted + ) + + for (const variant of activeVariants) { + await removeThumbnail(`envMapVariantPreview:${variant.id}`) + + const variantFileId = + variant.previewFileId ?? + variant.panoramicFile?.fileId ?? + variant.cubeFaces?.px.fileId ?? + variant.fileId ?? + null + if (!variantFileId) continue + + const source = await loadEnvironmentMapPreviewBlob(variantFileId) + if (!source) continue + + if (variant.id === environmentMap.previewVariantId) { generateEnvironmentMapThumbnailAsync( environmentMap.id, source.blob, + source.fileName, + variant.id + ) + } else { + generateEnvironmentMapVariantThumbnailAsync( + variant.id, + source.blob, source.fileName ) } } - const regeneratedVariantIds = (environmentMap.variants ?? []) - .filter(variant => !variant.isDeleted) - .map(variant => variant.id) + const regeneratedVariantIds = activeVariants.map(variant => variant.id) return HttpResponse.json({ message: diff --git a/src/frontend/src/mocks/services/browserAssetProcessor.ts b/src/frontend/src/mocks/services/browserAssetProcessor.ts index d42d17aa..7790fd57 100644 --- a/src/frontend/src/mocks/services/browserAssetProcessor.ts +++ b/src/frontend/src/mocks/services/browserAssetProcessor.ts @@ -8,18 +8,22 @@ * 2. Audio Waveforms — OfflineAudioContext peak extraction → Canvas 2D PNG */ import { + ACESFilmicToneMapping, AmbientLight, Box3, CanvasTexture, Color, DirectionalLight, + EquirectangularReflectionMapping, type Group, Mesh, MeshStandardMaterial, type Object3D, PerspectiveCamera, + PMREMGenerator, Scene, SphereGeometry, + type Texture, Vector3, WebGLRenderer, } from 'three' @@ -712,6 +716,109 @@ export async function generateTextureSetThumbnail( } } +// ─── Environment Map Thumbnails ───────────────────────────────────────── + +async function loadEquirectangularEnvTexture( + blob: Blob, + fileName: string +): Promise { + const lower = fileName.toLowerCase() + const url = URL.createObjectURL(blob) + try { + if (lower.endsWith('.hdr')) { + const { RGBELoader } = await import('three-stdlib') + const loader = new RGBELoader() + return await new Promise((resolve, reject) => { + loader.load(url, resolve as (tex: unknown) => void, undefined, reject) + }) + } + if (lower.endsWith('.exr')) { + const { EXRLoader } = await import('three/addons/loaders/EXRLoader.js') + const loader = new EXRLoader() + return await new Promise((resolve, reject) => { + loader.load(url, resolve as (tex: unknown) => void, undefined, reject) + }) + } + const bitmap = await createImageBitmap(blob) + return new CanvasTexture(bitmap as unknown as HTMLCanvasElement) + } finally { + URL.revokeObjectURL(url) + } +} + +/** + * Render an environment map thumbnail as an animated WebP: a polished + * metallic sphere using the env map for IBL, orbited 360° around the + * camera. Mirrors the real asset-processor's environmentMapProcessor.js + * output — tone-mapped through ACES so HDRs no longer blow out to white. + */ +export async function generateEnvironmentMapThumbnail( + fileBlob: Blob, + fileName: string, + width = 256, + height = 256 +): Promise { + try { + const envTexture = await loadEquirectangularEnvTexture(fileBlob, fileName) + envTexture.mapping = EquirectangularReflectionMapping + + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + + const renderer = new WebGLRenderer({ + canvas, + alpha: true, + antialias: true, + preserveDrawingBuffer: true, + }) + renderer.setSize(width, height) + renderer.setClearColor(0x2a2a2e, 1) + renderer.toneMapping = ACESFilmicToneMapping + renderer.toneMappingExposure = 1.0 + + const pmrem = new PMREMGenerator(renderer) + const prefiltered = pmrem.fromEquirectangular(envTexture).texture + + const scene = new Scene() + scene.background = envTexture + scene.environment = prefiltered + + const camera = new PerspectiveCamera(45, width / height, 0.01, 1000) + + const geometry = new SphereGeometry(1, 64, 64) + const material = new MeshStandardMaterial({ + color: 0xffffff, + metalness: 1.0, + roughness: 0.08, + envMapIntensity: 1.2, + }) + scene.add(new Mesh(geometry, material)) + + const fov = camera.fov * (Math.PI / 180) + const distance = (2.0 / (2 * Math.tan(fov / 2))) * 1.8 + + const thumbnailBlob = await renderOrbitAnimation( + renderer, + scene, + camera, + distance, + width, + height + ) + + renderer.dispose() + pmrem.dispose() + geometry.dispose() + material.dispose() + envTexture.dispose() + prefiltered.dispose() + return thumbnailBlob + } catch { + return generatePlaceholderThumbnail(width, height, '#4a90d9') + } +} + // ─── Audio Waveforms ──────────────────────────────────────────────────── /** diff --git a/src/frontend/src/shared/components/UploadDropZone.css b/src/frontend/src/shared/components/UploadDropZone.css new file mode 100644 index 00000000..8859d8bc --- /dev/null +++ b/src/frontend/src/shared/components/UploadDropZone.css @@ -0,0 +1,19 @@ +/* Canonical drop-zone visual. + * Single source of truth for the file-drop affordance. The transparent + * baseline border keeps layout stable when the active state turns the + * border solid; only the color/background change on .drag-over so the + * page does not jump. + */ +.upload-drop-zone { + position: relative; + border: 2px dashed transparent; + background-color: transparent; + transition: + border-color 0.18s ease, + background-color 0.18s ease; +} + +.upload-drop-zone.drag-over { + border-color: var(--primary-color, #3182ce); + background-color: rgba(49, 130, 206, 0.04); +} diff --git a/src/frontend/src/shared/components/UploadDropZone.tsx b/src/frontend/src/shared/components/UploadDropZone.tsx new file mode 100644 index 00000000..9707ebee --- /dev/null +++ b/src/frontend/src/shared/components/UploadDropZone.tsx @@ -0,0 +1,47 @@ +import { forwardRef, ReactNode } from 'react' + +import { useDragAndDrop } from '@/shared/hooks/useFileUpload' + +import './UploadDropZone.css' + +interface UploadDropZoneProps { + onFilesDropped: (files: File[]) => void + children: ReactNode + className?: string + disabled?: boolean +} + +/** + * Canonical drop-zone wrapper. Provides a single visual treatment (dashed + * primary-color border + subtle background) for file drag-and-drop across + * the app, and routes drops to `onFilesDropped`. + * + * The wrapped element receives `.upload-drop-zone` plus any caller-provided + * `className` for layout, and toggles `.drag-over` while a file drag is over + * it. Tab drags and other non-file drags are ignored at the hook level. + * + * Pass `disabled` to opt out (e.g. while a modal owns drag handling). + */ +export const UploadDropZone = forwardRef( + function UploadDropZone( + { onFilesDropped, children, className, disabled = false }, + ref + ) { + const handlers = useDragAndDrop(onFilesDropped) + const dragHandlers = disabled ? {} : handlers + + return ( +
+ {children} +
+ ) + } +) + +export default UploadDropZone diff --git a/src/frontend/src/shared/components/UploadableGrid.css b/src/frontend/src/shared/components/UploadableGrid.css deleted file mode 100644 index f33bdd64..00000000 --- a/src/frontend/src/shared/components/UploadableGrid.css +++ /dev/null @@ -1,67 +0,0 @@ -.uploadable-grid-container { - position: relative; - min-height: 200px; -} - -.uploadable-grid-container.dragging { - opacity: 0.6; -} - -.uploadable-grid-overlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(59, 130, 246, 0.1); - border: 2px dashed #3b82f6; - border-radius: 8px; - display: flex; - align-items: center; - justify-content: center; - pointer-events: none; - z-index: 100; -} - -.uploadable-grid-overlay-content { - text-align: center; - color: #3b82f6; -} - -.uploadable-grid-overlay-content i { - font-size: 3rem; - display: block; - margin-bottom: 1rem; -} - -.uploadable-grid-overlay-content p { - font-size: 1.1rem; - font-weight: 500; - margin: 0; -} - -.uploadable-grid-loading { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - text-align: center; - background: rgba(255, 255, 255, 0.95); - padding: 2rem; - border-radius: 8px; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); - z-index: 101; -} - -.uploadable-grid-loading i { - font-size: 2.5rem; - color: #3b82f6; - display: block; - margin-bottom: 1rem; -} - -.uploadable-grid-loading p { - margin: 0; - font-size: 1rem; - color: #666; -} diff --git a/src/frontend/src/shared/components/UploadableGrid.tsx b/src/frontend/src/shared/components/UploadableGrid.tsx deleted file mode 100644 index ad33ee73..00000000 --- a/src/frontend/src/shared/components/UploadableGrid.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import './UploadableGrid.css' - -import { type ReactNode, useCallback, useRef, useState } from 'react' - -interface UploadableGridProps { - children: ReactNode - onFilesDropped: (files: File[]) => void - isUploading?: boolean - uploadMessage?: string - className?: string -} - -export function UploadableGrid({ - children, - onFilesDropped, - isUploading = false, - uploadMessage = 'Drop files here to upload', - className = '', -}: UploadableGridProps) { - const [isDragging, setIsDragging] = useState(false) - const dragCounter = useRef(0) - - const handleDragEnter = useCallback((e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - dragCounter.current++ - if (dragCounter.current === 1) { - setIsDragging(true) - } - }, []) - - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - dragCounter.current-- - if (dragCounter.current === 0) { - setIsDragging(false) - } - }, []) - - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - }, []) - - const handleDrop = useCallback( - (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - dragCounter.current = 0 - setIsDragging(false) - - const files = Array.from(e.dataTransfer.files) - if (files.length > 0) { - onFilesDropped(files) - } - }, - [onFilesDropped] - ) - - return ( -
- {children} - {isDragging && ( -
-
- -

{uploadMessage}

-
-
- )} - {isUploading && ( -
- -

Uploading...

-
- )} -
- ) -} diff --git a/src/frontend/src/shared/components/container-tabs/ContainerEnvironmentMapsTab.tsx b/src/frontend/src/shared/components/container-tabs/ContainerEnvironmentMapsTab.tsx index 5b6c0880..e87003a0 100644 --- a/src/frontend/src/shared/components/container-tabs/ContainerEnvironmentMapsTab.tsx +++ b/src/frontend/src/shared/components/container-tabs/ContainerEnvironmentMapsTab.tsx @@ -8,7 +8,7 @@ import { useEffect, useRef } from 'react' import { getEnvironmentMapPrimaryPreviewUrl } from '@/features/environment-map/utils/environmentMapUtils' import { useTabContext } from '@/hooks/useTabContext' -import { UploadableGrid } from '@/shared/components' +import { UploadDropZone } from '@/shared/components/UploadDropZone' import { useContainerEnvironmentMaps } from '@/shared/hooks/useContainerEnvironmentMaps' import { type ContainerAdapter } from '@/shared/types/ContainerTypes' @@ -60,10 +60,8 @@ export function ContainerEnvironmentMapsTab({ <> -
@@ -137,7 +135,7 @@ export function ContainerEnvironmentMapsTab({
) : null}
- + -
@@ -108,7 +106,7 @@ export function ContainerSoundsTab({
)}
- + {/* Add Sound Dialog */} -
@@ -160,7 +158,7 @@ export function ContainerSpritesTab({
)}
- + {/* Add Sprite Dialog */} -
@@ -141,7 +139,7 @@ export function ContainerTextureSetsTab({
)}
- + ({ batches: [], isVisible: false, addUpload: jest.fn((file, fileType, batchId) => `upload-${Date.now()}`), + addUploads: jest.fn((files, fileType, batchId) => + files.map((_: File, i: number) => `upload-${Date.now()}-${i}`) + ), updateUploadProgress: jest.fn(), completeUpload: jest.fn(), failUpload: jest.fn(), diff --git a/src/frontend/src/shared/hooks/useFileUpload.ts b/src/frontend/src/shared/hooks/useFileUpload.ts index 3de75ceb..ab768605 100644 --- a/src/frontend/src/shared/hooks/useFileUpload.ts +++ b/src/frontend/src/shared/hooks/useFileUpload.ts @@ -8,6 +8,8 @@ import { isThreeJSRenderable, } from '../../utils/fileUtils' +const UPLOAD_CONCURRENCY = 4 + /** * Custom hook for handling file uploads with validation and progress tracking * @param {Object} options - Configuration options @@ -36,36 +38,20 @@ export function useFileUpload(options = {}) { const uploadProgressContext = useUploadProgress() /** - * Upload a single file - * @param {File} file - File to upload - * @param {string} uploadId - Optional upload ID for global progress tracking - * @param {string} batchId - Optional batch ID for backend tracking - * @returns {Promise} Upload result + * Validate a file pre-upload. Returns an error object on failure, null on pass. */ - const uploadSingleFile = async (file, uploadId = null, batchId = null) => { - if (!file) { - throw new Error('No file provided') - } - + const validateFile = file => { const fileExtension = '.' + file.name.split('.').pop().toLowerCase() - // Validate file format if (!isSupportedModelFormat(fileExtension)) { const error = new Error( `File ${file.name} is not a supported 3D model format` ) error.type = 'UNSUPPORTED_FORMAT' - - // Update global progress if enabled and available - if (useGlobalProgress && uploadId && uploadProgressContext) { - uploadProgressContext.failUpload(uploadId, error) - } - - throw error + return error } - // Check Three.js renderability if required - // .blend files bypass this check when blenderEnabled is true — they're handled by the asset-processor + // .blend files bypass renderability — handled by the asset-processor const isBlendFile = fileExtension === '.blend' if ( requireThreeJSRenderable && @@ -76,24 +62,39 @@ export function useFileUpload(options = {}) { `File ${file.name} (${fileExtension.toUpperCase()}) is supported but not renderable in 3D viewer. Use the upload page for this file type.` ) error.type = 'NON_RENDERABLE' + return error + } + + return null + } - // Update global progress if enabled and available + /** + * Upload a single file + * @param {File} file - File to upload + * @param {string} uploadId - Optional upload ID for global progress tracking + * @param {string} batchId - Optional batch ID for backend tracking + * @returns {Promise} Upload result + */ + const uploadSingleFile = async (file, uploadId = null, batchId = null) => { + if (!file) { + throw new Error('No file provided') + } + + const validationError = validateFile(file) + if (validationError) { if (useGlobalProgress && uploadId && uploadProgressContext) { - uploadProgressContext.failUpload(uploadId, error) + uploadProgressContext.failUpload(uploadId, validationError) } - - throw error + throw validationError } try { - // Update global progress if enabled and available if (useGlobalProgress && uploadId && uploadProgressContext) { uploadProgressContext.updateUploadProgress(uploadId, 50) } const result = await uploadModel(file, { batchId }) - // Update global progress if enabled and available if (useGlobalProgress && uploadId && uploadProgressContext) { uploadProgressContext.updateUploadProgress(uploadId, 100) uploadProgressContext.completeUpload(uploadId, result) @@ -101,7 +102,6 @@ export function useFileUpload(options = {}) { return result } catch (error) { - // Update global progress if enabled and available if (useGlobalProgress && uploadId && uploadProgressContext) { uploadProgressContext.failUpload(uploadId, error) } @@ -114,7 +114,8 @@ export function useFileUpload(options = {}) { } /** - * Upload multiple files with progress tracking + * Upload multiple files with progress tracking. Files are uploaded with bounded + * concurrency to avoid overwhelming the server while still being faster than serial. * @param {FileList|File[]} files - Files to upload * @returns {Promise} Upload results summary */ @@ -124,77 +125,98 @@ export function useFileUpload(options = {}) { } const fileArray = Array.from(files) + const total = fileArray.length const results = { succeeded: [], failed: [], - total: fileArray.length, + total, } setUploading(true) setUploadProgress(0) - // Create batch for multiple files - const batchId = - useGlobalProgress && uploadProgressContext && fileArray.length > 1 - ? uploadProgressContext.createBatch() - : undefined + const useStore = + useGlobalProgress && uploadProgressContext && total > 0 - try { - for (let i = 0; i < fileArray.length; i++) { - const file = fileArray[i] - - // Add to global progress tracker if enabled and available - const uploadId = - useGlobalProgress && uploadProgressContext - ? uploadProgressContext.addUpload(file, fileType, batchId) - : null - - try { - const result = await uploadSingleFile(file, uploadId, batchId) - results.succeeded.push({ file, result }) - } catch (error) { - results.failed.push({ file, error }) - - // Show error notification if toast is provided - if (toast?.current) { - const severity = - error.type === 'UNSUPPORTED_FORMAT' || - error.type === 'NON_RENDERABLE' - ? 'warn' - : 'error' - const summary = - error.type === 'UNSUPPORTED_FORMAT' - ? 'Unsupported File' - : error.type === 'NON_RENDERABLE' - ? 'Non-renderable Format' - : 'Upload Failed' - - toast.current.show({ - severity, - summary, - detail: error.message, - }) - } + // Create batch + reserve all upload IDs in a single store update. + // This avoids N separate addUpload calls (each cloning state) when many + // files are dropped. + const batchId = + useStore && total > 1 ? uploadProgressContext.createBatch() : undefined + + const uploadIds = useStore + ? uploadProgressContext.addUploads(fileArray, fileType, batchId) + : new Array(total).fill(null) + + let completed = 0 + const handleFileResult = (file, error) => { + completed++ + const progress = Math.round((completed / total) * 100 * 100) / 100 + setUploadProgress(progress) + + if (error) { + results.failed.push({ file, error }) + + if (toast?.current) { + const severity = + error.type === 'UNSUPPORTED_FORMAT' || + error.type === 'NON_RENDERABLE' + ? 'warn' + : 'error' + const summary = + error.type === 'UNSUPPORTED_FORMAT' + ? 'Unsupported File' + : error.type === 'NON_RENDERABLE' + ? 'Non-renderable Format' + : 'Upload Failed' + + toast.current.show({ + severity, + summary, + detail: error.message, + }) + } - if (onError) { - onError(file, error) - } + if (onError) { + onError(file, error) } + } + } - // Round progress to 2 decimal places - const progress = - Math.round(((i + 1) / fileArray.length) * 100 * 100) / 100 - setUploadProgress(progress) + const runOne = async index => { + const file = fileArray[index] + const uploadId = uploadIds[index] + try { + const result = await uploadSingleFile(file, uploadId, batchId) + results.succeeded.push({ file, result }) + handleFileResult(file, null) + } catch (error) { + handleFileResult(file, error) } + } + + try { + // Bounded concurrency pool: keep UPLOAD_CONCURRENCY workers busy until + // the queue is drained. Order of completion is unspecified; results + // arrays are populated as each upload finishes. + let next = 0 + const workers = new Array(Math.min(UPLOAD_CONCURRENCY, total)) + .fill(null) + .map(async () => { + while (true) { + const index = next++ + if (index >= total) return + await runOne(index) + } + }) + await Promise.all(workers) - // Call onSuccess once after all uploads complete (if any succeeded) if (onSuccess && results.succeeded.length > 0) { onSuccess(null, results) } return results } catch (err) { - // Handle unexpected errors if (toast?.current) { toast.current.show({ severity: 'error', @@ -218,7 +240,6 @@ export function useFileUpload(options = {}) { setUploading(true) setUploadProgress(0) - // Add to global progress tracker if enabled and available const uploadId = useGlobalProgress && uploadProgressContext ? uploadProgressContext.addUpload(file, fileType) @@ -254,19 +275,56 @@ export function useFileUpload(options = {}) { } } +// --------------------------------------------------------------------------- +// Shared drag coordinator +// --------------------------------------------------------------------------- +// All instances of useDragAndDrop share a single pair of window listeners. +// When the first instance mounts we attach; when the last unmounts we detach. +// Each instance registers a clear-callback that runs on global dragend/drop. + +type DragSubscriber = () => void +const dragSubscribers: Set = new Set() +let dragListenersAttached = false +let attachedDragEndHandler: ((e: Event) => void) | null = null +let attachedDropHandler: ((e: Event) => void) | null = null + +function notifyDragSubscribers() { + dragSubscribers.forEach(fn => fn()) +} + +function attachDragListeners() { + if (dragListenersAttached) return + attachedDragEndHandler = () => notifyDragSubscribers() + attachedDropHandler = () => notifyDragSubscribers() + window.addEventListener('dragend', attachedDragEndHandler) + window.addEventListener('drop', attachedDropHandler) + dragListenersAttached = true +} + +function detachDragListeners() { + if (!dragListenersAttached) return + if (attachedDragEndHandler) { + window.removeEventListener('dragend', attachedDragEndHandler) + attachedDragEndHandler = null + } + if (attachedDropHandler) { + window.removeEventListener('drop', attachedDropHandler) + attachedDropHandler = null + } + dragListenersAttached = false +} + /** * Utility function to create drag and drop handlers * @param {Function} onFilesDropped - Callback when files are dropped * @returns {Object} Drag and drop event handlers */ export function useDragAndDrop(onFilesDropped) { - // Use a ref to track nested drag enter/leave events - // This prevents flickering when dragging over child elements + // Track nested drag enter/leave to prevent flickering when dragging over + // child elements (counter-based approach). const dragCounterRef = useRef(0) const dragTargetRef = useRef(null) - // Clear drag state helper function - // Wrapped in useCallback to ensure stable reference across renders const clearDragState = useCallback(() => { dragCounterRef.current = 0 document.body.classList.remove('dragging-file') @@ -276,25 +334,16 @@ export function useDragAndDrop(onFilesDropped) { } }, []) - // Set up global event listeners to handle edge cases where drag leaves the window + // Subscribe this instance's clear callback to the shared coordinator. useEffect(() => { - const handleDragEnd = () => { - // When drag operation ends anywhere, clear the drag state - clearDragState() - } - - const handleDrop = () => { - // When drop happens anywhere in the document, clear the drag state - clearDragState() - } - - // Add listeners to window to catch drag operations that end outside our drop zones - window.addEventListener('dragend', handleDragEnd) - window.addEventListener('drop', handleDrop) + dragSubscribers.add(clearDragState) + attachDragListeners() return () => { - window.removeEventListener('dragend', handleDragEnd) - window.removeEventListener('drop', handleDrop) + dragSubscribers.delete(clearDragState) + if (dragSubscribers.size === 0) { + detachDragListeners() + } // Clean up any lingering drag state on unmount clearDragState() } @@ -304,10 +353,8 @@ export function useDragAndDrop(onFilesDropped) { e.preventDefault() e.stopPropagation() - // Clear drag state immediately and unconditionally clearDragState() - // Only process files if they are actually present if ( e.dataTransfer && e.dataTransfer.files && @@ -315,12 +362,9 @@ export function useDragAndDrop(onFilesDropped) { ) { const files = Array.from(e.dataTransfer.files) - // Call the callback in a try-catch to ensure drag state is always cleared - // even if the callback throws an error try { onFilesDropped(files) } catch (error) { - // Ensure drag state is cleared even if callback fails clearDragState() throw error } @@ -331,8 +375,8 @@ export function useDragAndDrop(onFilesDropped) { e.preventDefault() e.stopPropagation() - // Only add drag visual feedback if files are being dragged - // This prevents tab drags from interfering with the UI + // Only add drag visual feedback if files are being dragged. + // This prevents tab drags (text/plain, application/json) from interfering. if ( e.dataTransfer && e.dataTransfer.types && @@ -340,7 +384,6 @@ export function useDragAndDrop(onFilesDropped) { ) { dragCounterRef.current++ - // Store reference to the drag target if (dragCounterRef.current === 1) { dragTargetRef.current = e.currentTarget document.body.classList.add('dragging-file') @@ -353,7 +396,6 @@ export function useDragAndDrop(onFilesDropped) { e.preventDefault() e.stopPropagation() - // Only decrement for file drags if ( e.dataTransfer && e.dataTransfer.types && @@ -361,13 +403,7 @@ export function useDragAndDrop(onFilesDropped) { ) { dragCounterRef.current-- - // Only remove classes when we've left all nested elements (counter reaches 0) - if (dragCounterRef.current === 0) { - clearDragState() - } - - // Safety check: prevent negative counter - if (dragCounterRef.current < 0) { + if (dragCounterRef.current <= 0) { clearDragState() } } @@ -376,7 +412,6 @@ export function useDragAndDrop(onFilesDropped) { const onDragOver = e => { e.preventDefault() e.stopPropagation() - // Don't add any visual feedback here - it's handled in onDragEnter } return { diff --git a/src/frontend/src/stores/uploadProgressStore.ts b/src/frontend/src/stores/uploadProgressStore.ts index beff6dcc..367a9ce9 100644 --- a/src/frontend/src/stores/uploadProgressStore.ts +++ b/src/frontend/src/stores/uploadProgressStore.ts @@ -1,5 +1,13 @@ import { create } from 'zustand' +export type UploadFileType = + | 'model' + | 'texture' + | 'file' + | 'sprite' + | 'sound' + | 'environmentMap' + export interface UploadItem { id: string file: File @@ -7,7 +15,7 @@ export interface UploadItem { status: 'pending' | 'uploading' | 'completed' | 'error' result?: unknown error?: Error - fileType: 'model' | 'texture' | 'file' | 'sprite' | 'sound' | 'environmentMap' + fileType: UploadFileType batchId?: string // ID of the batch this upload belongs to } @@ -22,17 +30,12 @@ interface UploadProgressStore { uploads: UploadItem[] batches: UploadBatch[] isVisible: boolean - addUpload: ( - file: File, - fileType: - | 'model' - | 'texture' - | 'file' - | 'sprite' - | 'sound' - | 'environmentMap', + addUpload: (file: File, fileType: UploadFileType, batchId?: string) => string + addUploads: ( + files: File[], + fileType: UploadFileType, batchId?: string - ) => string + ) => string[] updateUploadProgress: (id: string, progress: number) => void completeUpload: (id: string, result?: unknown) => void updateUploadResult: (id: string, result: unknown) => void @@ -45,6 +48,41 @@ interface UploadProgressStore { createBatch: () => string } +// Apply a per-item update to both the flat uploads array and the matching +// batch's nested files array. Only the affected batch is cloned — other +// batches keep their previous reference, which avoids O(N²) work when many +// items progress in parallel. +function applyItemUpdate( + state: { uploads: UploadItem[]; batches: UploadBatch[] }, + id: string, + updater: (item: UploadItem) => UploadItem +): { uploads: UploadItem[]; batches: UploadBatch[] } { + let uploadsChanged = false + const newUploads = state.uploads.map(upload => { + if (upload.id !== id) return upload + uploadsChanged = true + return updater(upload) + }) + + let batchesChanged = false + const newBatches = state.batches.map(batch => { + let fileFound = false + const newFiles = batch.files.map(upload => { + if (upload.id !== id) return upload + fileFound = true + return updater(upload) + }) + if (!fileFound) return batch + batchesChanged = true + return { ...batch, files: newFiles } + }) + + return { + uploads: uploadsChanged ? newUploads : state.uploads, + batches: batchesChanged ? newBatches : state.batches, + } +} + export const useUploadProgressStore = create(set => ({ uploads: [], batches: [], @@ -61,17 +99,7 @@ export const useUploadProgressStore = create(set => ({ return batchId }, - addUpload: ( - file: File, - fileType: - | 'model' - | 'texture' - | 'file' - | 'sprite' - | 'sound' - | 'environmentMap', - batchId?: string - ) => { + addUpload: (file, fileType, batchId) => { const id = `upload-${Date.now()}-${Math.random()}` const newUpload: UploadItem = { id, @@ -100,117 +128,91 @@ export const useUploadProgressStore = create(set => ({ return id }, - updateUploadProgress: (id: string, progress: number) => { - set(state => { - const newUploads = state.uploads.map(upload => - upload.id === id - ? { ...upload, progress, status: 'uploading' as const } - : upload - ) - - // Update batches too - const newBatches = state.batches.map(batch => ({ - ...batch, - files: batch.files.map(upload => - upload.id === id - ? { ...upload, progress, status: 'uploading' as const } - : upload - ), - })) - + addUploads: (files, fileType, batchId) => { + if (files.length === 0) return [] + const ids: string[] = [] + const newItems: UploadItem[] = files.map((file, index) => { + const id = `upload-${Date.now()}-${index}-${Math.random()}` + ids.push(id) return { - uploads: newUploads, - batches: newBatches, + id, + file, + progress: 0, + status: 'pending', + fileType, + batchId, } }) - }, - - completeUpload: (id: string, result?: unknown) => { set(state => { - const newUploads = state.uploads.map(upload => - upload.id === id - ? { ...upload, progress: 100, status: 'completed' as const, result } - : upload - ) - - const newBatches = state.batches.map(batch => ({ - ...batch, - files: batch.files.map(upload => - upload.id === id - ? { - ...upload, - progress: 100, - status: 'completed' as const, - result, - } - : upload - ), - })) - + const newUploads = state.uploads.concat(newItems) + const newBatches = batchId + ? state.batches.map(batch => + batch.id === batchId + ? { ...batch, files: batch.files.concat(newItems) } + : batch + ) + : state.batches return { uploads: newUploads, batches: newBatches, + isVisible: true, } }) + return ids }, - updateUploadResult: (id: string, result: unknown) => { - set(state => { - const newUploads = state.uploads.map(upload => - upload.id === id - ? { ...upload, result: { ...upload.result, ...result } } - : upload - ) - - const newBatches = state.batches.map(batch => ({ - ...batch, - files: batch.files.map(upload => - upload.id === id - ? { ...upload, result: { ...upload.result, ...result } } - : upload - ), + updateUploadProgress: (id, progress) => { + set(state => + applyItemUpdate(state, id, upload => ({ + ...upload, + progress, + status: 'uploading' as const, })) - - return { - uploads: newUploads, - batches: newBatches, - } - }) + ) }, - failUpload: (id: string, error: Error) => { - set(state => { - const newUploads = state.uploads.map(upload => - upload.id === id - ? { ...upload, status: 'error' as const, error } - : upload - ) + completeUpload: (id, result) => { + set(state => + applyItemUpdate(state, id, upload => ({ + ...upload, + progress: 100, + status: 'completed' as const, + result, + })) + ) + }, - const newBatches = state.batches.map(batch => ({ - ...batch, - files: batch.files.map(upload => - upload.id === id - ? { ...upload, status: 'error' as const, error } - : upload - ), + updateUploadResult: (id, result) => { + set(state => + applyItemUpdate(state, id, upload => ({ + ...upload, + result: { ...(upload.result as object), ...(result as object) }, })) + ) + }, - return { - uploads: newUploads, - batches: newBatches, - } - }) + failUpload: (id, error) => { + set(state => + applyItemUpdate(state, id, upload => ({ + ...upload, + status: 'error' as const, + error, + })) + ) }, removeUpload: (id: string) => { set(state => { const newUploads = state.uploads.filter(upload => upload.id !== id) const newBatches = state.batches - .map(batch => ({ - ...batch, - files: batch.files.filter(upload => upload.id !== id), - })) - .filter(batch => batch.files.length > 0) // Remove empty batches + .map(batch => { + const idx = batch.files.findIndex(upload => upload.id === id) + if (idx === -1) return batch + const newFiles = batch.files.slice() + newFiles.splice(idx, 1) + return { ...batch, files: newFiles } + }) + .filter(batch => batch.files.length > 0) return { uploads: newUploads, @@ -226,12 +228,18 @@ export const useUploadProgressStore = create(set => ({ ) const newBatches = state.batches - .map(batch => ({ - ...batch, - files: batch.files.filter( - upload => upload.status !== 'completed' && upload.status !== 'error' - ), - })) + .map(batch => { + const hasFinished = batch.files.some( + upload => + upload.status === 'completed' || upload.status === 'error' + ) + if (!hasFinished) return batch + const newFiles = batch.files.filter( + upload => + upload.status !== 'completed' && upload.status !== 'error' + ) + return { ...batch, files: newFiles } + }) .filter(batch => batch.files.length > 0) return {