"use client"; import { Trash2 as RemoveIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { createContext, Dispatch, forwardRef, SetStateAction, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import { Accept, DropzoneOptions, DropzoneState, ErrorCode, FileRejection, FileWithPath, useDropzone, } from "react-dropzone"; import { showError } from "../../utils/toast"; import { buttonVariants, Input, Tooltip, TooltipContent, TooltipTrigger } from "../../shadcnui"; import { cn } from "../../utils"; export type { DropzoneOptions } from "react-dropzone"; type DirectionOptions = "rtl" | "ltr" | undefined; /** * One entry of react-dropzone 19.3's grouped `accept` form. Declared locally rather than * imported because 19.1 has no `AcceptGroup` export, and this file must compile against both. */ type AcceptGroupLike = { description?: string; accept: Accept }; type FileUploaderContextType = { dropzoneState: DropzoneState; isLOF: boolean; isFileTooBig: boolean; removeFileFromSet: (index: number) => void; activeIndex: number; setActiveIndex: Dispatch>; orientation: "horizontal" | "vertical"; direction: DirectionOptions; // Mirrors whatever react-dropzone declares: 19.1 had `Accept`, 19.3 widened it to // `Accept | AcceptGroup[]`. Indexing the option type keeps this correct on both. accept?: DropzoneOptions["accept"]; }; 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; reSelect?: boolean; onValueChange: (value: File[] | null) => void; dropzoneOptions: DropzoneOptions; orientation?: "horizontal" | "vertical"; /** * Opt-in rejection reporting. When supplied, the component hands every * rejection — react-dropzone's own plus the files it had to drop because the * `maxFiles` cap was already reached — to the consumer and raises no toast of * its own. When absent, the built-in toast behaviour is unchanged. */ onFilesRejected?: (rejections: FileRejection[]) => void; }; export const FileUploader = forwardRef>( ( { className, dropzoneOptions, value, onValueChange, reSelect, orientation = "vertical", children, dir, onFilesRejected, ...props }, ref, ) => { const [isFileTooBig, setIsFileTooBig] = useState(false); const [isLOF, setIsLOF] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); const { maxFiles = 1, maxSize = 4 * 1024 * 1024, multiple = true } = dropzoneOptions; const t = useTranslations(); 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], ); const onDrop = useCallback( (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { const files = acceptedFiles; if (!files) { showError(t("common.errors.file"), { description: t("common.errors.file_large"), }); return; } const newValues: File[] = value ? [...value] : []; if (reSelectAll) { newValues.splice(0, newValues.length); } // Split at the remaining capacity instead of silently dropping the tail: // the overflow is a rejection the consumer may want to show. const remaining = Math.max(0, maxFiles - newValues.length); const admitted = files.slice(0, remaining); const overflow = files.slice(remaining); admitted.forEach((file) => newValues.push(file)); onValueChange(newValues); if (onFilesRejected) { const overflowRejections: FileRejection[] = overflow.map((file) => ({ file: file as FileWithPath, errors: [{ code: ErrorCode.TooManyFiles, message: `Too many files. Only ${maxFiles} allowed.` }], })); const allRejections = [...rejectedFiles, ...overflowRejections]; if (allRejections.length > 0) onFilesRejected(allRejections); return; } if (rejectedFiles.length > 0) { for (let i = 0; i < rejectedFiles.length; i++) { if (rejectedFiles[i].errors[0]?.code === "file-too-large") { showError(t("common.errors.file"), { description: t(`common.errors.file_max`, { size: maxSize / 1024 / 1024 }), }); break; } if (rejectedFiles[i].errors[0]?.message) { showError(t(`common.errors.file`), { description: rejectedFiles[i].errors[0].message, }); break; } } } }, [reSelectAll, value, maxFiles, maxSize, onValueChange, onFilesRejected, t], ); useEffect(() => { if (!value) return; if (value.length === maxFiles) { // setIsLOF(true); return; } setIsLOF(false); }, [value, maxFiles]); const opts = dropzoneOptions ? dropzoneOptions : { maxFiles, maxSize, multiple }; const dropzoneState = useDropzone({ ...opts, onDrop, onDropRejected: () => setIsFileTooBig(true), onDropAccepted: () => setIsFileTooBig(false), }); const { isDragActive } = dropzoneState; // Correctly get isDragActive return (
0, "bg-muted border-primary border-dashed": isDragActive, // Apply drag-active styles to the main FileUploader div }, )} 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>( ({ className, index, children, ...props }, ref) => { const { removeFileFromSet, activeIndex, direction } = useFileUpload(); const isSelected = index === activeIndex; const t = useTranslations(); return (
{children}
); }, ); FileUploaderItem.displayName = "FileUploaderItem"; export const FileInput = forwardRef>( ({ className, children, ...props }, ref) => { const { dropzoneState, isFileTooBig, isLOF, accept } = useFileUpload(); const t = useTranslations(); const rootProps = isLOF ? {} : dropzoneState.getRootProps(); // Get isDragActive from the context for FileInput as well, to ensure it can react if needed, or to simplify its own styling. const { isDragActive: parentIsDragActive } = dropzoneState; const acceptedLabels = useMemo(() => { if (!accept) return null; // react-dropzone 19.3 made two changes to `accept`: it allows a grouped form // (`{ description?, accept }[]`) beside the flat MIME -> extensions record, and it widened // each value from `readonly string[]` to `string | readonly string[]`. Normalise both so a // single-extension string is not iterated character by character. const entries: [string, string | readonly string[]][] = Array.isArray(accept) ? accept.flatMap((group: AcceptGroupLike) => Object.entries(group.accept)) : Object.entries(accept); const extensions = new Set(); let hasWildcardImages = false; for (const [mime, exts] of entries) { if (mime === "image/*") hasWildcardImages = true; for (const ext of typeof exts === "string" ? [exts] : exts) extensions.add(ext); } const labels = Array.from(extensions).sort(); if (hasWildcardImages) labels.push(t("ui.labels.images")); return labels.length > 0 ? labels : null; }, [accept, t]); const dropArea = (
{children}
); return (
{acceptedLabels ? ( {t("ui.labels.accepted_file_types")}: {acceptedLabels.join(", ")} ) : ( dropArea )}
); }, ); FileInput.displayName = "FileInput";