diff --git a/ui/src/components/AddImagesModal.tsx b/ui/src/components/AddImagesModal.tsx index 82ad6c72..57d08300 100644 --- a/ui/src/components/AddImagesModal.tsx +++ b/ui/src/components/AddImagesModal.tsx @@ -2,9 +2,7 @@ import { createGlobalState } from 'react-global-hooks'; import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react'; import { FaUpload } from 'react-icons/fa'; -import { useCallback, useState } from 'react'; -import { useDropzone } from 'react-dropzone'; -import { apiClient } from '@/utils/api'; +import { useEffect, useRef } from 'react'; export interface AddImagesModalState { datasetName: string; @@ -19,69 +17,34 @@ export const openImagesModal = (datasetName: string, onComplete: () => void) => export default function AddImagesModal() { const [addImagesModalInfo, setAddImagesModalInfo] = addImagesModalState.use(); - const [uploadProgress, setUploadProgress] = useState(0); - const [isUploading, setIsUploading] = useState(false); const open = addImagesModalInfo !== null; + const panelRef = useRef(null); const onCancel = () => { - if (!isUploading) { - setAddImagesModalInfo(null); - } + setAddImagesModalInfo(null); }; const onDone = () => { - if (addImagesModalInfo?.onComplete && !isUploading) { + if (addImagesModalInfo?.onComplete) { addImagesModalInfo.onComplete(); - setAddImagesModalInfo(null); } + setAddImagesModalInfo(null); }; - const onDrop = useCallback( - async (acceptedFiles: File[]) => { - if (acceptedFiles.length === 0) return; + // Close modal as soon as files are dragged in so the FullscreenDropOverlay can handle the drop + useEffect(() => { + if (!open) return; - setIsUploading(true); - setUploadProgress(0); - - const formData = new FormData(); - acceptedFiles.forEach(file => { - formData.append('files', file); - }); - formData.append('datasetName', addImagesModalInfo?.datasetName || ''); - - try { - await apiClient.post(`/api/datasets/upload`, formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - onUploadProgress: progressEvent => { - const percentCompleted = Math.round((progressEvent.loaded * 100) / (progressEvent.total || 100)); - setUploadProgress(percentCompleted); - }, - timeout: 0, // Disable timeout - }); - - onDone(); - } catch (error) { - console.error('Upload failed:', error); - } finally { - setIsUploading(false); - setUploadProgress(0); + const handleDragEnter = (e: DragEvent) => { + const types = e?.dataTransfer?.types; + if (types && Array.from(types).includes('Files')) { + setAddImagesModalInfo(null); } - }, - [addImagesModalInfo], - ); + }; - const { getRootProps, getInputProps, isDragActive } = useDropzone({ - onDrop, - accept: { - 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'], - 'video/*': ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.m4v', '.flv'], - 'audio/*': ['.mp3', '.wav'], - 'text/*': ['.txt'], - }, - multiple: true, - }); + window.addEventListener('dragenter', handleDragEnter); + return () => window.removeEventListener('dragenter', handleDragEnter); + }, [open, setAddImagesModalInfo]); return ( @@ -93,6 +56,7 @@ export default function AddImagesModal() {
@@ -103,24 +67,13 @@ export default function AddImagesModal() {
-

- {isDragActive ? 'Drop the files here...' : 'Drag & drop files here, or click to select files'} + Drag & drop files anywhere on the page to upload

- {isUploading && ( -
-
-
-
-

Uploading... {uploadProgress}%

-
- )}
@@ -128,9 +81,7 @@ export default function AddImagesModal() { @@ -138,9 +89,7 @@ export default function AddImagesModal() { type="button" data-autofocus onClick={onCancel} - disabled={isUploading} - className={`mt-3 inline-flex w-full justify-center rounded-md bg-gray-800 px-3 py-2 text-sm font-semibold text-gray-200 hover:bg-gray-800 sm:mt-0 sm:w-auto ring-0 - ${isUploading ? 'opacity-50 cursor-not-allowed' : ''}`} + className="mt-3 inline-flex w-full justify-center rounded-md bg-gray-800 px-3 py-2 text-sm font-semibold text-gray-200 hover:bg-gray-800 sm:mt-0 sm:w-auto ring-0" > Cancel diff --git a/ui/src/components/FullscreenDropOverlay.tsx b/ui/src/components/FullscreenDropOverlay.tsx index 6cdd14f9..d5957faf 100644 --- a/ui/src/components/FullscreenDropOverlay.tsx +++ b/ui/src/components/FullscreenDropOverlay.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useDropzone } from 'react-dropzone'; -import { FaUpload } from 'react-icons/fa'; +import { FaUpload, FaTimesCircle, FaSpinner } from 'react-icons/fa'; import { apiClient } from '@/utils/api'; type AcceptMap = { @@ -10,12 +10,28 @@ type AcceptMap = { }; interface FullscreenDropOverlayProps { - datasetName: string; // where to upload - onComplete?: () => void; // called after successful upload - accept?: AcceptMap; // optional override - multiple?: boolean; // default true + datasetName: string; + onComplete?: () => void; + accept?: AcceptMap; + multiple?: boolean; } +type FileStatus = 'pending' | 'uploading' | 'error'; + +interface FileEntry { + id: number; + file: File; + status: FileStatus; + progress: number; + error?: string; +} + +const MAX_CONCURRENT = 3; +const ROW_HEIGHT = 32; +const VISIBLE_ROWS = 8; + +let nextId = 0; + export default function FullscreenDropOverlay({ datasetName, onComplete, @@ -24,16 +40,18 @@ export default function FullscreenDropOverlay({ }: FullscreenDropOverlayProps) { const [visible, setVisible] = useState(false); const [isUploading, setIsUploading] = useState(false); - const [uploadProgress, setUploadProgress] = useState(0); - const dragDepthRef = useRef(0); // drag-enter/leave tracking + const [fileEntries, setFileEntries] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [doneCount, setDoneCount] = useState(0); + const [errorCount, setErrorCount] = useState(0); + const dragDepthRef = useRef(0); + const abortRef = useRef(false); - // Only show the overlay for real file drags (not text, images from page, etc) const isFileDrag = (e: DragEvent) => { const types = e?.dataTransfer?.types; return !!types && Array.from(types).includes('Files'); }; - // Window-level drag listeners to toggle visibility useEffect(() => { const onDragEnter = (e: DragEvent) => { if (!isFileDrag(e)) return; @@ -43,7 +61,6 @@ export default function FullscreenDropOverlay({ }; const onDragOver = (e: DragEvent) => { if (!isFileDrag(e)) return; - // Must preventDefault to allow dropping in the browser e.preventDefault(); if (!visible) setVisible(true); }; @@ -54,63 +71,131 @@ export default function FullscreenDropOverlay({ setVisible(false); } }; - const onDrop = (e: DragEvent) => { + const onWindowDrop = (e: DragEvent) => { if (!isFileDrag(e)) return; - // Prevent browser from opening the file e.preventDefault(); dragDepthRef.current = 0; - // We do NOT hide here; the dropzone onDrop will handle workflow visibility. }; window.addEventListener('dragenter', onDragEnter); window.addEventListener('dragover', onDragOver); window.addEventListener('dragleave', onDragLeave); - window.addEventListener('drop', onDrop); + window.addEventListener('drop', onWindowDrop); return () => { window.removeEventListener('dragenter', onDragEnter); window.removeEventListener('dragover', onDragOver); window.removeEventListener('dragleave', onDragLeave); - window.removeEventListener('drop', onDrop); + window.removeEventListener('drop', onWindowDrop); }; }, [visible, isUploading]); + const uploadSingleFile = useCallback( + async (entry: FileEntry): Promise<'done' | 'error'> => { + if (abortRef.current) return 'error'; + + const id = entry.id; + + setFileEntries(prev => + prev.map(e => (e.id === id ? { ...e, status: 'uploading' as FileStatus, progress: 0 } : e)), + ); + + const formData = new FormData(); + formData.append('files', entry.file); + formData.append('datasetName', datasetName || ''); + + try { + await apiClient.post('/api/datasets/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: pe => { + const percent = Math.round(((pe.loaded || 0) * 100) / (pe.total || pe.loaded || 1)); + setFileEntries(prev => prev.map(e => (e.id === id ? { ...e, progress: percent } : e))); + }, + timeout: 0, + }); + // Remove from list on success + setFileEntries(prev => prev.filter(e => e.id !== id)); + setDoneCount(prev => prev + 1); + return 'done'; + } catch (err) { + console.error(`Upload failed for ${entry.file.name}:`, err); + setFileEntries(prev => + prev.map(e => + e.id === id + ? { ...e, status: 'error' as FileStatus, error: err instanceof Error ? err.message : 'Upload failed' } + : e, + ), + ); + setErrorCount(prev => prev + 1); + return 'error'; + } + }, + [datasetName], + ); + + const processQueue = useCallback( + async (entries: FileEntry[]) => { + setIsUploading(true); + abortRef.current = false; + + let nextIndex = 0; + + const runNext = async (): Promise => { + while (nextIndex < entries.length) { + if (abortRef.current) return; + const idx = nextIndex++; + await uploadSingleFile(entries[idx]); + } + }; + + const workers = Array.from({ length: Math.min(MAX_CONCURRENT, entries.length) }, () => runNext()); + await Promise.all(workers); + + setIsUploading(false); + if (!abortRef.current) { + onComplete?.(); + setFileEntries([]); + setVisible(false); + setTotalCount(0); + setDoneCount(0); + setErrorCount(0); + } + }, + [uploadSingleFile, onComplete], + ); + const onDrop = useCallback( - async (acceptedFiles: File[]) => { + (acceptedFiles: File[]) => { if (acceptedFiles.length === 0) { - // no accepted files; hide overlay cleanly setVisible(false); return; } - setIsUploading(true); - setUploadProgress(0); - - const formData = new FormData(); - acceptedFiles.forEach(file => formData.append('files', file)); - formData.append('datasetName', datasetName || ''); - - try { - await apiClient.post(`/api/datasets/upload`, formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - onUploadProgress: pe => { - const percent = Math.round(((pe.loaded || 0) * 100) / (pe.total || pe.loaded || 1)); - setUploadProgress(percent); - }, - timeout: 0, - }); - onComplete?.(); - } catch (err) { - console.error('Upload failed:', err); - } finally { - setIsUploading(false); - setUploadProgress(0); - setVisible(false); - } + const entries: FileEntry[] = acceptedFiles.map(file => ({ + id: nextId++, + file, + status: 'pending' as FileStatus, + progress: 0, + })); + setFileEntries(entries); + setTotalCount(entries.length); + setDoneCount(0); + setErrorCount(0); + processQueue(entries); }, - [datasetName, onComplete], + [processQueue], ); + const handleClose = useCallback(() => { + abortRef.current = true; + setIsUploading(false); + setFileEntries([]); + setVisible(false); + setTotalCount(0); + setDoneCount(0); + setErrorCount(0); + }, []); + const dropAccept = useMemo( () => accept || { @@ -128,56 +213,134 @@ export default function FullscreenDropOverlay({ multiple, noClick: true, noKeyboard: true, - // Prevent "folder opens" by browser if someone drags outside the overlay mid-drop: preventDropOnDocument: true, }); + const overallPercent = totalCount > 0 ? Math.round(((doneCount + errorCount) / totalCount) * 100) : 0; + return (
- {/* Fullscreen capture layer */} - - {/* Backdrop: keep it subtle so context remains visible */}
- {/* Center drop target UI */}
-
-
- - {!isUploading ? ( - <> -

Drop files to upload

-

- Destination: {datasetName || 'unknown'} -

-

Images, videos, or .txt supported

- - ) : ( - <> -

Uploading… {uploadProgress}%

-
-
-
- - )} +
+ {/* Drop target / status box */} +
+
+ + {!isUploading ? ( + <> +

Drop files to upload

+

+ Destination: {datasetName || 'unknown'} +

+

Images, videos, or .txt supported

+ + ) : ( + <> +

+ Uploading… {doneCount + errorCount} / {totalCount} +

+
+
+
+ {errorCount > 0 && ( +

+ {errorCount} file{errorCount !== 1 ? 's' : ''} failed +

+ )} + + + )} +
+ + {/* File progress list — only shows pending, uploading, and errored files */} + {fileEntries.length > 0 && }
); } + +/** Virtualized file progress list for handling thousands of files */ +function FileProgressList({ entries }: { entries: FileEntry[] }) { + const containerRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + + const totalHeight = entries.length * ROW_HEIGHT; + const containerHeight = Math.min(entries.length, VISIBLE_ROWS) * ROW_HEIGHT; + + const startIdx = Math.floor(scrollTop / ROW_HEIGHT); + const endIdx = Math.min(entries.length, startIdx + VISIBLE_ROWS + 2); + const visibleEntries = entries.slice(startIdx, endIdx); + const offsetY = startIdx * ROW_HEIGHT; + + const onScroll = useCallback(() => { + if (containerRef.current) { + setScrollTop(containerRef.current.scrollTop); + } + }, []); + + return ( +
e.stopPropagation()} + className="rounded-xl bg-black/60 backdrop-blur-sm border border-white/10 overflow-y-auto" + style={{ height: containerHeight + 2 }} + > +
+
+ {visibleEntries.map(entry => ( + + ))} +
+
+
+ ); +} + +function FileRow({ entry }: { entry: FileEntry }) { + return ( +
+ + {entry.status === 'error' && } + {entry.status === 'uploading' && } + {entry.status === 'pending' && } + + + + {entry.file.name} + + + + {entry.status === 'uploading' && {entry.progress}%} + {entry.status === 'error' && Failed} + {entry.status === 'pending' && Queued} + +
+ ); +}