| |
| |
| |
|
|
| import * as React from 'react' |
| import { FileText, Upload, X } from 'lucide-react' |
| import Dropzone, { type DropzoneProps, type FileRejection } from 'react-dropzone' |
| import { toast } from 'sonner' |
| import { useTranslation } from 'react-i18next' |
|
|
| import { cn } from '@/lib/utils' |
| import { useControllableState } from '@radix-ui/react-use-controllable-state' |
| import Button from '@/components/ui/Button' |
| import { ScrollArea } from '@/components/ui/ScrollArea' |
| import { supportedFileTypes } from '@/lib/constants' |
|
|
| interface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> { |
| |
| |
| |
| |
| |
| |
| value?: File[] |
|
|
| |
| |
| |
| |
| |
| |
| onValueChange?: (files: File[]) => void |
|
|
| |
| |
| |
| |
| |
| |
| onUpload?: (files: File[]) => Promise<void> |
|
|
| |
| |
| |
| |
| |
| |
| onReject?: (rejections: FileRejection[]) => void |
|
|
| |
| |
| |
| |
| |
| |
| progresses?: Record<string, number> |
|
|
| |
| |
| |
| |
| |
| |
| fileErrors?: Record<string, string> |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| accept?: DropzoneProps['accept'] |
|
|
| |
| |
| |
| |
| |
| |
| maxSize?: DropzoneProps['maxSize'] |
|
|
| |
| |
| |
| |
| |
| |
| maxFileCount?: DropzoneProps['maxFiles'] |
|
|
| |
| |
| |
| |
| |
| |
| multiple?: boolean |
|
|
| |
| |
| |
| |
| |
| |
| disabled?: boolean |
|
|
| description?: string |
| } |
|
|
| function formatBytes( |
| bytes: number, |
| opts: { |
| decimals?: number |
| sizeType?: 'accurate' | 'normal' |
| } = {} |
| ) { |
| const { decimals = 0, sizeType = 'normal' } = opts |
|
|
| const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'] |
| const accurateSizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB'] |
| if (bytes === 0) return '0 Byte' |
| const i = Math.floor(Math.log(bytes) / Math.log(1024)) |
| return `${(bytes / Math.pow(1024, i)).toFixed(decimals)} ${ |
| sizeType === 'accurate' ? (accurateSizes[i] ?? 'Bytes') : (sizes[i] ?? 'Bytes') |
| }` |
| } |
|
|
| function FileUploader(props: FileUploaderProps) { |
| const { t } = useTranslation() |
| const { |
| value: valueProp, |
| onValueChange, |
| onUpload, |
| onReject, |
| progresses, |
| fileErrors, |
| accept = supportedFileTypes, |
| maxSize = 1024 * 1024 * 200, |
| maxFileCount = 1, |
| multiple = false, |
| disabled = false, |
| description, |
| className, |
| ...dropzoneProps |
| } = props |
|
|
| const [files, setFiles] = useControllableState({ |
| prop: valueProp, |
| onChange: onValueChange |
| }) |
|
|
| const onDrop = React.useCallback( |
| (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { |
| |
| const totalFileCount = (files?.length ?? 0) + acceptedFiles.length + rejectedFiles.length |
|
|
| |
| if (!multiple && maxFileCount === 1 && (acceptedFiles.length + rejectedFiles.length) > 1) { |
| toast.error(t('documentPanel.uploadDocuments.fileUploader.singleFileLimit')) |
| return |
| } |
|
|
| if (totalFileCount > maxFileCount) { |
| toast.error(t('documentPanel.uploadDocuments.fileUploader.maxFilesLimit', { count: maxFileCount })) |
| return |
| } |
|
|
| |
| if (rejectedFiles.length > 0) { |
| if (onReject) { |
| |
| onReject(rejectedFiles) |
| } else { |
| |
| rejectedFiles.forEach(({ file }) => { |
| toast.error(t('documentPanel.uploadDocuments.fileUploader.fileRejected', { name: file.name })) |
| }) |
| } |
| } |
|
|
| |
| const newAcceptedFiles = acceptedFiles.map((file) => |
| Object.assign(file, { |
| preview: URL.createObjectURL(file) |
| }) |
| ) |
|
|
| |
| const newRejectedFiles = rejectedFiles.map(({ file }) => |
| Object.assign(file, { |
| preview: URL.createObjectURL(file), |
| rejected: true |
| }) |
| ) |
|
|
| |
| const allNewFiles = [...newAcceptedFiles, ...newRejectedFiles] |
| const updatedFiles = files ? [...files, ...allNewFiles] : allNewFiles |
|
|
| |
| setFiles(updatedFiles) |
|
|
| |
| if (onUpload && acceptedFiles.length > 0) { |
| |
| const validFiles = acceptedFiles.filter(file => { |
| |
| if (!file.name) { |
| return false; |
| } |
|
|
| |
| const fileExt = `.${file.name.split('.').pop()?.toLowerCase() || ''}`; |
| const isAccepted = Object.entries(accept || {}).some(([mimeType, extensions]) => { |
| return file.type === mimeType || (Array.isArray(extensions) && extensions.includes(fileExt)); |
| }); |
|
|
| |
| const isSizeValid = file.size <= maxSize; |
|
|
| return isAccepted && isSizeValid; |
| }); |
|
|
| if (validFiles.length > 0) { |
| onUpload(validFiles); |
| } |
| } |
| }, |
| [files, maxFileCount, multiple, onUpload, onReject, setFiles, t, accept, maxSize] |
| ) |
|
|
| function onRemove(index: number) { |
| if (!files) return |
| const newFiles = files.filter((_, i) => i !== index) |
| setFiles(newFiles) |
| onValueChange?.(newFiles) |
| } |
|
|
| |
| React.useEffect(() => { |
| return () => { |
| if (!files) return |
| files.forEach((file) => { |
| if (isFileWithPreview(file)) { |
| URL.revokeObjectURL(file.preview) |
| } |
| }) |
| } |
| |
| }, []) |
|
|
| const isDisabled = disabled || (files?.length ?? 0) >= maxFileCount |
|
|
| return ( |
| <div className="relative flex flex-col gap-6 overflow-hidden"> |
| <Dropzone |
| onDrop={onDrop} |
| // remove accept,use customizd validator |
| noClick={false} |
| noKeyboard={false} |
| maxSize={maxSize} |
| maxFiles={maxFileCount} |
| multiple={maxFileCount > 1 || multiple} |
| disabled={isDisabled} |
| validator={(file) => { |
| // Ensure file name exists |
| if (!file.name) { |
| return { |
| code: 'invalid-file-name', |
| message: t('documentPanel.uploadDocuments.fileUploader.invalidFileName', |
| { fallback: 'Invalid file name' }) |
| }; |
| } |
| |
| // Safely extract file extension |
| const fileExt = `.${file.name.split('.').pop()?.toLowerCase() || ''}`; |
| |
| // Ensure accept object exists and has correct format |
| const isAccepted = Object.entries(accept || {}).some(([mimeType, extensions]) => { |
| // Ensure extensions is an array before calling includes |
| return file.type === mimeType || (Array.isArray(extensions) && extensions.includes(fileExt)); |
| }); |
| |
| if (!isAccepted) { |
| return { |
| code: 'file-invalid-type', |
| message: t('documentPanel.uploadDocuments.fileUploader.unsupportedType') |
| }; |
| } |
| |
| // Check file size |
| if (file.size > maxSize) { |
| return { |
| code: 'file-too-large', |
| message: t('documentPanel.uploadDocuments.fileUploader.fileTooLarge', { |
| maxSize: formatBytes(maxSize) |
| }) |
| }; |
| } |
| |
| return null; |
| }} |
| > |
| {({ getRootProps, getInputProps, isDragActive }) => ( |
| <div |
| {...getRootProps()} |
| className={cn( |
| 'group border-muted-foreground/25 hover:bg-muted/25 relative grid h-52 w-full cursor-pointer place-items-center rounded-lg border-2 border-dashed px-5 py-2.5 text-center transition', |
| 'ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none', |
| isDragActive && 'border-muted-foreground/50', |
| isDisabled && 'pointer-events-none opacity-60', |
| className |
| )} |
| {...dropzoneProps} |
| > |
| <input {...getInputProps()} /> |
| {isDragActive ? ( |
| <div className="flex flex-col items-center justify-center gap-4 sm:px-5"> |
| <div className="rounded-full border border-dashed p-3"> |
| <Upload className="text-muted-foreground size-7" aria-hidden="true" /> |
| </div> |
| <p className="text-muted-foreground font-medium">{t('documentPanel.uploadDocuments.fileUploader.dropHere')}</p> |
| </div> |
| ) : ( |
| <div className="flex flex-col items-center justify-center gap-4 sm:px-5"> |
| <div className="rounded-full border border-dashed p-3"> |
| <Upload className="text-muted-foreground size-7" aria-hidden="true" /> |
| </div> |
| <div className="flex flex-col gap-px"> |
| <p className="text-muted-foreground font-medium"> |
| {t('documentPanel.uploadDocuments.fileUploader.dragAndDrop')} |
| </p> |
| {description ? ( |
| <p className="text-muted-foreground/70 text-sm">{description}</p> |
| ) : ( |
| <p className="text-muted-foreground/70 text-sm"> |
| {t('documentPanel.uploadDocuments.fileUploader.uploadDescription', { |
| count: maxFileCount, |
| isMultiple: maxFileCount === Infinity, |
| maxSize: formatBytes(maxSize) |
| })} |
| {t('documentPanel.uploadDocuments.fileTypes')} |
| </p> |
| )} |
| </div> |
| </div> |
| )} |
| </div> |
| )} |
| </Dropzone> |
| {files?.length ? ( |
| <ScrollArea className="h-fit w-full px-3"> |
| <div className="flex max-h-48 flex-col gap-4"> |
| {files?.map((file, index) => ( |
| <FileCard |
| key={index} |
| file={file} |
| onRemove={() => onRemove(index)} |
| progress={progresses?.[file.name]} |
| error={fileErrors?.[file.name]} |
| /> |
| ))} |
| </div> |
| </ScrollArea> |
| ) : null} |
| </div> |
| ) |
| } |
|
|
| interface ProgressProps { |
| value: number |
| error?: boolean |
| showIcon?: boolean |
| } |
|
|
| function Progress({ value, error }: ProgressProps) { |
| return ( |
| <div className="relative h-2 w-full"> |
| <div className="h-full w-full overflow-hidden rounded-full bg-secondary"> |
| <div |
| className={cn( |
| 'h-full transition-all', |
| error ? 'bg-red-400' : 'bg-primary' |
| )} |
| style={{ width: `${value}%` }} |
| /> |
| </div> |
| </div> |
| ) |
| } |
|
|
| interface FileCardProps { |
| file: File |
| onRemove: () => void |
| progress?: number |
| error?: string |
| } |
|
|
| function FileCard({ file, progress, error, onRemove }: FileCardProps) { |
| const { t } = useTranslation() |
| return ( |
| <div className="relative flex items-center gap-2.5"> |
| <div className="flex flex-1 gap-2.5"> |
| {error ? ( |
| <FileText className="text-red-400 size-10" aria-hidden="true" /> |
| ) : ( |
| isFileWithPreview(file) ? <FilePreview file={file} /> : null |
| )} |
| <div className="flex w-full flex-col gap-2"> |
| <div className="flex flex-col gap-px"> |
| <p className="text-foreground/80 line-clamp-1 text-sm font-medium">{file.name}</p> |
| <p className="text-muted-foreground text-xs">{formatBytes(file.size)}</p> |
| </div> |
| {error ? ( |
| <div className="text-red-400 text-sm"> |
| <div className="relative mb-2"> |
| <Progress value={100} error={true} /> |
| </div> |
| <p>{error}</p> |
| </div> |
| ) : ( |
| progress ? <Progress value={progress} /> : null |
| )} |
| </div> |
| </div> |
| <div className="flex items-center gap-2"> |
| <Button type="button" variant="outline" size="icon" className="size-7" onClick={onRemove}> |
| <X className="size-4" aria-hidden="true" /> |
| <span className="sr-only">{t('documentPanel.uploadDocuments.fileUploader.removeFile')}</span> |
| </Button> |
| </div> |
| </div> |
| ) |
| } |
|
|
| function isFileWithPreview(file: File): file is File & { preview: string } { |
| return 'preview' in file && typeof file.preview === 'string' |
| } |
|
|
| interface FilePreviewProps { |
| file: File & { preview: string } |
| } |
|
|
| function FilePreview({ file }: FilePreviewProps) { |
| if (file.type.startsWith('image/')) { |
| return <div className="aspect-square shrink-0 rounded-md object-cover" /> |
| } |
|
|
| return <FileText className="text-muted-foreground size-10" aria-hidden="true" /> |
| } |
|
|
| export default FileUploader |
|
|