Spaces:
Sleeping
Sleeping
import React, { useCallback, useState } from 'react'; | |
import { useDropzone } from 'react-dropzone'; | |
import { motion, AnimatePresence } from 'framer-motion'; | |
import { | |
CloudArrowUpIcon, | |
DocumentIcon, | |
CheckCircleIcon, | |
XCircleIcon, | |
XMarkIcon | |
} from '@heroicons/react/24/outline'; | |
import { uploadDocument } from '../services/api'; | |
import toast from 'react-hot-toast'; | |
const FileUploader = ({ darkMode, onClose }) => { | |
const [uploading, setUploading] = useState(false); | |
const [uploadedFiles, setUploadedFiles] = useState([]); | |
const onDrop = useCallback(async (acceptedFiles) => { | |
setUploading(true); | |
for (const file of acceptedFiles) { | |
try { | |
console.log(`Starting upload for file: ${file.name}, size: ${file.size} bytes`); | |
const formData = new FormData(); | |
formData.append('file', file); | |
// Check file size limits | |
const maxSize = 100 * 1024 * 1024; // 100MB | |
if (file.size > maxSize) { | |
toast.error(`${file.name} is too large (${formatFileSize(file.size)}). Maximum size is 100MB.`); | |
setUploadedFiles(prev => [...prev, { | |
name: file.name, | |
size: file.size, | |
status: 'error', | |
error: 'File too large (max 100MB)' | |
}]); | |
continue; // Skip this file | |
} else if (file.size > 10 * 1024 * 1024) { | |
toast(`Warning: ${file.name} is large (${formatFileSize(file.size)}). Upload may take time.`, { | |
icon: '⚠️', | |
duration: 6000 | |
}); | |
} | |
await uploadDocument(formData); | |
setUploadedFiles(prev => [...prev, { | |
name: file.name, | |
size: file.size, | |
status: 'success' | |
}]); | |
toast.success(`${file.name} uploaded successfully!`); | |
console.log(`Successfully uploaded: ${file.name}`); | |
} catch (error) { | |
console.error(`Failed to upload ${file.name}:`, error); | |
setUploadedFiles(prev => [...prev, { | |
name: file.name, | |
size: file.size, | |
status: 'error', | |
error: error.message | |
}]); | |
// More specific error messages | |
let errorMessage = `Failed to upload ${file.name}`; | |
if (error.message.includes('Network Error')) { | |
errorMessage += ': Network connection failed. Check if the server is running.'; | |
} else if (error.message.includes('timeout')) { | |
errorMessage += ': Upload timed out. File may be too large.'; | |
} else if (error.message.includes('413')) { | |
errorMessage += ': File too large (max 100MB allowed).'; | |
} else if (error.message.includes('415')) { | |
errorMessage += ': Unsupported file type.'; | |
} else if (error.message.includes('500')) { | |
errorMessage += ': Server error. Check server logs.'; | |
} else { | |
errorMessage += `: ${error.message}`; | |
} | |
toast.error(errorMessage); | |
} | |
} | |
setUploading(false); | |
}, []); | |
const { getRootProps, getInputProps, isDragActive } = useDropzone({ | |
onDrop, | |
accept: { | |
'application/pdf': ['.pdf'], | |
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], | |
'text/plain': ['.txt'] | |
}, | |
multiple: true | |
}); | |
const formatFileSize = (bytes) => { | |
if (bytes === 0) return '0 Bytes'; | |
const k = 1024; | |
const sizes = ['Bytes', 'KB', 'MB', 'GB']; | |
const i = Math.floor(Math.log(bytes) / Math.log(k)); | |
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; | |
}; | |
const removeFile = (index) => { | |
setUploadedFiles(prev => prev.filter((_, i) => i !== index)); | |
}; | |
return ( | |
<div className="space-y-4 md:space-y-6"> | |
{/* Dropzone - Mobile optimized */} | |
<motion.div | |
{...getRootProps()} | |
whileHover={{ scale: 1.01 }} | |
whileTap={{ scale: 0.99 }} | |
className={`file-drop-zone border-2 border-dashed rounded-2xl md:rounded-3xl p-6 md:p-8 text-center cursor-pointer transition-all touch-manipulation ${ | |
isDragActive | |
? darkMode | |
? 'border-primary-400 bg-primary-900/20 scale-[1.02]' | |
: 'border-primary-500 bg-primary-50 scale-[1.02]' | |
: darkMode | |
? 'border-gray-600 hover:border-gray-500 active:border-primary-400 bg-gray-800/50 hover:bg-gray-800' | |
: 'border-gray-300 hover:border-gray-400 active:border-primary-300 bg-gray-50 hover:bg-gray-100' | |
} min-h-[200px] md:min-h-[240px] flex flex-col justify-center`} | |
> | |
<input {...getInputProps()} /> | |
{/* Upload Icon - Mobile optimized */} | |
<motion.div | |
animate={isDragActive ? { y: -5, scale: 1.1 } : { y: 0, scale: 1 }} | |
transition={{ duration: 0.2 }} | |
> | |
<CloudArrowUpIcon className={`w-16 h-16 md:w-20 md:h-20 mx-auto mb-4 md:mb-6 ${ | |
isDragActive | |
? darkMode ? 'text-primary-400' : 'text-primary-500' | |
: darkMode ? 'text-gray-400' : 'text-gray-500' | |
}`} /> | |
</motion.div> | |
{/* Upload Text - Mobile optimized */} | |
<h3 className={`text-xl md:text-2xl font-bold mb-3 md:mb-4 ${ | |
darkMode ? 'text-white' : 'text-gray-900' | |
}`}> | |
{isDragActive ? 'Drop files here!' : 'Upload study materials'} | |
</h3> | |
<p className={`mb-4 md:mb-6 text-base md:text-lg px-2 ${ | |
darkMode ? 'text-gray-400' : 'text-gray-600' | |
}`}> | |
{isDragActive | |
? 'Release to upload your files' | |
: 'Drag & drop files here, or tap to browse' | |
} | |
</p> | |
{/* File size info - Mobile optimized */} | |
<p className={`text-sm md:text-base mb-4 md:mb-6 ${ | |
darkMode ? 'text-gray-500' : 'text-gray-500' | |
}`}> | |
Maximum file size: <span className="font-semibold">100MB</span> | |
</p> | |
{/* File type badges - Mobile optimized */} | |
<div className="flex flex-wrap justify-center gap-2 md:gap-3"> | |
<motion.span | |
whileHover={{ scale: 1.05 }} | |
className={`px-4 py-2 md:px-5 md:py-2.5 rounded-full text-sm md:text-base font-semibold shadow-md ${ | |
darkMode | |
? 'bg-blue-900/40 text-blue-300 border border-blue-700/50' | |
: 'bg-blue-100 text-blue-800 border border-blue-200' | |
}`} | |
> | |
</motion.span> | |
<motion.span | |
whileHover={{ scale: 1.05 }} | |
className={`px-4 py-2 md:px-5 md:py-2.5 rounded-full text-sm md:text-base font-semibold shadow-md ${ | |
darkMode | |
? 'bg-green-900/40 text-green-300 border border-green-700/50' | |
: 'bg-green-100 text-green-800 border border-green-200' | |
}`} | |
> | |
📝 DOCX | |
</motion.span> | |
<motion.span | |
whileHover={{ scale: 1.05 }} | |
className={`px-4 py-2 md:px-5 md:py-2.5 rounded-full text-sm md:text-base font-semibold shadow-md ${ | |
darkMode | |
? 'bg-purple-900/40 text-purple-300 border border-purple-700/50' | |
: 'bg-purple-100 text-purple-800 border border-purple-200' | |
}`} | |
> | |
📋 TXT | |
</motion.span> | |
</div> | |
{/* Mobile-specific help text */} | |
<div className="md:hidden mt-4"> | |
<p className={`text-xs ${ | |
darkMode ? 'text-gray-500' : 'text-gray-400' | |
}`}> | |
💡 Tip: You can select multiple files at once | |
</p> | |
</div> | |
</motion.div> | |
{/* Upload Progress - Mobile optimized */} | |
<AnimatePresence> | |
{uploading && ( | |
<motion.div | |
initial={{ opacity: 0, y: 20, scale: 0.95 }} | |
animate={{ opacity: 1, y: 0, scale: 1 }} | |
exit={{ opacity: 0, y: -20, scale: 0.95 }} | |
className={`p-4 md:p-6 rounded-2xl ${ | |
darkMode ? 'bg-gray-800/50 border border-gray-700/50' : 'bg-gray-100/50 border border-gray-200/50' | |
} backdrop-blur-sm`} | |
> | |
<div className="flex items-center space-x-3 md:space-x-4"> | |
<div className="relative"> | |
<div className="animate-spin rounded-full h-6 w-6 md:h-8 md:w-8 border-b-2 border-primary-500"></div> | |
<div className="absolute inset-0 rounded-full border-2 border-primary-500/20"></div> | |
</div> | |
<div> | |
<span className={`font-medium text-base md:text-lg ${ | |
darkMode ? 'text-gray-200' : 'text-gray-800' | |
}`}> | |
Uploading files... | |
</span> | |
<p className={`text-sm ${ | |
darkMode ? 'text-gray-400' : 'text-gray-600' | |
}`}> | |
Please wait while we process your documents | |
</p> | |
</div> | |
</div> | |
</motion.div> | |
)} | |
</AnimatePresence> | |
{/* Uploaded Files List - Mobile optimized */} | |
<AnimatePresence> | |
{uploadedFiles.length > 0 && ( | |
<motion.div | |
initial={{ opacity: 0, height: 0 }} | |
animate={{ opacity: 1, height: 'auto' }} | |
exit={{ opacity: 0, height: 0 }} | |
className="space-y-3 md:space-y-4" | |
> | |
<h4 className={`font-bold text-lg md:text-xl ${ | |
darkMode ? 'text-gray-200' : 'text-gray-800' | |
}`}> | |
Uploaded Files ({uploadedFiles.length}) | |
</h4> | |
<div className="space-y-2 md:space-y-3"> | |
{uploadedFiles.map((file, index) => ( | |
<motion.div | |
key={index} | |
initial={{ opacity: 0, x: -20 }} | |
animate={{ opacity: 1, x: 0 }} | |
transition={{ delay: index * 0.1 }} | |
className={`flex items-center justify-between p-4 md:p-5 rounded-xl md:rounded-2xl transition-all ${ | |
darkMode | |
? 'bg-gray-800/70 border border-gray-700/50 hover:bg-gray-800' | |
: 'bg-gray-50 border border-gray-200/50 hover:bg-gray-100' | |
} shadow-sm hover:shadow-md`} | |
> | |
<div className="flex items-center space-x-3 md:space-x-4 flex-1 min-w-0"> | |
{/* File Icon */} | |
<div className={`p-2 md:p-3 rounded-lg flex-shrink-0 ${ | |
file.status === 'success' | |
? darkMode | |
? 'bg-green-900/30 text-green-400' | |
: 'bg-green-100 text-green-700' | |
: darkMode | |
? 'bg-red-900/30 text-red-400' | |
: 'bg-red-100 text-red-700' | |
}`}> | |
<DocumentIcon className="w-5 h-5 md:w-6 md:h-6" /> | |
</div> | |
{/* File Info */} | |
<div className="flex-1 min-w-0"> | |
<p className={`font-medium text-sm md:text-base truncate ${ | |
darkMode ? 'text-gray-100' : 'text-gray-900' | |
}`}> | |
{file.name} | |
</p> | |
<div className="flex items-center space-x-2 md:space-x-3 mt-1"> | |
<p className={`text-xs md:text-sm ${ | |
darkMode ? 'text-gray-400' : 'text-gray-500' | |
}`}> | |
{formatFileSize(file.size)} | |
</p> | |
{file.status === 'error' && file.error && ( | |
<> | |
<span className={`text-xs ${ | |
darkMode ? 'text-gray-600' : 'text-gray-400' | |
}`}>•</span> | |
<p className={`text-xs truncate ${ | |
darkMode ? 'text-red-400' : 'text-red-600' | |
}`}> | |
{file.error} | |
</p> | |
</> | |
)} | |
</div> | |
</div> | |
</div> | |
{/* Status and Actions */} | |
<div className="flex items-center space-x-2 md:space-x-3 flex-shrink-0"> | |
{/* Status Icon */} | |
{file.status === 'success' ? ( | |
<motion.div | |
initial={{ scale: 0 }} | |
animate={{ scale: 1 }} | |
transition={{ type: "spring", delay: 0.2 }} | |
> | |
<CheckCircleIcon className="w-6 h-6 md:w-7 md:h-7 text-green-500" /> | |
</motion.div> | |
) : ( | |
<motion.div | |
initial={{ scale: 0 }} | |
animate={{ scale: 1 }} | |
transition={{ type: "spring", delay: 0.2 }} | |
> | |
<XCircleIcon className="w-6 h-6 md:w-7 md:h-7 text-red-500" /> | |
</motion.div> | |
)} | |
{/* Remove Button - Larger touch target */} | |
<motion.button | |
whileHover={{ scale: 1.1 }} | |
whileTap={{ scale: 0.9 }} | |
onClick={() => removeFile(index)} | |
className={`p-2 md:p-2.5 rounded-lg transition-colors touch-manipulation ${ | |
darkMode | |
? 'hover:bg-gray-700 active:bg-gray-600 text-gray-400 hover:text-gray-200' | |
: 'hover:bg-gray-200 active:bg-gray-300 text-gray-500 hover:text-gray-700' | |
}`} | |
title="Remove file" | |
> | |
<XMarkIcon className="w-4 h-4 md:w-5 md:h-5" /> | |
</motion.button> | |
</div> | |
</motion.div> | |
))} | |
</div> | |
</motion.div> | |
)} | |
</AnimatePresence> | |
{/* Action Buttons - Mobile optimized */} | |
<div className="flex flex-col sm:flex-row gap-3 md:gap-4 pt-2"> | |
{/* Upload More Button */} | |
<motion.button | |
whileHover={{ scale: 1.02 }} | |
whileTap={{ scale: 0.98 }} | |
{...getRootProps()} | |
className={`flex-1 sm:flex-none px-6 py-3 md:py-3.5 rounded-xl md:rounded-2xl font-semibold transition-all touch-manipulation ${ | |
darkMode | |
? 'bg-primary-600 hover:bg-primary-700 active:bg-primary-800 text-white shadow-lg' | |
: 'bg-primary-500 hover:bg-primary-600 active:bg-primary-700 text-white shadow-lg' | |
} hover:shadow-xl active:shadow-2xl disabled:opacity-50 disabled:cursor-not-allowed`} | |
disabled={uploading} | |
> | |
<input {...getInputProps()} /> | |
{uploading ? 'Uploading...' : 'Upload More Files'} | |
</motion.button> | |
{/* Close Button */} | |
{onClose && ( | |
<motion.button | |
whileHover={{ scale: 1.02 }} | |
whileTap={{ scale: 0.98 }} | |
onClick={onClose} | |
className={`flex-1 sm:flex-none px-6 py-3 md:py-3.5 rounded-xl md:rounded-2xl font-semibold transition-all touch-manipulation ${ | |
darkMode | |
? 'bg-gray-700 hover:bg-gray-600 active:bg-gray-500 text-gray-200 shadow-lg' | |
: 'bg-gray-200 hover:bg-gray-300 active:bg-gray-400 text-gray-700 shadow-lg' | |
} hover:shadow-xl active:shadow-2xl`} | |
> | |
Done | |
</motion.button> | |
)} | |
</div> | |
</div> | |
); | |
}; | |
export default FileUploader; |