From 432579de83b9cb3611acee49764739e5badd480c Mon Sep 17 00:00:00 2001 From: truthwing Date: Thu, 6 Mar 2025 17:51:26 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20conponent=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/business_components/package.json | 2 + .../src/ui/file-upload/index.tsx | 49 +++ .../src/ui/file-upload/lib/file_upload.tsx | 364 ++++++++++++++++++ .../business_components/src/ui/index.ts | 5 +- 4 files changed, 418 insertions(+), 2 deletions(-) create mode 100644 packages/common/business_components/src/ui/file-upload/index.tsx create mode 100644 packages/common/business_components/src/ui/file-upload/lib/file_upload.tsx diff --git a/packages/common/business_components/package.json b/packages/common/business_components/package.json index 551de2ac5..9618ff295 100644 --- a/packages/common/business_components/package.json +++ b/packages/common/business_components/package.json @@ -34,8 +34,10 @@ "clsx": "^2.1.1", "lucide-react": "^0.474.0", "path": "^0.12.7", + "react-dropzone": "^14.3.8", "react-hook-form": "^7.54.2", "recharts": "^2.15.1", + "sonner": "^2.0.1", "tailwind-merge": "^3.0.1", "zod": "^3.24.2" }, diff --git a/packages/common/business_components/src/ui/file-upload/index.tsx b/packages/common/business_components/src/ui/file-upload/index.tsx new file mode 100644 index 000000000..13126692d --- /dev/null +++ b/packages/common/business_components/src/ui/file-upload/index.tsx @@ -0,0 +1,49 @@ +"use client"; +import { CloudUpload, Paperclip } from "lucide-react"; +import React from "react"; +import { FileInput, FileUploader, FileUploaderContent, FileUploaderItem } from "./lib/file_upload"; +const dropZoneConfig = { + maxFiles: 5, + maxSize: 1024 * 1024 * 4, + multiple: true, + }; +export const FileUpload = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes & { files: File[]|null, setFiles: (files: File[]|null) => void , fileTypes: string|undefined} +>(({ ...props }) => { + return ( + + +
+ +

+ Click to upload +   or drag and drop +

+

+ {!props?.fileTypes?"SVG, PNG, JPG, GIF":props?.fileTypes} +

+
+
+ + {props.files && + props.files.length > 0 && + props.files.map((file:File, i:number) => ( + + + {file.name} + + ))} + +
+ ) +}) \ No newline at end of file diff --git a/packages/common/business_components/src/ui/file-upload/lib/file_upload.tsx b/packages/common/business_components/src/ui/file-upload/lib/file_upload.tsx new file mode 100644 index 000000000..3db4bcceb --- /dev/null +++ b/packages/common/business_components/src/ui/file-upload/lib/file_upload.tsx @@ -0,0 +1,364 @@ +"use client"; + +import { Input, buttonVariants } from "@common/components"; +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"; +import { toast } from "sonner"; +import { cn } from "../../../lib/utils"; + +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; + reSelect?: boolean; + onValueChange: (value: File[] | null) => void; + dropzoneOptions: DropzoneOptions; + orientation?: "horizontal" | "vertical"; +}; + +/** + * File upload Docs: {@link: https://localhost:3000/docs/file-upload} + */ + +export const FileUploader = forwardRef< + HTMLDivElement, + FileUploaderProps & React.HTMLAttributes & {acceptName?: string|undefined} +>( + ( + { + className, + dropzoneOptions, + value, + onValueChange, + reSelect, + orientation = "vertical", + children, + dir, + acceptName, + ...props + }, + ref, + ) => { + const [isFileTooBig, setIsFileTooBig] = useState(false); + const [isLOF, setIsLOF] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + const { + accept = { + "image/*": acceptName?acceptName.split(",").map(extend=>("."+extend)):[".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); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [value, activeIndex, removeFileFromSet], + ); + + 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); + } + + files.forEach((file) => { + 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; + } + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [reSelectAll, value], + ); + + 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< + HTMLDivElement, + React.HTMLAttributes +>(({ 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< + HTMLDivElement, + React.HTMLAttributes +>(({ className, children, ...props }, ref) => { + const { dropzoneState, isFileTooBig, isLOF } = useFileUpload(); + const rootProps = isLOF ? {} : dropzoneState.getRootProps(); + return ( +
+
+ {children} +
+ +
+ ); +}); + +FileInput.displayName = "FileInput"; diff --git a/packages/common/business_components/src/ui/index.ts b/packages/common/business_components/src/ui/index.ts index ef8fa6f8c..1876f2b50 100644 --- a/packages/common/business_components/src/ui/index.ts +++ b/packages/common/business_components/src/ui/index.ts @@ -1,5 +1,6 @@ export * from "./chart"; -export * from "./table"; +export * from "./file-upload"; export * from "./form"; export * from "./search_form"; -export * from "./select"; \ No newline at end of file +export * from "./select"; +export * from "./table";