438 lines
14 KiB
TypeScript
438 lines
14 KiB
TypeScript
/**
|
||
* @see https://github.com/sadmann7/file-uploader
|
||
*/
|
||
|
||
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 of the uploader.
|
||
* @type File[]
|
||
* @default undefined
|
||
* @example value={files}
|
||
*/
|
||
value?: File[]
|
||
|
||
/**
|
||
* Function to be called when the value changes.
|
||
* @type (files: File[]) => void
|
||
* @default undefined
|
||
* @example onValueChange={(files) => setFiles(files)}
|
||
*/
|
||
onValueChange?: (files: File[]) => void
|
||
|
||
/**
|
||
* Function to be called when files are uploaded.
|
||
* @type (files: File[]) => Promise<void>
|
||
* @default undefined
|
||
* @example onUpload={(files) => uploadFiles(files)}
|
||
*/
|
||
onUpload?: (files: File[]) => Promise<void>
|
||
|
||
/**
|
||
* Function to be called when files are rejected.
|
||
* @type (rejections: FileRejection[]) => void
|
||
* @default undefined
|
||
* @example onReject={(rejections) => handleRejectedFiles(rejections)}
|
||
*/
|
||
onReject?: (rejections: FileRejection[]) => void
|
||
|
||
/**
|
||
* Progress of the uploaded files.
|
||
* @type Record<string, number> | undefined
|
||
* @default undefined
|
||
* @example progresses={{ "file1.png": 50 }}
|
||
*/
|
||
progresses?: Record<string, number>
|
||
|
||
/**
|
||
* Error messages for failed uploads.
|
||
* @type Record<string, string> | undefined
|
||
* @default undefined
|
||
* @example fileErrors={{ "file1.png": "Upload failed" }}
|
||
*/
|
||
fileErrors?: Record<string, string>
|
||
|
||
/**
|
||
* Accepted file types for the uploader.
|
||
* @type { [key: string]: string[]}
|
||
* @default
|
||
* ```ts
|
||
* { "text/*": [] }
|
||
* ```
|
||
* @example accept={["text/plain", "application/pdf"]}
|
||
*/
|
||
accept?: DropzoneProps['accept']
|
||
|
||
/**
|
||
* Maximum file size for the uploader.
|
||
* @type number | undefined
|
||
* @default 1024 * 1024 * 200 // 200MB
|
||
* @example maxSize={1024 * 1024 * 2} // 2MB
|
||
*/
|
||
maxSize?: DropzoneProps['maxSize']
|
||
|
||
/**
|
||
* Maximum number of files for the uploader.
|
||
* @type number | undefined
|
||
* @default 1
|
||
* @example maxFileCount={4}
|
||
*/
|
||
maxFileCount?: DropzoneProps['maxFiles']
|
||
|
||
/**
|
||
* Whether the uploader should accept multiple files.
|
||
* @type boolean
|
||
* @default false
|
||
* @example multiple
|
||
*/
|
||
multiple?: boolean
|
||
|
||
/**
|
||
* Whether the uploader is disabled.
|
||
* @type boolean
|
||
* @default false
|
||
* @example disabled
|
||
*/
|
||
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[]) => {
|
||
// Calculate total file count including both accepted and rejected files
|
||
const totalFileCount = (files?.length ?? 0) + acceptedFiles.length + rejectedFiles.length
|
||
|
||
// Check file count limits
|
||
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
|
||
}
|
||
|
||
// Handle rejected files first - this will set error states
|
||
if (rejectedFiles.length > 0) {
|
||
if (onReject) {
|
||
// Use the onReject callback if provided
|
||
onReject(rejectedFiles)
|
||
} else {
|
||
// Fall back to toast notifications if no callback is provided
|
||
rejectedFiles.forEach(({ file }) => {
|
||
toast.error(t('documentPanel.uploadDocuments.fileUploader.fileRejected', { name: file.name }))
|
||
})
|
||
}
|
||
}
|
||
|
||
// Process accepted files
|
||
const newAcceptedFiles = acceptedFiles.map((file) =>
|
||
Object.assign(file, {
|
||
preview: URL.createObjectURL(file)
|
||
})
|
||
)
|
||
|
||
// Process rejected files for UI display
|
||
const newRejectedFiles = rejectedFiles.map(({ file }) =>
|
||
Object.assign(file, {
|
||
preview: URL.createObjectURL(file),
|
||
rejected: true
|
||
})
|
||
)
|
||
|
||
// Combine all files for display
|
||
const allNewFiles = [...newAcceptedFiles, ...newRejectedFiles]
|
||
const updatedFiles = files ? [...files, ...allNewFiles] : allNewFiles
|
||
|
||
// Update the files state with all files
|
||
setFiles(updatedFiles)
|
||
|
||
// Only upload accepted files - make sure we're not uploading rejected files
|
||
if (onUpload && acceptedFiles.length > 0) {
|
||
// Filter out any files that might have been rejected by our custom validator
|
||
const validFiles = acceptedFiles.filter(file => {
|
||
// Check if file type is accepted
|
||
const fileExt = `.${file.name.split('.').pop()?.toLowerCase() || ''}`;
|
||
const isAccepted = Object.entries(accept || {}).some(([mimeType, extensions]) => {
|
||
return file.type === mimeType || extensions.includes(fileExt);
|
||
});
|
||
|
||
// Check file size
|
||
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)
|
||
}
|
||
|
||
// Revoke preview url when component unmounts
|
||
React.useEffect(() => {
|
||
return () => {
|
||
if (!files) return
|
||
files.forEach((file) => {
|
||
if (isFileWithPreview(file)) {
|
||
URL.revokeObjectURL(file.preview)
|
||
}
|
||
})
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [])
|
||
|
||
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) => {
|
||
// Check if file type is accepted
|
||
const fileExt = `.${file.name.split('.').pop()?.toLowerCase() || ''}`;
|
||
const isAccepted = Object.entries(accept || {}).some(([mimeType, extensions]) => {
|
||
return file.type === mimeType || 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 // New property to control icon display
|
||
}
|
||
|
||
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
|