'use client'; import { createGlobalState } from 'react-global-hooks'; import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/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); export const openImagesModal = (datasetName: string, onComplete: () => void) => { addImagesModalState.set({ datasetName, onComplete }); }; /** 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; useEffect(() => { if (!datasetName) return; let depth = 0; const isFileDrag = (e: DragEvent) => { const types = e?.dataTransfer?.types; 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', 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: {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'}`} > {!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 && (
)}
); } /** 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}
); }