파일 업로드 conponent 추가

This commit is contained in:
truthwing
2025-03-06 17:51:26 +09:00
parent 6fe828d77f
commit 432579de83
4 changed files with 418 additions and 2 deletions
@@ -34,8 +34,10 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.474.0", "lucide-react": "^0.474.0",
"path": "^0.12.7", "path": "^0.12.7",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"recharts": "^2.15.1", "recharts": "^2.15.1",
"sonner": "^2.0.1",
"tailwind-merge": "^3.0.1", "tailwind-merge": "^3.0.1",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
@@ -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<HTMLParagraphElement> & { files: File[]|null, setFiles: (files: File[]|null) => void , fileTypes: string|undefined}
>(({ ...props }) => {
return (
<FileUploader
value={props.files}
onValueChange={props.setFiles}
dropzoneOptions={dropZoneConfig}
acceptName={props?.fileTypes}
className="relative bg-background rounded-lg p-2"
>
<FileInput
id="fileInput"
className="outline-dashed outline-1 outline-slate-500"
>
<div className="flex items-center justify-center flex-col p-8 w-full ">
<CloudUpload className='text-gray-500 w-10 h-10' />
<p className="mb-1 text-sm text-gray-500 dark:text-gray-400">
<span className="font-semibold">Click to upload</span>
&nbsp; or drag and drop
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{!props?.fileTypes?"SVG, PNG, JPG, GIF":props?.fileTypes}
</p>
</div>
</FileInput>
<FileUploaderContent>
{props.files &&
props.files.length > 0 &&
props.files.map((file:File, i:number) => (
<FileUploaderItem key={i} index={i}>
<Paperclip className="h-4 w-4 stroke-current" />
<span>{file.name}</span>
</FileUploaderItem>
))}
</FileUploaderContent>
</FileUploader>
)
})
@@ -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<SetStateAction<number>>;
orientation: "horizontal" | "vertical";
direction: DirectionOptions;
};
const FileUploaderContext = createContext<FileUploaderContextType | null>(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<HTMLDivElement> & {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<HTMLDivElement>) => {
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 (
<FileUploaderContext.Provider
value={{
dropzoneState,
isLOF,
isFileTooBig,
removeFileFromSet,
activeIndex,
setActiveIndex,
orientation,
direction,
}}
>
<div
ref={ref}
tabIndex={0}
onKeyDownCapture={handleKeyDown}
className={cn(
"grid w-full focus:outline-none overflow-hidden ",
className,
{
"gap-2": value && value.length > 0,
},
)}
dir={dir}
{...props}
>
{children}
</div>
</FileUploaderContext.Provider>
);
},
);
FileUploader.displayName = "FileUploader";
export const FileUploaderContent = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ children, className, ...props }, ref) => {
const { orientation } = useFileUpload();
const containerRef = useRef<HTMLDivElement>(null);
return (
<div
className={cn("w-full px-1")}
ref={containerRef}
aria-description="content file holder"
>
<div
{...props}
ref={ref}
className={cn(
"flex rounded-xl gap-1",
orientation === "horizontal" ? "flex-raw flex-wrap" : "flex-col",
className,
)}
>
{children}
</div>
</div>
);
});
FileUploaderContent.displayName = "FileUploaderContent";
export const FileUploaderItem = forwardRef<
HTMLDivElement,
{ index: number } & React.HTMLAttributes<HTMLDivElement>
>(({ className, index, children, ...props }, ref) => {
const { removeFileFromSet, activeIndex, direction } = useFileUpload();
const isSelected = index === activeIndex;
return (
<div
ref={ref}
className={cn(
buttonVariants({ variant: "ghost" }),
"h-6 p-1 justify-between cursor-pointer relative",
className,
isSelected ? "bg-muted" : "",
)}
{...props}
>
<div className="font-medium leading-none tracking-tight flex items-center gap-1.5 h-full w-full">
{children}
</div>
<button
type="button"
className={cn(
"absolute",
direction === "rtl" ? "top-1 left-1" : "top-1 right-1",
)}
onClick={() => removeFileFromSet(index)}
>
<span className="sr-only">remove item {index}</span>
<RemoveIcon className="w-4 h-4 hover:stroke-destructive duration-200 ease-in-out" />
</button>
</div>
);
});
FileUploaderItem.displayName = "FileUploaderItem";
export const FileInput = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => {
const { dropzoneState, isFileTooBig, isLOF } = useFileUpload();
const rootProps = isLOF ? {} : dropzoneState.getRootProps();
return (
<div
ref={ref}
{...props}
className={`relative w-full ${
isLOF ? "opacity-50 cursor-not-allowed " : "cursor-pointer "
}`}
>
<div
className={cn(
`w-full rounded-lg duration-300 ease-in-out
${
dropzoneState.isDragAccept
? "border-green-500"
: dropzoneState.isDragReject || isFileTooBig
? "border-red-500"
: "border-gray-300"
}`,
className,
)}
{...rootProps}
>
{children}
</div>
<Input
ref={dropzoneState.inputRef}
disabled={isLOF}
{...dropzoneState.getInputProps()}
className={`${isLOF ? "cursor-not-allowed" : ""}`}
/>
</div>
);
});
FileInput.displayName = "FileInput";
@@ -1,5 +1,6 @@
export * from "./chart"; export * from "./chart";
export * from "./table"; export * from "./file-upload";
export * from "./form"; export * from "./form";
export * from "./search_form"; export * from "./search_form";
export * from "./select"; export * from "./select";
export * from "./table";