diff --git a/frontend/src/assets/cross.svg b/frontend/src/assets/cross.svg new file mode 100644 index 00000000..c82c8d87 --- /dev/null +++ b/frontend/src/assets/cross.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/images.svg b/frontend/src/assets/images.svg new file mode 100644 index 00000000..bd822b96 --- /dev/null +++ b/frontend/src/assets/images.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/components/FileUpload.tsx b/frontend/src/components/FileUpload.tsx new file mode 100644 index 00000000..4072730a --- /dev/null +++ b/frontend/src/components/FileUpload.tsx @@ -0,0 +1,229 @@ +import React, { useCallback, useState } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { twMerge } from 'tailwind-merge'; + +import Cross from '../assets/cross.svg'; +import ImagesIcon from '../assets/images.svg'; + +interface FileUploadProps { + onUpload: (files: File[]) => void; + onRemove?: (file: File) => void; + multiple?: boolean; + maxFiles?: number; + maxSize?: number; // in bytes + accept?: Record; // e.g. { 'image/*': ['.png', '.jpg'] } + showPreview?: boolean; + previewSize?: number; + + children?: React.ReactNode; + className?: string; + activeClassName?: string; + acceptClassName?: string; + rejectClassName?: string; + + uploadText?: string | { text: string; colorClass?: string }[]; + dragActiveText?: string; + fileTypeText?: string; + sizeLimitText?: string; + + disabled?: boolean; + validator?: (file: File) => { isValid: boolean; error?: string }; +} + +export const FileUpload = ({ + onUpload, + onRemove, + multiple = false, + maxFiles = 1, + maxSize = 5 * 1024 * 1024, + accept = { 'image/*': ['.jpeg', '.png', '.jpg'] }, + showPreview = false, + previewSize = 80, + children, + className = 'border-2 border-dashed rounded-3xl p-6 text-center cursor-pointer transition-colors border-silver dark:border-[#7E7E7E]', + activeClassName = 'border-blue-500 bg-blue-50', + acceptClassName = 'border-green-500 dark:border-green-500 bg-green-50 dark:bg-green-50/10', + rejectClassName = 'border-red-500 bg-red-50 dark:bg-red-500/10 dark:border-red-500', + uploadText = 'Click to upload or drag and drop', + dragActiveText = 'Drop the files here', + fileTypeText = 'PNG, JPG, JPEG up to', + sizeLimitText = 'MB', + disabled = false, + validator, +}: FileUploadProps) => { + const [errors, setErrors] = useState([]); + const [preview, setPreview] = useState(null); + const [currentFile, setCurrentFile] = useState(null); + + const validateFile = (file: File) => { + const defaultValidation = { + isValid: true, + error: '', + }; + + if (validator) { + const customValidation = validator(file); + if (!customValidation.isValid) { + return customValidation; + } + } + + if (file.size > maxSize) { + return { + isValid: false, + error: `File exceeds ${maxSize / 1024 / 1024}MB limit`, + }; + } + + return defaultValidation; + }; + + const onDrop = useCallback( + (acceptedFiles: File[], fileRejections: any[]) => { + setErrors([]); + + if (fileRejections.length > 0) { + const newErrors = fileRejections + .map(({ errors }) => errors.map((e: any) => e.message)) + .flat(); + setErrors(newErrors); + return; + } + + const validationResults = acceptedFiles.map(validateFile); + const invalidFiles = validationResults.filter((r) => !r.isValid); + + if (invalidFiles.length > 0) { + setErrors(invalidFiles.map((f) => f.error!)); + return; + } + + const filesToUpload = multiple ? acceptedFiles : [acceptedFiles[0]]; + onUpload(filesToUpload); + + const file = multiple ? acceptedFiles[0] : acceptedFiles[0]; + setCurrentFile(file); + + if (showPreview && file.type.startsWith('image/')) { + const reader = new FileReader(); + reader.onload = () => setPreview(reader.result as string); + reader.readAsDataURL(file); + } + }, + [onUpload, multiple, maxSize, validator], + ); + + const { + getRootProps, + getInputProps, + isDragActive, + isDragAccept, + isDragReject, + } = useDropzone({ + onDrop, + multiple, + maxFiles, + maxSize, + accept, + disabled, + }); + + const currentClassName = twMerge( + 'border-2 border-dashed rounded-3xl p-8 text-center cursor-pointer transition-colors border-silver dark:border-[#7E7E7E]', + className, + isDragActive && activeClassName, + isDragAccept && acceptClassName, + isDragReject && rejectClassName, + disabled && 'opacity-50 cursor-not-allowed', + ); + + const handleRemove = () => { + setPreview(null); + setCurrentFile(null); + if (onRemove && currentFile) onRemove(currentFile); + }; + + const renderPreview = () => ( +
+ preview + +
+ ); + + const renderUploadText = () => { + if (Array.isArray(uploadText)) { + return ( +

+ {uploadText.map((segment, i) => ( + + {segment.text} + + ))} +

+ ); + } + return

{uploadText}

; + }; + + const defaultContent = ( +
+ {showPreview && preview ? ( + renderPreview() + ) : ( +
+ +
+ )} +
+
+ {isDragActive ? ( +

{dragActiveText}

+ ) : ( + renderUploadText() + )} +
+

+ {fileTypeText} {maxSize / 1024 / 1024} + {sizeLimitText} +

+
+
+ ); + + return ( +
+
+ + {children || defaultContent} + {errors.length > 0 && ( +
+ {errors.map((error, i) => ( +

+ {error} +

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