diff --git a/ui/src/app/datasets/[datasetName]/page.tsx b/ui/src/app/datasets/[datasetName]/page.tsx index d3bf68fc..dcba4736 100644 --- a/ui/src/app/datasets/[datasetName]/page.tsx +++ b/ui/src/app/datasets/[datasetName]/page.tsx @@ -1,12 +1,14 @@ 'use client'; -import { useEffect, useState, use } from 'react'; +import { useEffect, useState, use, useMemo } from 'react'; +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 { 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 }[]>([]); @@ -38,6 +40,56 @@ export default function DatasetPage({ params }: { params: { datasetName: string } }, [datasetName]); + const PageInfoContent = useMemo(() => { + let icon = null; + let text = ''; + let subtitle = ''; + let showIt = false; + let bgColor = ''; + let textColor = ''; + let iconColor = ''; + + if (status == 'loading') { + icon = ; + text = 'Loading Images'; + subtitle = 'Please wait while we fetch your dataset images...'; + showIt = true; + bgColor = 'bg-gray-50 dark:bg-gray-800/50'; + textColor = 'text-gray-900 dark:text-gray-100'; + iconColor = 'text-gray-500 dark:text-gray-400'; + } + if (status == 'error') { + icon = ; + text = 'Error Loading Images'; + subtitle = 'There was a problem fetching the images. Please try refreshing the page.'; + showIt = true; + bgColor = 'bg-red-50 dark:bg-red-950/20'; + textColor = 'text-red-900 dark:text-red-100'; + iconColor = 'text-red-600 dark:text-red-400'; + } + if (status == 'success' && imgList.length === 0) { + icon = ; + text = 'No Images Found'; + subtitle = 'This dataset is empty. Click "Add Images" to get started.'; + showIt = true; + bgColor = 'bg-gray-50 dark:bg-gray-800/50'; + textColor = 'text-gray-900 dark:text-gray-100'; + iconColor = 'text-gray-500 dark:text-gray-400'; + } + + if (!showIt) return null; + + return ( +
+
{icon}
+

{text}

+

{subtitle}

+
+ ); + }, [status, imgList.length]); + return ( <> {/* Fixed top bar */} @@ -61,11 +113,9 @@ export default function DatasetPage({ params }: { params: { datasetName: string - {status === 'loading' &&

Loading...

} - {status === 'error' &&

Error fetching images

} - {status === 'success' && ( + {PageInfoContent} + {status === 'success' && imgList.length > 0 && (
- {imgList.length === 0 &&

No images found

} {imgList.map(img => ( + refreshImageList(datasetName)} + /> ); } diff --git a/ui/src/components/FullscreenDropOverlay.tsx b/ui/src/components/FullscreenDropOverlay.tsx new file mode 100644 index 00000000..425b7aef --- /dev/null +++ b/ui/src/components/FullscreenDropOverlay.tsx @@ -0,0 +1,182 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { FaUpload } from 'react-icons/fa'; +import { apiClient } from '@/utils/api'; + +type AcceptMap = { + [mime: string]: string[]; +}; + +interface FullscreenDropOverlayProps { + datasetName: string; // where to upload + onComplete?: () => void; // called after successful upload + accept?: AcceptMap; // optional override + multiple?: boolean; // default true +} + +export default function FullscreenDropOverlay({ + datasetName, + onComplete, + accept, + multiple = true, +}: FullscreenDropOverlayProps) { + const [visible, setVisible] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const dragDepthRef = useRef(0); // drag-enter/leave tracking + + // 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; + dragDepthRef.current += 1; + setVisible(true); + e.preventDefault(); + }; + const onDragOver = (e: DragEvent) => { + if (!isFileDrag(e)) return; + // Must preventDefault to allow dropping in the browser + 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 onDrop = (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); + + return () => { + window.removeEventListener('dragenter', onDragEnter); + window.removeEventListener('dragover', onDragOver); + window.removeEventListener('dragleave', onDragLeave); + window.removeEventListener('drop', onDrop); + }; + }, [visible, isUploading]); + + const onDrop = useCallback( + async (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); + } + }, + [datasetName, onComplete], + ); + + const dropAccept = useMemo( + () => + accept || { + 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'], + 'video/*': ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.m4v', '.flv'], + 'text/*': ['.txt'], + }, + [accept], + ); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept: dropAccept, + multiple, + noClick: true, + noKeyboard: true, + // Prevent "folder opens" by browser if someone drags outside the overlay mid-drop: + preventDropOnDocument: true, + }); + + 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}%

+
+
+
+ + )} +
+
+
+
+ ); +}