import { Trash2 as RemoveIcon } from "lucide-react"; import { type Dispatch, type SetStateAction, createContext, forwardRef, useCallback, useContext, useEffect, useRef, useState, } from "react"; import { type DropzoneOptions, type DropzoneState, type FileRejection, useDropzone, } from "react-dropzone-esm"; import { toast } from "sonner"; import { buttonVariants } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { cn } from "~/lib/clsx"; type DirectionOptions = "rtl" | "ltr" | undefined; type FileUploaderContextType = { dropzoneState: DropzoneState; isLOF: boolean; isFileTooBig: boolean; removeFileFromSet: (index: number) => void; activeIndex: number; setActiveIndex: Dispatch>; orientation: "horizontal" | "vertical"; direction: DirectionOptions; }; const FileUploaderContext = createContext(null); export const useFileUpload = () => { const context = useContext(FileUploaderContext); if (!context) { throw new Error("useFileUpload must be used within a FileUploaderProvider"); } return context; }; type FileUploaderProps = { value: File[] | null | undefined; reSelect?: boolean; onValueChange: (value: File[] | null) => void; dropzoneOptions: DropzoneOptions; orientation?: "horizontal" | "vertical"; }; export const FileUploader = forwardRef< HTMLDivElement, FileUploaderProps & React.HTMLAttributes >( ( { className, dropzoneOptions, value, onValueChange, reSelect, orientation = "vertical", children, dir, ...props }, ref, ) => { const [isFileTooBig, setIsFileTooBig] = useState(false); const [isLOF, setIsLOF] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); const { accept = { "image/*": [".jpg", ".jpeg", ".png", ".gif"], }, maxFiles = 1, maxSize = 4 * 1024 * 1024, multiple = true, } = dropzoneOptions; const reSelectAll = maxFiles === 1 ? true : reSelect; const direction: DirectionOptions = dir === "rtl" ? "rtl" : "ltr"; const removeFileFromSet = useCallback( (i: number) => { if (!value) return; const newFiles = value.filter((_, index) => index !== i); onValueChange(newFiles); }, [value, onValueChange], ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { e.preventDefault(); e.stopPropagation(); if (!value) return; const moveNext = () => { const nextIndex = activeIndex + 1; setActiveIndex(nextIndex > value.length - 1 ? 0 : nextIndex); }; const movePrev = () => { const nextIndex = activeIndex - 1; setActiveIndex(nextIndex < 0 ? value.length - 1 : nextIndex); }; const prevKey = orientation === "horizontal" ? direction === "ltr" ? "ArrowLeft" : "ArrowRight" : "ArrowUp"; const nextKey = orientation === "horizontal" ? direction === "ltr" ? "ArrowRight" : "ArrowLeft" : "ArrowDown"; if (e.key === nextKey) { moveNext(); } else if (e.key === prevKey) { movePrev(); } else if (e.key === "Enter" || e.key === "Space") { if (activeIndex === -1) { dropzoneState.inputRef.current?.click(); } } else if (e.key === "Delete" || e.key === "Backspace") { if (activeIndex !== -1) { removeFileFromSet(activeIndex); if (value.length - 1 === 0) { setActiveIndex(-1); return; } movePrev(); } } else if (e.key === "Escape") { setActiveIndex(-1); } }, [value, activeIndex, removeFileFromSet, orientation, direction], ); const onDrop = useCallback( (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { const files = acceptedFiles; if (!files) { toast.error("file error , probably too big"); return; } const newValues: File[] = value ? [...value] : []; if (reSelectAll) { newValues.splice(0, newValues.length); } for (const file of files) { if (newValues.length < maxFiles) { newValues.push(file); } } onValueChange(newValues); if (rejectedFiles.length > 0) { for (let i = 0; i < rejectedFiles.length; i++) { if (rejectedFiles[i].errors[0]?.code === "file-too-large") { toast.error(`File is too large. Max size is ${maxSize / 1024 / 1024}MB`); break; } if (rejectedFiles[i].errors[0]?.message) { toast.error(rejectedFiles[i].errors[0].message); break; } } } }, [value, onValueChange, maxFiles, maxSize, reSelectAll], ); useEffect(() => { if (!value) return; if (value.length === maxFiles) { setIsLOF(true); return; } setIsLOF(false); }, [value, maxFiles]); const opts = dropzoneOptions ? dropzoneOptions : { accept, maxFiles, maxSize, multiple }; const dropzoneState = useDropzone({ ...opts, onDrop, onDropRejected: () => setIsFileTooBig(true), onDropAccepted: () => setIsFileTooBig(false), }); return (
0, })} dir={dir} {...props} > {children}
); }, ); FileUploader.displayName = "FileUploader"; export const FileUploaderContent = forwardRef>( ({ children, className, ...props }, ref) => { const { orientation } = useFileUpload(); const containerRef = useRef(null); return (
{children}
); }, ); FileUploaderContent.displayName = "FileUploaderContent"; export const FileUploaderItem = forwardRef< HTMLDivElement, { index: number } & React.HTMLAttributes >(({ className, index, children, ...props }, ref) => { const { removeFileFromSet, activeIndex, direction } = useFileUpload(); const isSelected = index === activeIndex; return (
{children}
); }); FileUploaderItem.displayName = "FileUploaderItem"; export const FileInput = forwardRef>( ({ className, children, ...props }, ref) => { const { dropzoneState, isFileTooBig, isLOF } = useFileUpload(); const rootProps = isLOF ? {} : dropzoneState.getRootProps(); return (
{children}
); }, ); FileInput.displayName = "FileInput";