diff --git a/src/UpupUploader.tsx b/src/UpupUploader.tsx index 25bc5943..4e88dfbb 100644 --- a/src/UpupUploader.tsx +++ b/src/UpupUploader.tsx @@ -29,6 +29,7 @@ import { RefAttributes, SetStateAction, forwardRef, + useCallback, useEffect, useImperativeHandle, useState, @@ -134,6 +135,79 @@ export const UpupUploader: FC> = */ const client = getClient(s3Configs) + /** + * Check if file type is accepted + * @param file File to check + * @throws Error if file type is not accepted + */ + const validateFileType = (file: File) => { + if (!checkFileType(file, accept, baseConfigs?.onFileTypeMismatch)) { + const error = new Error(`File type ${file.type} not accepted`) + baseConfigs?.onFileUploadFail?.(file, error) + throw error + } + } + + /** + * Check if file size is within limits + * @param files Array of files to check + * @throws Error if total file size exceeds limit + */ + const validateFileSize = (files: File[]) => { + if (maxFilesSize) { + const totalSize = files.reduce( + (acc, file) => acc + file.size, + 0, + ) + if (totalSize > maxFilesSize) { + const error = new Error( + `Total file size must be less than ${ + maxFilesSize / 1024 / 1024 + }MB`, + ) + files.forEach( + file => baseConfigs?.onFileUploadFail?.(file, error), + ) + throw error + } + } + } + + /** + * Compress files if compression is enabled + * @param files Array of files to compress + * @returns Promise Compressed files + */ + const compressFiles = async (files: File[]): Promise => { + if (!toBeCompressed) return files + + try { + return await Promise.all( + files.map(async file => { + const compressed = await compressFile({ + element: file, + element_name: file.name, + }) + return compressed + }), + ) + } catch (error) { + files.forEach( + file => + baseConfigs?.onFileUploadFail?.(file, error as Error), + ) + throw error + } + } + + const handleUploadCancel = useCallback(() => { + if (progress > 0 && progress < 100) { + // Cancel the ongoing upload + handler.abort() + baseConfigs?.onCancelUpload?.(files) + } + }, [progress, files, handler, baseConfigs]) + /** * Expose the handleUpload function to the parent component */ @@ -150,93 +224,60 @@ export const UpupUploader: FC> = : files return await this.proceedUpload(filesList) }, - + cancelUpload: handleUploadCancel, async proceedUpload(filesList: File[]) { return new Promise(async (resolve, reject) => { - /** - * Check if the total size of files is less than the maximum size - */ - const filesSize = maxFilesSize - ? files.reduce((acc, file) => acc + file.size, 0) - : 0 - if (maxFilesSize && filesSize > maxFilesSize) { - reject( - new Error( - 'The total size of files must be less than ' + - maxFilesSize / 1024 / 1024 + - 'MB', - ), - ) - } - /** - * Upload the file to the cloud storage - */ - let filesToUpload: File[] - let keys: string[] = [] - /** - * Compress the file before uploading it to the cloud storage - */ - if (toBeCompressed) - filesToUpload = await Promise.all( - filesList.map(async file => { - /** - * Compress the file - */ - return await compressFile({ - element: file, - element_name: file.name, - }) - }), - ) - else filesToUpload = filesList - /** - * Loop through the files array and upload the files - */ - if (filesToUpload) { - try { - filesToUpload.map(async file => { + try { + // Validate all files first + filesList.forEach(validateFileType) + validateFileSize(filesList) + + // Notify upload start + filesList.forEach(file => { + baseConfigs?.onFileUploadStart?.(file) + }) + + // Compress files if needed + const processedFiles = await compressFiles(filesList) + + const uploadPromises = processedFiles.map( + async file => { const fileExtension = file.name.split('.').pop() - /** - * assign a unique name for the file contain timestamp and random string with extension from the original file - */ - const key = `${Date.now()}__${uuidv4()}.${fileExtension}` - - /** - * Upload the file to the cloud storage - */ - await uploadObject({ - client, - bucket, - key, - file, - }) - .then(data => { - if (data.httpStatusCode === 200) { - keys.push(key) - } else - throw new Error( - 'Something went wrong', - ) - }) - .catch(err => { - throw new Error(err.message) + const key = `${uuidv4()}.${fileExtension}` + + try { + const result = await uploadObject({ + client, + bucket, + key, + file, }) - .finally(() => { - if ( - keys.length === filesToUpload.length + + if (result.httpStatusCode === 200) { + baseConfigs?.onFileUploadComplete?.( + file, + key, ) - resolve(keys) // return the keys to the parent component - }) - }) - } catch (error) { - if (error instanceof Error) { - // ✅ TypeScript knows err is Error - reject(new Error(error.message)) - } else { - reject(new Error('Something went wrong')) - } - } - } else reject(undefined) + return key + } else { + throw new Error('Upload failed') + } + } catch (error) { + baseConfigs?.onFileUploadFail?.( + file, + error as Error, + ) + throw error + } + }, + ) + + const keys = await Promise.all(uploadPromises) + baseConfigs?.onAllUploadsComplete?.(keys) + resolve(keys) + } catch (error) { + reject(error) + } }) }, })) @@ -297,23 +338,70 @@ export const UpupUploader: FC> = mutateFiles() }, [files]) - // Modify the input onChange handler + // Modify the input onChange handler to include validation const handleFileChange = (e: React.ChangeEvent) => { - const acceptedFiles = Array.from(e.target.files || []) - .filter(file => checkFileType(file, accept)) - .map(createFileWithId) - setFiles(files => - isAddingMore ? [...files, ...acceptedFiles] : acceptedFiles, - ) - e.target.value = '' + try { + const newFiles = Array.from(e.target.files || []) + newFiles.forEach(validateFileType) + validateFileSize(newFiles) + + const acceptedFiles = newFiles.map(createFileWithId) + setFiles(files => + isAddingMore ? [...files, ...acceptedFiles] : acceptedFiles, + ) + } catch (error) { + console.error(error) + // Don't set files if validation fails + } finally { + e.target.value = '' + } } - // Modify the DropZone props - const handleDropzoneFiles = (newFiles: File[]) => { - const filesWithIds = newFiles.map(createFileWithId) - setFiles(files => - isAddingMore ? [...files, ...filesWithIds] : [...filesWithIds], - ) + // Modify the DropZone handler to include validation and maintain existing files + const handleDropzoneFiles: Dispatch< + SetStateAction + > = filesOrUpdater => { + if (typeof filesOrUpdater === 'function') { + setFiles(prevFiles => { + try { + const updatedFiles = filesOrUpdater(prevFiles) + const newFiles = updatedFiles.slice(prevFiles.length) + + // Validate only new files + newFiles.forEach(validateFileType) + validateFileSize([...prevFiles, ...newFiles]) + + const filesWithIds = newFiles.map(createFileWithId) + return [...prevFiles, ...filesWithIds] + } catch (error) { + console.error(error) + return prevFiles + } + }) + } else { + try { + filesOrUpdater.forEach(validateFileType) + setFiles(prevFiles => { + try { + validateFileSize([...prevFiles, ...filesOrUpdater]) + const filesWithIds = + filesOrUpdater.map(createFileWithId) + return [...prevFiles, ...filesWithIds] + } catch (error) { + console.error(error) + return prevFiles + } + }) + } catch (error) { + console.error(error) + } + } + } + + // Add file removal handler + const handleFileRemove = (file: FileWithId) => { + setFiles(prev => prev.filter(f => f !== file)) + baseConfigs?.onFileRemove?.(file) } return mini ? ( @@ -321,6 +409,8 @@ export const UpupUploader: FC> = files={files} setFiles={setFiles} maxFileSize={maxFileSize} + handleFileRemove={handleFileRemove} + baseConfigs={baseConfigs} /> ) : (
> = {isDragging && ( - > - } + setFiles={handleDropzoneFiles} setIsDragging={setIsDragging} multiple={multiple} accept={accept} + baseConfigs={baseConfigs} /> )} @@ -372,6 +459,8 @@ export const UpupUploader: FC> = onFileClick={onFileClick} progress={progress} limit={limit} + handleFileRemove={handleFileRemove} + onCancelUpload={baseConfigs.onCancelUpload} />
@@ -381,6 +470,7 @@ export const UpupUploader: FC> = methods={METHODS.filter(method => { return uploadAdapters.includes(method.id as any) })} + baseConfigs={baseConfigs} /> = ({ file, objectUrl }) => { filePath={objectUrl} onError={(e: Error) => { console.error('Error in file preview:', e) + return }} errorComponent={() => ( diff --git a/src/components/UpupMini/MiniDropZone.tsx b/src/components/UpupMini/MiniDropZone.tsx deleted file mode 100644 index d5122381..00000000 --- a/src/components/UpupMini/MiniDropZone.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { motion } from 'framer-motion' -import { Dispatch, FC, SetStateAction } from 'react' -import { TbUpload } from 'react-icons/tb' - -type Props = { - setFiles: Dispatch> - setIsDragging: Dispatch> -} - -const MiniDropZone: FC = ({ setFiles, setIsDragging }: Props) => { - return ( - { - e.preventDefault() - setIsDragging(true) - e.dataTransfer.dropEffect = 'copy' - }} - > -
- - - -

Drop your files here

-
- { - setFiles(() => [e.target.files![0]]) - setIsDragging(false) - }} - onDrop={e => { - e.preventDefault() - setFiles(() => [[...Array.from(e.dataTransfer.files)][0]]) - setIsDragging(false) - }} - /> -
- ) -} - -export default MiniDropZone diff --git a/src/components/UpupMini/MiniPreview.tsx b/src/components/UpupMini/MiniPreview.tsx index 7276123a..315c04d0 100644 --- a/src/components/UpupMini/MiniPreview.tsx +++ b/src/components/UpupMini/MiniPreview.tsx @@ -3,7 +3,9 @@ import { FC } from 'react' import { FileHandlerProps } from 'types/file' import PreviewComponent from '../UpupUploader/PreviewComponent' -const MiniPreview: FC = ({ files, setFiles }) => { +const MiniPreview: FC< + FileHandlerProps & { handleFileRemove: (file: File) => void } +> = ({ files, setFiles, handleFileRemove }) => { return ( {files.length > 0 && ( @@ -20,6 +22,7 @@ const MiniPreview: FC = ({ files, setFiles }) => { setFiles={setFiles} file={file} index={files.indexOf(file)} + handleFileRemove={handleFileRemove} mini /> ))} diff --git a/src/components/UpupMini/index.tsx b/src/components/UpupMini/index.tsx index 6b640339..ac51a7ef 100644 --- a/src/components/UpupMini/index.tsx +++ b/src/components/UpupMini/index.tsx @@ -1,25 +1,30 @@ import type { Dispatch, FC, LegacyRef, SetStateAction } from 'react' import { useRef } from 'react' +import { DropZone } from 'components/UpupUploader' import { AnimatePresence } from 'framer-motion' import { useDragAndDrop } from 'hooks' import { checkFileType } from 'lib' import { BaseConfigs } from 'types' +import { FileWithId } from 'types/file' import MetaVersion from '../MetaVersion' -import { default as MiniDropZone } from './MiniDropZone' import { default as MiniPreview } from './MiniPreview' type Props = { files: File[] setFiles: Dispatch> maxFileSize: BaseConfigs['maxFileSize'] + handleFileRemove: (file: FileWithId) => void + baseConfigs?: BaseConfigs } export const UpupMini: FC = ({ files, setFiles, maxFileSize, -}: Props) => { + handleFileRemove, + baseConfigs, +}) => { const { isDragging, setIsDragging, @@ -39,9 +44,12 @@ export const UpupMini: FC = ({ > {isDragging && ( - )} @@ -60,7 +68,11 @@ export const UpupMini: FC = ({ }} /> - +

@@ -82,5 +94,4 @@ export const UpupMini: FC = ({ ) } -export { default as MiniDropZone } from './MiniDropZone' export { default as MiniPreview } from './MiniPreview' diff --git a/src/components/UpupUploader/DropZone.tsx b/src/components/UpupUploader/DropZone.tsx index 2e592bff..178c1b00 100644 --- a/src/components/UpupUploader/DropZone.tsx +++ b/src/components/UpupUploader/DropZone.tsx @@ -1,13 +1,14 @@ import { motion } from 'framer-motion' -import { checkFileType } from 'lib' import { Dispatch, FC, SetStateAction } from 'react' import { TbUpload } from 'react-icons/tb' +import { BaseConfigs } from 'types' type Props = { setFiles: Dispatch> setIsDragging: Dispatch> multiple?: boolean accept?: string + baseConfigs?: BaseConfigs } const DropZone: FC = ({ @@ -15,6 +16,7 @@ const DropZone: FC = ({ setIsDragging, multiple, accept = '*', + baseConfigs, }: Props) => { return ( = ({ e.preventDefault() setIsDragging(true) e.dataTransfer.dropEffect = 'copy' + + const files = Array.from(e.dataTransfer.files) + baseConfigs?.onFileDragOver?.(files) + }} + onDragLeave={e => { + e.preventDefault() + setIsDragging(false) + + const files = Array.from(e.dataTransfer.files) + baseConfigs?.onFileDragLeave?.(files) + }} + onDrop={e => { + e.preventDefault() + const files = Array.from(e.dataTransfer.files) + setFiles(prev => (multiple ? [...prev, ...files] : [files[0]])) + setIsDragging(false) + baseConfigs?.onFileDrop?.(files) }} > -
+
-

Drop your files here

+

+ Drop your files here +

{ - const acceptedFiles = Array.from( - e.target.files as FileList, - ).filter(file => checkFileType(file, accept)) - setFiles(prev => multiple - ? [...prev, ...acceptedFiles] - : // only one file - [acceptedFiles[0]], + ? [...prev, ...Array.from(e.target.files || [])] + : [e.target.files![0]], ) - - setIsDragging(false) - - // reset input - e.target.value = '' - }} - onDrop={e => { - e.preventDefault() - setFiles(prev => { - const acceptedFiles = Array.from( - e.dataTransfer.files, - ).filter(file => checkFileType(file, accept)) - - return multiple - ? [...prev, ...acceptedFiles] - : // only one file - [acceptedFiles[0]] - }) setIsDragging(false) + e.target.value = '' // Reset input }} /> diff --git a/src/components/UpupUploader/MethodSelector.tsx b/src/components/UpupUploader/MethodSelector.tsx index cfed3065..9724e99f 100644 --- a/src/components/UpupUploader/MethodSelector.tsx +++ b/src/components/UpupUploader/MethodSelector.tsx @@ -1,13 +1,25 @@ import { FC, MutableRefObject } from 'react' -import type { Method } from 'types' +import type { BaseConfigs, Method } from 'types' type Props = { setView: (view: string) => void inputRef?: MutableRefObject methods: Method[] + baseConfigs?: BaseConfigs } -const MethodsSelector: FC = ({ setView, inputRef, methods }: Props) => { +const MethodsSelector: FC = ({ + setView, + inputRef, + methods, + baseConfigs, +}: Props) => { + const handleMethodClick = (methodId: string) => { + baseConfigs?.onIntegrationClick?.(methodId) + if (methodId === 'INTERNAL') inputRef && inputRef?.current?.click() + else setView(methodId) + } + return (

@@ -31,11 +43,7 @@ const MethodsSelector: FC = ({ setView, inputRef, methods }: Props) => { onKeyDown={e => { if (e.key === 'Enter') e.preventDefault() }} - onClick={() => - method.id === 'INTERNAL' - ? inputRef && inputRef.current!.click() - : setView(method.id) - } + onClick={() => handleMethodClick(method.id)} > {method.icon} diff --git a/src/components/UpupUploader/Preview.tsx b/src/components/UpupUploader/Preview.tsx index c049d2f0..05bcd170 100644 --- a/src/components/UpupUploader/Preview.tsx +++ b/src/components/UpupUploader/Preview.tsx @@ -2,7 +2,8 @@ import Box from '@mui/material/Box' import { LinearProgressBar } from 'components' import PreviewComponent from 'components/UpupUploader/PreviewComponent' import { AnimatePresence, motion } from 'framer-motion' -import { FC, memo } from 'react' +import { FC, memo, useCallback } from 'react' +import { BaseConfigs } from 'types' import { FileHandlerProps, FileWithId } from 'types/file' import { v4 as uuidv4 } from 'uuid' @@ -13,6 +14,8 @@ type Props = { onFileClick?: (file: FileWithId) => void progress: number limit?: number + handleFileRemove: (file: FileWithId) => void + onCancelUpload: BaseConfigs['onCancelUpload'] } & FileHandlerProps const Preview: FC = ({ @@ -24,7 +27,16 @@ const Preview: FC = ({ onFileClick, progress, limit, + handleFileRemove, + onCancelUpload, }: Props) => { + const handleCancel = useCallback(() => { + if (files.length > 0) { + onCancelUpload?.(files) + setFiles([]) + } + }, [files, setFiles, onCancelUpload]) + return ( {files.length > 0 && ( @@ -37,7 +49,7 @@ const Preview: FC = ({ > - - + +
+ + +
+

) }