diff --git a/ui/src/app/datasets/[datasetName]/page.tsx b/ui/src/app/datasets/[datasetName]/page.tsx index dcba4736..7d457f59 100644 --- a/ui/src/app/datasets/[datasetName]/page.tsx +++ b/ui/src/app/datasets/[datasetName]/page.tsx @@ -5,10 +5,9 @@ import { LuImageOff, LuLoader, LuBan } from 'react-icons/lu'; import { FaChevronLeft } from 'react-icons/fa'; import DatasetImageCard from '@/components/DatasetImageCard'; import { Button } from '@headlessui/react'; -import AddImagesModal, { openImagesModal } from '@/components/AddImagesModal'; +import AddImagesModal, { openImagesModal, useOpenImagesModalOnDrag } from '@/components/AddImagesModal'; import { TopBar, MainContent } from '@/components/layout'; import { apiClient } from '@/utils/api'; -import FullscreenDropOverlay from '@/components/FullscreenDropOverlay'; export default function DatasetPage({ params }: { params: { datasetName: string } }) { const [imgList, setImgList] = useState<{ img_path: string }[]>([]); @@ -34,6 +33,8 @@ export default function DatasetPage({ params }: { params: { datasetName: string setStatus('error'); }); }; + useOpenImagesModalOnDrag(datasetName, () => refreshImageList(datasetName)); + useEffect(() => { if (datasetName) { refreshImageList(datasetName); @@ -128,10 +129,6 @@ export default function DatasetPage({ params }: { params: { datasetName: string )} - refreshImageList(datasetName)} - /> ); } diff --git a/ui/src/components/AddImagesModal.tsx b/ui/src/components/AddImagesModal.tsx index 57d08300..074b7f7d 100644 --- a/ui/src/components/AddImagesModal.tsx +++ b/ui/src/components/AddImagesModal.tsx @@ -1,12 +1,15 @@ 'use client'; import { createGlobalState } from 'react-global-hooks'; import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react'; -import { FaUpload } from 'react-icons/fa'; -import { useEffect, useRef } from 'react'; +import { FaUpload, FaTimesCircle, FaSpinner } from 'react-icons/fa'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { apiClient } from '@/utils/api'; export interface AddImagesModalState { datasetName: string; onComplete?: () => void; + openedByDrag?: boolean; } export const addImagesModalState = createGlobalState(null); @@ -15,39 +18,227 @@ export const openImagesModal = (datasetName: string, onComplete: () => void) => addImagesModalState.set({ datasetName, onComplete }); }; -export default function AddImagesModal() { - const [addImagesModalInfo, setAddImagesModalInfo] = addImagesModalState.use(); - const open = addImagesModalInfo !== null; - const panelRef = useRef(null); +/** Call on a page that knows its datasetName — auto-opens the modal when files are dragged onto the page. */ +export function useOpenImagesModalOnDrag(datasetName: string, onComplete: () => void) { + const onCompleteRef = useRef(onComplete); + onCompleteRef.current = onComplete; - const onCancel = () => { - setAddImagesModalInfo(null); - }; - - const onDone = () => { - if (addImagesModalInfo?.onComplete) { - addImagesModalInfo.onComplete(); - } - setAddImagesModalInfo(null); - }; - - // Close modal as soon as files are dragged in so the FullscreenDropOverlay can handle the drop useEffect(() => { - if (!open) return; + if (!datasetName) return; - const handleDragEnter = (e: DragEvent) => { + let depth = 0; + const isFileDrag = (e: DragEvent) => { const types = e?.dataTransfer?.types; - if (types && Array.from(types).includes('Files')) { - setAddImagesModalInfo(null); + return !!types && Array.from(types).includes('Files'); + }; + + const onDragEnter = (e: DragEvent) => { + if (!isFileDrag(e)) return; + depth += 1; + if (depth === 1) { + if (!addImagesModalState.get()) { + addImagesModalState.set({ datasetName, onComplete: onCompleteRef.current, openedByDrag: true }); + } + } + e.preventDefault(); + }; + const onDragLeave = (e: DragEvent) => { + if (!isFileDrag(e)) return; + depth = Math.max(0, depth - 1); + if (depth === 0) { + const current = addImagesModalState.get(); + if (current?.openedByDrag) { + addImagesModalState.set(null); + } + } + }; + const onDrop = (e: DragEvent) => { + if (!isFileDrag(e)) return; + depth = 0; + // Files were dropped — modal is now committed, no longer dismissable by drag-out + const current = addImagesModalState.get(); + if (current?.openedByDrag) { + addImagesModalState.set({ ...current, openedByDrag: false }); } }; - window.addEventListener('dragenter', handleDragEnter); - return () => window.removeEventListener('dragenter', handleDragEnter); - }, [open, setAddImagesModalInfo]); + window.addEventListener('dragenter', onDragEnter); + window.addEventListener('dragleave', onDragLeave); + window.addEventListener('drop', onDrop); + return () => { + window.removeEventListener('dragenter', onDragEnter); + window.removeEventListener('dragleave', onDragLeave); + window.removeEventListener('drop', onDrop); + }; + }, [datasetName]); +} + +type AcceptMap = { [mime: string]: string[] }; +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 AddImagesModal() { + const [modalInfo, setModalInfo] = addImagesModalState.use(); + const open = modalInfo !== null; + + const [isUploading, setIsUploading] = useState(false); + const [fileEntries, setFileEntries] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [doneCount, setDoneCount] = useState(0); + const [errorCount, setErrorCount] = useState(0); + const abortRef = useRef(false); + const modalInfoRef = useRef(modalInfo); + modalInfoRef.current = modalInfo; + + const datasetName = modalInfo?.datasetName ?? ''; + + 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, + }); + 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 resetState = useCallback(() => { + setFileEntries([]); + setTotalCount(0); + setDoneCount(0); + setErrorCount(0); + }, []); + + 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) { + modalInfoRef.current?.onComplete?.(); + setModalInfo(null); + resetState(); + } + }, + [uploadSingleFile, setModalInfo, resetState], + ); + + const onDrop = useCallback( + (acceptedFiles: File[]) => { + if (acceptedFiles.length === 0) return; + + 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); + }, + [processQueue], + ); + + const handleCancel = useCallback(() => { + abortRef.current = true; + setIsUploading(false); + setModalInfo(null); + resetState(); + }, [setModalInfo, resetState]); + + const dropAccept = useMemo( + () => ({ + 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'], + 'video/*': ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.m4v', '.flv'], + 'audio/*': ['.mp3', '.wav'], + 'text/*': ['.txt'], + }), + [], + ); + + const { + getRootProps, + getInputProps, + isDragActive, + open: openFilePicker, + } = useDropzone({ + onDrop, + accept: dropAccept, + multiple: true, + noClick: true, + noKeyboard: true, + }); + + const overallPercent = totalCount > 0 ? Math.round(((doneCount + errorCount) / totalCount) * 100) : 0; return ( - + { + if (!isUploading) handleCancel(); + }} + className="relative z-10" + >
- Add Images to: {addImagesModalInfo?.datasetName} + Add Images to: {datasetName} -
+ + {/* Drop zone + click to select */} +
+
{ + if (!isUploading) openFilePicker(); + }} + className={`h-40 w-full flex flex-col items-center justify-center border-2 border-dashed rounded-lg cursor-pointer transition-colors + ${isDragActive ? 'border-blue-400 bg-blue-500/10' : 'border-gray-600 hover:border-gray-400'}`} > -

