// https://github.com/sadmann7/file-uploader
'use client';
import { FileText, Upload, X } from 'lucide-react';
import * as React from 'react';
import Dropzone, {
type DropzoneProps,
type FileRejection,
} from 'react-dropzone';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useControllableState } from '@/hooks/use-controllable-state';
import { cn, formatBytes } from '@/lib/utils';
function isFileWithPreview(file: File): file is File & { preview: string } {
return 'preview' in file && typeof file.preview === 'string';
}
interface FileCardProps {
file: File;
onRemove: () => void;
progress?: number;
}
interface FilePreviewProps {
file: File & { preview: string };
}
function FilePreview({ file }: FilePreviewProps) {
if (file.type.startsWith('image/')) {
return (
);
}
return (
);
}
function FileCard({ file, progress, onRemove }: FileCardProps) {
return (
{isFileWithPreview(file) ?
: null}
{file.name}
{formatBytes(file.size)}
{progress ?
: null}
);
}
interface FileUploaderProps extends React.HTMLAttributes {
/**
* 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
* @default undefined
* @example onUpload={(files) => uploadFiles(files)}
*/
onUpload?: (files: File[]) => Promise;
/**
* Progress of the uploaded files.
* @type Record | undefined
* @default undefined
* @example progresses={{ "file1.png": 50 }}
*/
progresses?: Record;
/**
* Accepted file types for the uploader.
* @type { [key: string]: string[]}
* @default
* ```ts
* { "image/*": [] }
* ```
* @example accept={["image/png", "image/jpeg"]}
*/
accept?: DropzoneProps['accept'];
/**
* Maximum file size for the uploader.
* @type number | undefined
* @default 1024 * 1024 * 2 // 2MB
* @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;
}
export function FileUploader(props: FileUploaderProps) {
const {
value: valueProp,
onValueChange,
onUpload,
progresses,
accept = {
'image/*': [],
},
maxSize = 1024 * 1024 * 2,
maxFileCount = 1,
multiple = false,
disabled = false,
className,
...dropzoneProps
} = props;
const [files, setFiles] = useControllableState({
prop: valueProp,
onChange: onValueChange,
});
const onDrop = React.useCallback(
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
if (!multiple && maxFileCount === 1 && acceptedFiles.length > 1) {
toast.error('Cannot upload more than 1 file at a time');
return;
}
if ((files?.length ?? 0) + acceptedFiles.length > maxFileCount) {
toast.error(`Cannot upload more than ${maxFileCount} files`);
return;
}
const newFiles = acceptedFiles.map((file) =>
Object.assign(file, {
preview: URL.createObjectURL(file),
}),
);
const updatedFiles = files ? [...files, ...newFiles] : newFiles;
setFiles(updatedFiles);
if (rejectedFiles.length > 0) {
rejectedFiles.forEach(({ file }) => {
toast.error(`File ${file.name} was rejected`);
});
}
if (
onUpload &&
updatedFiles.length > 0 &&
updatedFiles.length <= maxFileCount
) {
const target =
updatedFiles.length > 0 ? `${updatedFiles.length} files` : `file`;
toast.promise(onUpload(updatedFiles), {
loading: `Uploading ${target}...`,
success: () => {
setFiles([]);
return `${target} uploaded`;
},
error: `Failed to upload ${target}`,
});
}
},
[files, maxFileCount, multiple, onUpload, setFiles],
);
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 (
1 || multiple}
disabled={isDisabled}
>
{({ getRootProps, getInputProps, isDragActive }) => (
{isDragActive ? (
) : (
Drag {`'n'`} drop files here, or click to select files
You can upload
{maxFileCount > 1
? ` ${maxFileCount === Infinity ? 'multiple' : maxFileCount}
files (up to ${formatBytes(maxSize)} each)`
: ` a file with ${formatBytes(maxSize)}`}
)}
)}
{files?.length ? (
{files?.map((file, index) => (
onRemove(index)}
progress={progresses?.[file.name]}
/>
))}
) : null}
);
}