import React, { useCallback, useMemo, useRef, useState } from 'react' import { Progress } from '@/components/heroui/Progress' import { addToast } from '@/lib/toast' import CloudUploadIcon from '@/components/icons/CloudUploadIcon' import TrashIcon from '@/components/icons/TrashIcon' import CloseIcon from '@/components/icons/CloseIcon' import FileCheckIcon from '@/components/icons/FileCheckIcon' import CloudDownloadIcon from '@/components/icons/CloudDownloadIcon' import { handleDownload } from '@/helpers' import { texts, format } from '@/texts' import { truncateValue } from '@/lib/formatters' import Button from '@/components/formElements/Button' import axiosInstance from '@/config/axios' import { prepareImageForUpload } from '@/lib/fileValidation' interface FileItem { id: string name: string size: string type?: string uploadPercentage: number fileId?: string } interface UploadFileProps { buttonText?: string accept?: string[] className?: string fileUploaded: (fileId: string, mode?: 'save' | 'remove', fileType?: string, fileUrl?: string) => void fileId?: string noInteraction?: boolean inputId?: string maxImageFileSize?: number // in MB maxVideoFileSize?: number // in MB maxAudioFileSize?: number // in MB maxTextFileSize?: number // in MB } const FileUpload = ({ buttonText, accept = [], className = '', fileUploaded, fileId, noInteraction = false, inputId, maxImageFileSize = 6, maxVideoFileSize = 500, maxAudioFileSize = 15, maxTextFileSize = 10, }: UploadFileProps) => { const [files, setFiles] = useState([]) const [abortController, setAbortController] = useState(null) const requestTimeoutRef = useRef(null) const defaultButtonText = buttonText || texts.common.uploadFile const formatFileSize = (bytes: number) => { const formatNumber = (number: number) => { return parseFloat(number.toFixed(2)).toString() } if (bytes < 1024) { return `${bytes} B` } else if (bytes < 1048576) { return `${formatNumber(bytes / 1024)} KB ` } else if (bytes < 1073741824) { return `${formatNumber(bytes / 1048576)} MB ` } else { return `${formatNumber(bytes / 1073741824)} GB ` } } const findFileFormat = (type: string) => { if (type.includes('image')) return 'PFT_Image' else if (type.includes('video')) return 'PFT_Video' else if (type.includes('audio')) return 'PFT_Audio' else if (type.includes('pdf')) return 'PFT_Text' } const hasType = useCallback((type: string) => accept.some((item) => item?.toLowerCase()?.includes(type)), [accept]) const acceptFile = useMemo(() => { if (!accept.length) { return '*' } else { const acceptableFormat = [] if (hasType('image')) acceptableFormat.push('image/*') // Accept all image types if (hasType('audio')) acceptableFormat.push('audio/*') // Accept all audio types if (hasType('video')) acceptableFormat.push('video/*') // Accept all video types if (hasType('text')) acceptableFormat.push( '.pdf', '.txt', '.doc', '.pptx', '.xlsx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'text/plain', // Plain text files 'text/csv', // CSV files 'text/html', // HTML files 'text/markdown', // Markdown files 'application/msword', // Legacy Word files 'application/rtf', // RTF files 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ) return acceptableFormat.join(',') } }, [accept, hasType]) const hintText = useMemo(() => { let text = '' if (hasType('image')) text = `JPEG(${maxImageFileSize} MB), PNG(${maxImageFileSize} MB), WebP(${maxImageFileSize} MB), HEIC(${maxImageFileSize} MB)` if (hasType('audio')) text += text.length ? `, MP3(${maxAudioFileSize} MB), WAV(${maxAudioFileSize} MB)` : `MP3(${maxAudioFileSize} MB), WAV(${maxAudioFileSize} MB)` if (hasType('video')) text += text.length ? `, MP4(${maxVideoFileSize} MB)` : `MP4(${maxVideoFileSize} MB)` if (hasType('text')) text += text.length ? `, PDF(${maxTextFileSize} MB), TXT(${maxTextFileSize} MB), DOCX(${maxTextFileSize} MB), PPTX(${maxTextFileSize} MB), XLSX(${maxTextFileSize} MB)` : `PDF(${maxTextFileSize} MB), TXT(${maxTextFileSize} MB), DOCX(${maxTextFileSize} MB), PPTX(${maxTextFileSize} MB), XLSX(${maxTextFileSize} MB)` return text }, [hasType, maxAudioFileSize, maxImageFileSize, maxTextFileSize, maxVideoFileSize]) const handleFileChange = async (event: React.ChangeEvent) => { const file = event.target.files?.[0] event.target.value = '' if (file) { const textMimeTypes = [ 'text/plain', // Plain text 'text/csv', // CSV 'text/html', // HTML 'text/markdown', // Markdown 'application/pdf', // PDF 'application/rtf', // RTF 'application/msword', // Legacy Word 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // Word (docx) 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', ] if (textMimeTypes.includes(file.type)) { // Check if acceptFile includes any text MIME type if (!textMimeTypes.some((type) => acceptFile.includes(type))) { addToast({ title: texts.common.fileMustBeAccepted, color: 'danger', }) return } } else { // For other file types, check main MIME type match const fileType = file.type.split('/')[0] // const acceptedType = acceptFile.split('/')[0] if (!acceptFile.includes(fileType)) { addToast({ title: texts.common.fileMustBeAccepted, color: 'danger', }) return } } const MAX_IMAGE_SIZE_IN_BYTES = maxImageFileSize * 1024 * 1024 if (file.type.includes('image') && file.size > MAX_IMAGE_SIZE_IN_BYTES) { addToast({ title: format(texts.common.imageFileTooLarge, { maxMb: maxImageFileSize }), color: 'danger', }) return } const MAX_VIDEO_SIZE_IN_BYTES = maxVideoFileSize * 1024 * 1024 if (file.type.includes('video') && file.size > MAX_VIDEO_SIZE_IN_BYTES) { addToast({ title: format(texts.common.videoFileTooLarge, { maxMb: maxVideoFileSize }), color: 'danger', }) return } const MAX_AUDIO_SIZE_IN_BYTES = maxAudioFileSize * 1024 * 1024 if (file.type.includes('audio') && file.size > MAX_AUDIO_SIZE_IN_BYTES) { addToast({ title: format(texts.common.audioFileTooLarge, { maxMb: maxAudioFileSize }), color: 'danger', }) return } const MAX_TEXT_SIZE_IN_BYTES = maxTextFileSize * 1024 * 1024 if (textMimeTypes.includes(file.type) && file.size > MAX_TEXT_SIZE_IN_BYTES) { addToast({ title: format(texts.common.textFileTooLarge, { maxMb: maxTextFileSize }), color: 'danger', }) return } const uploadFile = hasType('image') ? await prepareImageForUpload(file, maxImageFileSize) : file if (typeof uploadFile === 'string') { addToast({ title: uploadFile, color: 'danger' }) return } setFiles((prev) => [ ...prev, { id: Date.now().toString(), name: uploadFile.name, size: formatFileSize(uploadFile.size), type: findFileFormat(uploadFile.type), uploadPercentage: 0, }, ]) if (requestTimeoutRef.current) clearTimeout(requestTimeoutRef.current) requestTimeoutRef.current = setTimeout(() => { handleUpload(uploadFile) }, 500) } } const handleUpload = (file: File) => { const data = new FormData() data.append('file', file) const controller = new AbortController() setAbortController(controller) axiosInstance .post('/uploads', data, { signal: controller.signal, onUploadProgress: (progressEvent) => { if (progressEvent.total) { const uploadPercentage = Math.round((progressEvent.loaded / progressEvent.total) * 100) setFiles((prev) => prev.map((f) => (f.name === file.name ? { ...f, uploadPercentage } : f))) } }, }) .then((result) => { const payload = result.data as { data?: { id?: unknown; url?: unknown } } | undefined const uploaded = payload?.data const uploadedId = typeof uploaded?.id === 'string' ? uploaded.id : null const uploadedUrl = typeof uploaded?.url === 'string' ? uploaded.url : null if (uploadedId && uploadedUrl) { const newFile = { id: uploadedId, url: uploadedUrl, } setFiles((prev) => prev.map((f) => (f.name === file.name ? { ...f, fileId: newFile.id } : f))) fileUploaded(newFile.id, 'save', file?.type?.split('/')[0], newFile.url) } else { addToast({ title: texts.common.uploadFailed, color: 'danger' }) } }) .catch((error: unknown) => { if (typeof error !== 'object' || error === null || !('code' in error) || error.code !== 'ERR_CANCELED') { addToast({ title: texts.common.uploadFailed, color: 'danger' }) } }) } const cancelUpload = () => { if (abortController) { abortController.abort() setFiles((prev) => prev.slice(0, -1)) setAbortController(null) } } const removeFile = (fileId: string) => { setFiles((prev) => prev.filter((file) => file.id !== fileId)) fileUploaded(fileId, 'remove') } return (
{files?.length === 0 && !fileId && (
)} {!!fileId && !files?.length && (
handleDownload(fileId, texts.common.downloadedFileName)} onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), handleDownload(fileId, texts.common.downloadedFileName)) } >

{texts.common.downloadFile}

{!noInteraction && ( )}
)} {!!files?.length && files.map((file) => (
{file.uploadPercentage === 100 ? ( <>
{ if (!file.fileId) return void handleDownload(file.fileId, file.name) }} onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && file.fileId) { e.preventDefault() void handleDownload(file.fileId, file.name) } }} >

{texts.common.downloadFile}

) : ( <>

{truncateValue(file.name, 30)}

{file.size}

{file.uploadPercentage}%

)}
))}
) } export default FileUpload