"use client"; import * as React from "react"; import Image from "next/image"; import { Cross2Icon, UploadIcon } from "@radix-ui/react-icons"; import Dropzone, { type DropzoneProps, type FileRejection, } from "react-dropzone"; import { toast } from "sonner"; import { cn, formatBytes } from "@repo/ui/lib/utils"; import { useControllableState } from "@repo/ui/hooks/use-controllable-state"; import { Button } from "@repo/ui/src/button"; import { Progress } from "@repo/ui/src/progress"; import { ScrollArea } from "@repo/ui/src/scroll-area"; 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 React.Dispatch> * @default undefined * @example onValueChange={(files) => setFiles(files)} */ onValueChange?: React.Dispatch>; /** * 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 maxFiles={5} */ maxFiles?: 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, maxFiles = 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 && maxFiles === 1 && acceptedFiles.length > 1) { toast.error("Cannot upload more than 1 file at a time"); return; } if ((files?.length ?? 0) + acceptedFiles.length > maxFiles) { toast.error(`Cannot upload more than ${maxFiles} 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 <= maxFiles ) { 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, maxFiles, 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) >= maxFiles; return (
1 || multiple} disabled={isDisabled} > {({ getRootProps, getInputProps, isDragActive }) => (
{isDragActive ? (

Drop the files here

) : (

Drag {`'n'`} drop files here, or click to select files

You can upload {maxFiles > 1 ? ` ${maxFiles === Infinity ? "multiple" : maxFiles} files (up to ${formatBytes(maxSize)} each)` : ` a file with ${formatBytes(maxSize)}`}

)}
)}
{files?.length ? (
{files?.map((file, index) => ( onRemove(index)} progress={progresses?.[file.name]} /> ))}
) : null}
); } interface FileCardProps { file: File; onRemove: () => void; progress?: number; } function FileCard({ file, progress, onRemove }: FileCardProps) { return (
{isFileWithPreview(file) ? ( {file.name} ) : null}

{file.name}

{formatBytes(file.size)}

{progress ? : null}
); } function isFileWithPreview(file: File): file is File & { preview: string } { return "preview" in file && typeof file.preview === "string"; }