파일 업로드 conponent 추가
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
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 "./table";
|
||||
export * from "./file-upload";
|
||||
export * from "./form";
|
||||
export * from "./search_form";
|
||||
export * from "./select";
|
||||
export * from "./select";
|
||||
export * from "./table";
|
||||
|
||||
Reference in New Issue
Block a user