"use client" import {cn} from "@/lib/utils"; import { createContext, forwardRef, useCallback, useContext, useId, useMemo, useReducer, useState, } from "react"; import { Accept, FileRejection, useDropzone as rootUseDropzone, } from "react-dropzone"; import {Button, ButtonVariantsProps} from "@/components/ui/button"; type DropzoneResult = | { status: "pending"; } | { status: "error"; error: TUploadError; } | { status: "success"; result: TUploadRes; }; export type FileStatus = { id: string; fileName: string; file: File; tries: number; } & ( | { status: "pending"; result?: undefined; error?: undefined; } | { status: "error"; error: TUploadError; result?: undefined; } | { status: "success"; result: TUploadRes; error?: undefined; } ); const fileStatusReducer = ( state: FileStatus[], action: | { type: "add"; id: string; fileName: string; file: File; } | { type: "remove"; id: string; } | ({ type: "update-status"; id: string; } & DropzoneResult), ): FileStatus[] => { switch (action.type) { case "add": return [ ...state, { id: action.id, fileName: action.fileName, file: action.file, status: "pending", tries: 1, }, ]; case "remove": return state.filter((fileStatus) => fileStatus.id !== action.id); case "update-status": return state.map((fileStatus) => { if (fileStatus.id === action.id) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const {id, type, ...rest} = action; return { ...fileStatus, ...rest, tries: action.status === "pending" ? fileStatus.tries + 1 : fileStatus.tries, } as FileStatus; } return fileStatus; }); } }; type DropZoneErrorCode = (typeof dropZoneErrorCodes)[number]; const dropZoneErrorCodes = [ "file-invalid-type", "file-too-large", "file-too-small", "too-many-files", ] as const; const getDropZoneErrorCodes = (fileRejections: FileRejection[]) => { const errors = fileRejections.map((rejection) => { return rejection.errors .filter((error) => dropZoneErrorCodes.includes(error.code as DropZoneErrorCode), ) .map((error) => error.code) as DropZoneErrorCode[]; }); return Array.from(new Set(errors.flat())); }; const getRootError = ( errorCodes: DropZoneErrorCode[], limits: { accept?: Accept; maxSize?: number; minSize?: number; maxFiles?: number; }, ) => { const errors = errorCodes.map((error) => { switch (error) { case "file-invalid-type": const acceptedTypes = Object.values(limits.accept ?? {}) .flat() .join(", "); return `only ${acceptedTypes} are allowed`; case "file-too-large": const maxMb = limits.maxSize ? (limits.maxSize / (1024 * 1024)).toFixed(2) : "infinite?"; return `max size is ${maxMb}MB`; case "file-too-small": const roundedMinSize = limits.minSize ? (limits.minSize / (1024 * 1024)).toFixed(2) : "negative?"; return `min size is ${roundedMinSize}MB`; case "too-many-files": return `max ${limits.maxFiles} files`; } }); const joinedErrors = errors.join(", "); return joinedErrors.charAt(0).toUpperCase() + joinedErrors.slice(1); }; type UseDropzoneProps = { onDropFile: ( file: File, ) => Promise< Exclude, { status: "pending" }> >; onRemoveFile?: (id: string) => void | Promise; onFileUploaded?: (result: TUploadRes) => void; onFileUploadError?: (error: TUploadError) => void; onAllUploaded?: () => void; onRootError?: (error: string | undefined) => void; maxRetryCount?: number; autoRetry?: boolean; validation?: { accept?: Accept; minSize?: number; maxSize?: number; maxFiles?: number; }; shiftOnMaxFiles?: boolean; } & (TUploadError extends string ? { shapeUploadError?: (error: TUploadError) => string | void; } : { shapeUploadError: (error: TUploadError) => string | void; }); interface UseDropzoneReturn { getRootProps: ReturnType["getRootProps"]; getInputProps: ReturnType["getInputProps"]; onRemoveFile: (id: string) => Promise; onRetry: (id: string) => Promise; canRetry: (id: string) => boolean; fileStatuses: FileStatus[]; isInvalid: boolean; isDragActive: boolean; rootError: string | undefined; inputId: string; rootMessageId: string; rootDescriptionId: string; getFileMessageId: (id: string) => string; } const useDropzone = ( props: UseDropzoneProps, ): UseDropzoneReturn => { const { onDropFile: pOnDropFile, onRemoveFile: pOnRemoveFile, shapeUploadError: pShapeUploadError, onFileUploaded: pOnFileUploaded, onFileUploadError: pOnFileUploadError, onAllUploaded: pOnAllUploaded, onRootError: pOnRootError, maxRetryCount, autoRetry, validation, shiftOnMaxFiles, } = props; const inputId = useId(); const rootMessageId = `${inputId}-root-message`; const rootDescriptionId = `${inputId}-description`; const [rootError, _setRootError] = useState(undefined); const setRootError = useCallback( (error: string | undefined) => { _setRootError(error); if (pOnRootError !== undefined) { pOnRootError(error); } }, [pOnRootError, _setRootError], ); const [fileStatuses, dispatch] = useReducer(fileStatusReducer, []); const isInvalid = useMemo(() => { return ( fileStatuses.filter((file) => file.status === "error").length > 0 || rootError !== undefined ); }, [fileStatuses, rootError]); const _uploadFile = useCallback( async (file: File, id: string, tries = 0) => { const result = await pOnDropFile(file); if (result.status === "error") { if (autoRetry === true && tries < (maxRetryCount ?? Infinity)) { dispatch({type: "update-status", id, status: "pending"}); return _uploadFile(file, id, tries + 1); } dispatch({ type: "update-status", id, status: "error", error: pShapeUploadError !== undefined ? pShapeUploadError(result.error) : result.error, }); if (pOnFileUploadError !== undefined) { pOnFileUploadError(result.error); } return; } if (pOnFileUploaded !== undefined) { pOnFileUploaded(result.result); } dispatch({ type: "update-status", id, ...result, }); }, [ autoRetry, maxRetryCount, pOnDropFile, pShapeUploadError, pOnFileUploadError, pOnFileUploaded, ], ); const onRemoveFile = useCallback( async (id: string) => { await pOnRemoveFile?.(id); dispatch({type: "remove", id}); }, [pOnRemoveFile], ); const canRetry = useCallback( (id: string) => { const fileStatus = fileStatuses.find((file) => file.id === id); return ( fileStatus?.status === "error" && fileStatus.tries < (maxRetryCount ?? Infinity) ); }, [fileStatuses, maxRetryCount], ); const onRetry = useCallback( async (id: string) => { if (!canRetry(id)) { return; } dispatch({type: "update-status", id, status: "pending"}); const fileStatus = fileStatuses.find((file) => file.id === id); if (!fileStatus || fileStatus.status !== "error") { return; } await _uploadFile(fileStatus.file, id); }, [canRetry, fileStatuses, _uploadFile], ); const getFileMessageId = (id: string) => `${inputId}-${id}-message`; const dropzone = rootUseDropzone({ accept: validation?.accept, minSize: validation?.minSize, maxSize: validation?.maxSize, onDropAccepted: async (newFiles) => { setRootError(undefined); // useDropzone hook only checks max file count per group of uploaded files, allows going over if in multiple batches const fileCount = fileStatuses.length; const maxNewFiles = validation?.maxFiles === undefined ? Infinity : validation?.maxFiles - fileCount; if (maxNewFiles < newFiles.length) { if (shiftOnMaxFiles === true) { } else { setRootError(getRootError(["too-many-files"], validation ?? {})); } } const slicedNewFiles = shiftOnMaxFiles === true ? newFiles : newFiles.slice(0, maxNewFiles); const onDropFilePromises = slicedNewFiles.map(async (file, index) => { if (fileCount + 1 > maxNewFiles) { await onRemoveFile(fileStatuses[index].id); } const id = crypto.randomUUID(); dispatch({type: "add", fileName: file.name, file, id}); await _uploadFile(file, id); }); await Promise.all(onDropFilePromises); if (pOnAllUploaded !== undefined) { pOnAllUploaded(); } }, onDropRejected: (fileRejections) => { const errorMessage = getRootError( getDropZoneErrorCodes(fileRejections), validation ?? {} ); setRootError(errorMessage); }, }); return { getRootProps: dropzone.getRootProps, getInputProps: dropzone.getInputProps, inputId, rootMessageId, rootDescriptionId, getFileMessageId, onRemoveFile, onRetry, canRetry, fileStatuses: fileStatuses as FileStatus[], isInvalid, rootError, isDragActive: dropzone.isDragActive, }; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const DropZoneContext = createContext>({ getRootProps: () => ({}) as never, getInputProps: () => ({}) as never, onRemoveFile: async () => { }, onRetry: async () => { }, canRetry: () => false, fileStatuses: [], isInvalid: false, isDragActive: false, rootError: undefined, inputId: "", rootMessageId: "", rootDescriptionId: "", getFileMessageId: () => "", }); const useDropzoneContext = () => { return useContext(DropZoneContext) as UseDropzoneReturn< TUploadRes, TUploadError >; }; interface DropzoneProps extends UseDropzoneReturn { children: React.ReactNode; } const Dropzone = ( props: DropzoneProps, ) => { const {children, ...rest} = props; return ( {children} ); }; Dropzone.displayName = "Dropzone"; interface DropZoneAreaProps extends React.HTMLAttributes { } const DropZoneArea = forwardRef( ({className, children, ...props}, forwardedRef) => { const context = useDropzoneContext(); if (!context) { throw new Error("DropzoneArea must be used within a Dropzone"); } const {onFocus, onBlur, onDragEnter, onDragLeave, onDrop, ref} = context.getRootProps(); return ( // A11y behavior is handled through Trigger. All of these are only relevant to drag and drop which means this should be fine? // eslint-disable-next-line jsx-a11y/no-static-element-interactions
{ // TODO: test if this actually works? ref.current = instance; if (typeof forwardedRef === "function") { forwardedRef(instance); } else if (forwardedRef) { forwardedRef.current = instance; } }} onFocus={onFocus} onBlur={onBlur} onDragEnter={onDragEnter} onDragLeave={onDragLeave} onDrop={onDrop} {...props} aria-label="dropzone" className={cn( "flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", context.isDragActive && "animate-pulse bg-black/5", context.isInvalid && "border-destructive", className, )} > {children}
); }, ); DropZoneArea.displayName = "DropZoneArea"; export interface DropzoneDescriptionProps extends React.HTMLAttributes { } const DropzoneDescription = forwardRef< HTMLParagraphElement, DropzoneDescriptionProps >((props, ref) => { const {className, ...rest} = props; const context = useDropzoneContext(); if (!context) { throw new Error("DropzoneDescription must be used within a Dropzone"); } return (

); }); DropzoneDescription.displayName = "DropzoneDescription"; interface DropzoneFileListContext { onRemoveFile: () => Promise; onRetry: () => Promise; fileStatus: FileStatus; canRetry: boolean; dropzoneId: string; messageId: string; } const DropzoneFileListContext = createContext< DropzoneFileListContext >({ onRemoveFile: async () => { }, onRetry: async () => { }, fileStatus: {} as FileStatus, canRetry: false, dropzoneId: "", messageId: "", }); const useDropzoneFileListContext = () => { return useContext(DropzoneFileListContext); }; interface DropZoneFileListProps extends React.OlHTMLAttributes { } const DropzoneFileList = forwardRef( (props, ref) => { const context = useDropzoneContext(); if (!context) { throw new Error("DropzoneFileList must be used within a Dropzone"); } return (

    {props.children}
); }, ); DropzoneFileList.displayName = "DropzoneFileList"; interface DropzoneFileListItemProps extends React.LiHTMLAttributes { file: FileStatus; } const DropzoneFileListItem = forwardRef< HTMLLIElement, DropzoneFileListItemProps >(({className, ...props}, ref) => { const fileId = props.file.id; const { onRemoveFile: cOnRemoveFile, onRetry: cOnRetry, getFileMessageId: cGetFileMessageId, canRetry: cCanRetry, inputId: cInputId, } = useDropzoneContext(); const onRemoveFile = useCallback( () => cOnRemoveFile(fileId), [fileId, cOnRemoveFile], ); const onRetry = useCallback(() => cOnRetry(fileId), [fileId, cOnRetry]); const messageId = cGetFileMessageId(fileId); const isInvalid = props.file.status === "error"; const canRetry = useMemo(() => cCanRetry(fileId), [fileId, cCanRetry]); return (
  • {props.children}
  • ); }); DropzoneFileListItem.displayName = "DropzoneFileListItem"; interface DropzoneFileMessageProps extends React.HTMLAttributes { } const DropzoneFileMessage = forwardRef< HTMLParagraphElement, DropzoneFileMessageProps >((props, ref) => { const {children, ...rest} = props; const context = useDropzoneFileListContext(); if (!context) { throw new Error( "DropzoneFileMessage must be used within a DropzoneFileListItem", ); } const body = context.fileStatus.status === "error" ? String(context.fileStatus.error) : children; return (

    {body}

    ); }); DropzoneFileMessage.displayName = "DropzoneFileMessage"; interface DropzoneMessageProps extends React.HTMLAttributes { } const DropzoneMessage = forwardRef( (props, ref) => { const {children, ...rest} = props; const context = useDropzoneContext(); if (!context) { throw new Error("DropzoneRootMessage must be used within a Dropzone"); } const body = context.rootError ? String(context.rootError) : children; return (

    {body}

    ); }, ); DropzoneMessage.displayName = "DropzoneMessage"; interface DropzoneRemoveFileProps extends ButtonVariantsProps { className?: string; children?: React.ReactNode; } const DropzoneRemoveFile = forwardRef< HTMLButtonElement, DropzoneRemoveFileProps >(({className, ...props}, ref) => { const context = useDropzoneFileListContext(); if (!context) { throw new Error( "DropzoneRemoveFile must be used within a DropzoneFileListItem", ); } return ( ); }); DropzoneRemoveFile.displayName = "DropzoneRemoveFile"; interface DropzoneRetryFileProps extends ButtonVariantsProps { className?: string; children?: React.ReactNode; } const DropzoneRetryFile = forwardRef( ({className, ...props}, ref) => { const context = useDropzoneFileListContext(); if (!context) { throw new Error( "DropzoneRetryFile must be used within a DropzoneFileListItem", ); } const canRetry = context.canRetry; return ( ); }, ); DropzoneRetryFile.displayName = "DropzoneRetryFile"; interface DropzoneTriggerProps extends React.LabelHTMLAttributes { } const DropzoneTrigger = forwardRef( ({className, children, ...props}, ref) => { const context = useDropzoneContext(); if (!context) { throw new Error("DropzoneTrigger must be used within a Dropzone"); } const {fileStatuses, getFileMessageId} = context; const fileMessageIds = useMemo( () => fileStatuses .filter((file) => file.status === "error") .map((file) => getFileMessageId(file.id)), [fileStatuses, getFileMessageId], ); return ( ); }, ); DropzoneTrigger.displayName = "DropzoneTrigger"; interface InfiniteProgressProps extends React.HTMLAttributes { status: "pending" | "success" | "error"; } const valueTextMap = { pending: "indeterminate", success: "100%", error: "error", }; const InfiniteProgress = forwardRef( ({className, ...props}, ref) => { const done = props.status === "success" || props.status === "error"; const error = props.status === "error"; return (
    ); }, ); InfiniteProgress.displayName = "InfiniteProgress"; export { Dropzone, DropZoneArea, DropzoneDescription, DropzoneFileList, DropzoneFileListItem, DropzoneFileMessage, DropzoneMessage, DropzoneRemoveFile, DropzoneRetryFile, DropzoneTrigger, InfiniteProgress, useDropzone, };