- Drag & drop files anywhere on the page to upload -

+ {!isUploading ? ( + <> +

Drag & drop files here or click to select

+

Images, videos, or .txt supported

+ + ) : ( +

Drop more files to add to queue

+ )}
+ + {/* Upload progress */} + {isUploading && ( +
+

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

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

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

+ )} +
+ )} + + {/* File progress list */} + {fileEntries.length > 0 && ( +
+ +
+ )}
-
@@ -100,3 +323,60 @@ export default function AddImagesModal() {
); } + +/** Virtualized file progress list */ +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 ( +
+
+
+ {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} + +
+ ); +} diff --git a/ui/src/components/FullscreenDropOverlay.tsx b/ui/src/components/FullscreenDropOverlay.tsx deleted file mode 100644 index d5957faf..00000000 --- a/ui/src/components/FullscreenDropOverlay.tsx +++ /dev/null @@ -1,346 +0,0 @@ -'use client'; - -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useDropzone } from 'react-dropzone'; -import { FaUpload, FaTimesCircle, FaSpinner } from 'react-icons/fa'; -import { apiClient } from '@/utils/api'; - -type AcceptMap = { - [mime: string]: string[]; -}; - -interface FullscreenDropOverlayProps { - 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, - accept, - multiple = true, -}: FullscreenDropOverlayProps) { - const [visible, setVisible] = useState(false); - const [isUploading, setIsUploading] = useState(false); - 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); - - const isFileDrag = (e: DragEvent) => { - const types = e?.dataTransfer?.types; - return !!types && Array.from(types).includes('Files'); - }; - - useEffect(() => { - const onDragEnter = (e: DragEvent) => { - if (!isFileDrag(e)) return; - dragDepthRef.current += 1; - setVisible(true); - e.preventDefault(); - }; - const onDragOver = (e: DragEvent) => { - if (!isFileDrag(e)) return; - e.preventDefault(); - if (!visible) setVisible(true); - }; - const onDragLeave = (e: DragEvent) => { - if (!isFileDrag(e)) return; - dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); - if (dragDepthRef.current === 0 && !isUploading) { - setVisible(false); - } - }; - const onWindowDrop = (e: DragEvent) => { - if (!isFileDrag(e)) return; - e.preventDefault(); - dragDepthRef.current = 0; - }; - - window.addEventListener('dragenter', onDragEnter); - window.addEventListener('dragover', onDragOver); - window.addEventListener('dragleave', onDragLeave); - window.addEventListener('drop', onWindowDrop); - - return () => { - window.removeEventListener('dragenter', onDragEnter); - window.removeEventListener('dragover', onDragOver); - window.removeEventListener('dragleave', onDragLeave); - 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( - (acceptedFiles: File[]) => { - if (acceptedFiles.length === 0) { - setVisible(false); - return; - } - - 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); - }, - [processQueue], - ); - - const handleClose = useCallback(() => { - abortRef.current = true; - setIsUploading(false); - setFileEntries([]); - setVisible(false); - setTotalCount(0); - setDoneCount(0); - setErrorCount(0); - }, []); - - const dropAccept = useMemo( - () => - accept || { - 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'], - 'video/*': ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.m4v', '.flv'], - 'audio/*': ['.mp3', '.wav'], - 'text/*': ['.txt'], - }, - [accept], - ); - - const { getRootProps, getInputProps, isDragActive } = useDropzone({ - onDrop, - accept: dropAccept, - multiple, - noClick: true, - noKeyboard: true, - preventDropOnDocument: true, - }); - - const overallPercent = totalCount > 0 ? Math.round(((doneCount + errorCount) / totalCount) * 100) : 0; - - return ( -
- -
- -
-
- {/* 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} - -
- ); -}