Spaces:
Sleeping
Sleeping
| import { useState, useCallback } from 'react'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| import { HiUpload, HiX, HiPhotograph, HiDocumentText, HiCheckCircle, HiExclamationCircle } from 'react-icons/hi'; | |
| import { HiArrowPath } from 'react-icons/hi2'; | |
| import api from '../../services/api'; | |
| import './FileUpload.css'; | |
| /** | |
| * Reusable drag-and-drop file upload component. | |
| * | |
| * Props: | |
| * category {string} — storage category: 'profiles' | 'campaigns' | 'gallery' | 'stories' | 'blog' | 'help-requests' | |
| * entityId {string} — optional related entity ID | |
| * accept {string} — MIME types for the file input (e.g. "image/*" or "image/*,application/pdf") | |
| * label {string} — label shown in the drop zone | |
| * maxMB {number} — client-side file size hint (just for UI; server validates too) | |
| * currentUrl {string} — existing file URL to show as preview | |
| * onUpload {function} — callback(url, key) called on successful upload | |
| * onDelete {function} — callback(key) called when user removes the file | |
| * multiple {boolean} — allow multiple file selection | |
| * className {string} — extra class for the root element | |
| */ | |
| export default function FileUpload({ | |
| category, | |
| entityId = '', | |
| accept = 'image/*', | |
| label = 'Upload a file', | |
| maxMB = 5, | |
| currentUrl = '', | |
| onUpload, | |
| onDelete, | |
| multiple = false, | |
| className = '', | |
| }) { | |
| const [isDragging, setIsDragging] = useState(false); | |
| const [uploading, setUploading] = useState(false); | |
| const [progress, setProgress] = useState(0); | |
| const [error, setError] = useState(''); | |
| const [preview, setPreview] = useState(currentUrl); | |
| const [uploadedKey, setUploadedKey] = useState(''); | |
| const isImage = (file) => file?.type?.startsWith('image/'); | |
| const handleFiles = useCallback(async (files) => { | |
| if (!files || files.length === 0) return; | |
| const file = files[0]; // handle first file (multiple handled separately) | |
| setError(''); | |
| setProgress(0); | |
| // Client-side size check | |
| const maxBytes = maxMB * 1024 * 1024; | |
| if (file.size > maxBytes) { | |
| setError(`File too large. Maximum size is ${maxMB}MB.`); | |
| return; | |
| } | |
| // Show local preview for images immediately | |
| if (isImage(file)) { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => setPreview(e.target.result); | |
| reader.readAsDataURL(file); | |
| } | |
| setUploading(true); | |
| try { | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| formData.append('category', category); | |
| if (entityId) formData.append('entityId', entityId); | |
| // Use raw axios call for multipart + progress tracking | |
| // (api interceptor handles auth token) | |
| const result = await api.post('/upload', formData, { | |
| headers: { 'Content-Type': 'multipart/form-data' }, | |
| onUploadProgress: (e) => { | |
| const pct = Math.round((e.loaded * 100) / (e.total || 1)); | |
| setProgress(pct); | |
| }, | |
| }); | |
| const url = result.url || result.previewUrl || ''; | |
| const key = result.key || ''; | |
| setUploadedKey(key); | |
| if (url) setPreview(url); | |
| setProgress(100); | |
| onUpload?.(url, key, result); | |
| } catch (err) { | |
| setError(err.message || 'Upload failed. Please try again.'); | |
| // Revert preview on failure | |
| setPreview(currentUrl); | |
| } finally { | |
| setUploading(false); | |
| } | |
| }, [category, entityId, maxMB, currentUrl, onUpload]); | |
| const handleRemove = useCallback(() => { | |
| const key = uploadedKey; | |
| setPreview(''); | |
| setUploadedKey(''); | |
| setError(''); | |
| onDelete?.(key); | |
| }, [uploadedKey, onDelete]); | |
| // Drag events | |
| const onDragOver = (e) => { e.preventDefault(); setIsDragging(true); }; | |
| const onDragLeave = (e) => { e.preventDefault(); setIsDragging(false); }; | |
| const onDrop = (e) => { | |
| e.preventDefault(); | |
| setIsDragging(false); | |
| handleFiles(e.dataTransfer.files); | |
| }; | |
| const fileIcon = accept.includes('pdf') ? <HiDocumentText /> : <HiPhotograph />; | |
| return ( | |
| <div className={`fu-root ${className}`}> | |
| <AnimatePresence mode="wait"> | |
| {preview ? ( | |
| /* ── Preview State ── */ | |
| <motion.div | |
| key="preview" | |
| className="fu-preview" | |
| initial={{ opacity: 0, scale: 0.95 }} | |
| animate={{ opacity: 1, scale: 1 }} | |
| exit={{ opacity: 0, scale: 0.95 }} | |
| > | |
| {isImage({ type: accept.startsWith('image') ? 'image/jpeg' : '' }) || preview.match(/\.(jpg|jpeg|png|webp|gif)(\?|$)/i) ? ( | |
| <img src={preview} alt="Upload preview" className="fu-preview__img" /> | |
| ) : ( | |
| <div className="fu-preview__doc"> | |
| <HiDocumentText className="fu-preview__doc-icon" /> | |
| <span>File uploaded</span> | |
| </div> | |
| )} | |
| <button | |
| type="button" | |
| className="fu-preview__remove" | |
| onClick={handleRemove} | |
| title="Remove file" | |
| > | |
| <HiX /> | |
| </button> | |
| <div className="fu-preview__success"> | |
| <HiCheckCircle /> Uploaded | |
| </div> | |
| </motion.div> | |
| ) : ( | |
| /* ── Drop Zone ── */ | |
| <motion.label | |
| key="dropzone" | |
| className={`fu-zone ${isDragging ? 'fu-zone--dragging' : ''} ${uploading ? 'fu-zone--uploading' : ''}`} | |
| onDragOver={onDragOver} | |
| onDragLeave={onDragLeave} | |
| onDrop={onDrop} | |
| whileHover={{ scale: 1.01 }} | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| > | |
| <input | |
| type="file" | |
| accept={accept} | |
| multiple={multiple} | |
| className="fu-input" | |
| onChange={(e) => handleFiles(e.target.files)} | |
| disabled={uploading} | |
| /> | |
| <div className="fu-zone__icon"> | |
| {uploading ? ( | |
| <HiArrowPath className="spin-icon" /> | |
| ) : isDragging ? ( | |
| <HiUpload /> | |
| ) : ( | |
| fileIcon | |
| )} | |
| </div> | |
| <p className="fu-zone__label"> | |
| {uploading ? 'Uploading…' : isDragging ? 'Drop to upload' : label} | |
| </p> | |
| <p className="fu-zone__hint"> | |
| {uploading ? '' : `Drag & drop or click to browse · Max ${maxMB}MB`} | |
| </p> | |
| {/* Progress bar */} | |
| {uploading && ( | |
| <div className="fu-progress"> | |
| <motion.div | |
| className="fu-progress__bar" | |
| initial={{ width: 0 }} | |
| animate={{ width: `${progress}%` }} | |
| transition={{ ease: 'easeOut' }} | |
| /> | |
| <span className="fu-progress__label">{progress}%</span> | |
| </div> | |
| )} | |
| </motion.label> | |
| )} | |
| </AnimatePresence> | |
| {/* Error message */} | |
| <AnimatePresence> | |
| {error && ( | |
| <motion.p | |
| className="fu-error" | |
| initial={{ opacity: 0, y: -4 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0 }} | |
| > | |
| <HiExclamationCircle /> {error} | |
| </motion.p> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| ); | |
| } | |