summaryrefslogtreecommitdiff
path: root/lib/dolce/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'lib/dolce/hooks')
-rw-r--r--lib/dolce/hooks/use-file-upload-with-progress.ts136
-rw-r--r--lib/dolce/hooks/use-file-upload.ts107
2 files changed, 243 insertions, 0 deletions
diff --git a/lib/dolce/hooks/use-file-upload-with-progress.ts b/lib/dolce/hooks/use-file-upload-with-progress.ts
new file mode 100644
index 00000000..04fa5189
--- /dev/null
+++ b/lib/dolce/hooks/use-file-upload-with-progress.ts
@@ -0,0 +1,136 @@
+import { useState, useCallback } from "react";
+import { useDropzone, FileRejection } from "react-dropzone";
+import { toast } from "sonner";
+
+export interface FileUploadProgress {
+ file: File;
+ progress: number; // 0-100
+ status: "pending" | "uploading" | "completed" | "error";
+ error?: string;
+}
+
+interface UseFileUploadWithProgressOptions {
+ onFilesAdded?: (files: File[]) => void;
+}
+
+export function useFileUploadWithProgress(options: UseFileUploadWithProgressOptions = {}) {
+ const [fileProgresses, setFileProgresses] = useState<FileUploadProgress[]>([]);
+
+ // 파일 검증
+ const validateFiles = useCallback((filesToValidate: File[]): { valid: File[]; invalid: string[] } => {
+ const MAX_FILE_SIZE = 1024 * 1024 * 1024; // 1GB
+ const FORBIDDEN_EXTENSIONS = ['exe', 'com', 'dll', 'vbs', 'js', 'asp', 'aspx', 'bat', 'cmd'];
+
+ const validFiles: File[] = [];
+ const invalidFiles: string[] = [];
+
+ filesToValidate.forEach((file) => {
+ // 크기 검증
+ if (file.size > MAX_FILE_SIZE) {
+ invalidFiles.push(`${file.name}: 파일 크기가 1GB를 초과합니다`);
+ return;
+ }
+
+ // 확장자 검증 (블랙리스트)
+ const extension = file.name.split('.').pop()?.toLowerCase();
+ if (extension && FORBIDDEN_EXTENSIONS.includes(extension)) {
+ invalidFiles.push(`${file.name}: 금지된 파일 형식입니다 (.${extension})`);
+ return;
+ }
+
+ validFiles.push(file);
+ });
+
+ return { valid: validFiles, invalid: invalidFiles };
+ }, []);
+
+ // 파일 드롭 핸들러
+ const onDrop = useCallback((acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
+ const { valid: validFiles, invalid: invalidMessages } = validateFiles(acceptedFiles);
+
+ // 거부된 파일 처리
+ if (rejectedFiles.length > 0) {
+ rejectedFiles.forEach((rejected) => {
+ const errorMsg = rejected.errors?.[0]?.message || "파일이 거부되었습니다";
+ toast.error(`${rejected.file.name}: ${errorMsg}`);
+ });
+ }
+
+ // 유효하지 않은 파일 메시지 표시
+ if (invalidMessages.length > 0) {
+ invalidMessages.forEach((msg) => toast.error(msg));
+ }
+
+ if (validFiles.length > 0) {
+ // 중복 제거
+ const existingNames = new Set(fileProgresses.map((fp) => fp.file.name));
+ const newFiles = validFiles.filter((f) => !existingNames.has(f.name));
+
+ if (newFiles.length === 0) {
+ toast.error("이미 선택된 파일입니다");
+ return;
+ }
+
+ setFileProgresses((prev) => {
+ const newProgresses = newFiles.map((file) => ({
+ file,
+ progress: 0,
+ status: "pending" as const,
+ }));
+ const updated = [...prev, ...newProgresses];
+ options.onFilesAdded?.(updated.map((fp) => fp.file));
+ return updated;
+ });
+ toast.success(`${newFiles.length}개 파일이 선택되었습니다`);
+ }
+ }, [fileProgresses, validateFiles, options]);
+
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ onDrop,
+ multiple: true,
+ maxSize: 1024 * 1024 * 1024, // 1GB
+ });
+
+ // 파일 제거
+ const removeFile = useCallback((index: number) => {
+ setFileProgresses((prev) => prev.filter((_, i) => i !== index));
+ }, []);
+
+ // 전체 파일 제거
+ const clearFiles = useCallback(() => {
+ setFileProgresses([]);
+ }, []);
+
+ // 특정 파일의 진행도 업데이트
+ const updateFileProgress = useCallback((index: number, progress: number, status: FileUploadProgress["status"], error?: string) => {
+ setFileProgresses((prev) =>
+ prev.map((fp, i) =>
+ i === index ? { ...fp, progress, status, error } : fp
+ )
+ );
+ }, []);
+
+ // 파일 배열 직접 설정
+ const setFiles = useCallback((files: File[]) => {
+ setFileProgresses(
+ files.map((file) => ({
+ file,
+ progress: 0,
+ status: "pending",
+ }))
+ );
+ }, []);
+
+ return {
+ fileProgresses,
+ files: fileProgresses.map((fp) => fp.file),
+ setFiles,
+ removeFile,
+ clearFiles,
+ updateFileProgress,
+ getRootProps,
+ getInputProps,
+ isDragActive,
+ };
+}
+
diff --git a/lib/dolce/hooks/use-file-upload.ts b/lib/dolce/hooks/use-file-upload.ts
new file mode 100644
index 00000000..38556cb9
--- /dev/null
+++ b/lib/dolce/hooks/use-file-upload.ts
@@ -0,0 +1,107 @@
+import { useState, useCallback } from "react";
+import { useDropzone, FileRejection } from "react-dropzone";
+import { toast } from "sonner";
+
+interface UseFileUploadOptions {
+ onFilesAdded?: (files: File[]) => void;
+}
+
+export function useFileUpload(options: UseFileUploadOptions = {}) {
+ const [files, setFiles] = useState<File[]>([]);
+
+ // 파일 검증
+ const validateFiles = useCallback((filesToValidate: File[]): { valid: File[]; invalid: string[] } => {
+ const MAX_FILE_SIZE = 1024 * 1024 * 1024; // 1GB
+ const FORBIDDEN_EXTENSIONS = ['exe', 'com', 'dll', 'vbs', 'js', 'asp', 'aspx', 'bat', 'cmd'];
+
+ const validFiles: File[] = [];
+ const invalidFiles: string[] = [];
+
+ filesToValidate.forEach((file) => {
+ // 크기 검증
+ if (file.size > MAX_FILE_SIZE) {
+ invalidFiles.push(`${file.name}: 파일 크기가 1GB를 초과합니다`);
+ return;
+ }
+
+ // 확장자 검증 (블랙리스트)
+ const extension = file.name.split('.').pop()?.toLowerCase();
+ if (extension && FORBIDDEN_EXTENSIONS.includes(extension)) {
+ invalidFiles.push(`${file.name}: 금지된 파일 형식입니다 (.${extension})`);
+ return;
+ }
+
+ validFiles.push(file);
+ });
+
+ return { valid: validFiles, invalid: invalidFiles };
+ }, []);
+
+ // 파일 드롭 핸들러
+ const onDrop = useCallback((acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
+ const { valid: validFiles, invalid: invalidMessages } = validateFiles(acceptedFiles);
+
+ // 거부된 파일 처리
+ if (rejectedFiles.length > 0) {
+ rejectedFiles.forEach((rejected) => {
+ const errorMsg = rejected.errors?.[0]?.message || "파일이 거부되었습니다";
+ toast.error(`${rejected.file.name}: ${errorMsg}`);
+ });
+ }
+
+ // 유효하지 않은 파일 메시지 표시
+ if (invalidMessages.length > 0) {
+ invalidMessages.forEach((msg) => toast.error(msg));
+ }
+
+ if (validFiles.length > 0) {
+ // 중복 제거
+ const existingNames = new Set(files.map((f) => f.name));
+ const newFiles = validFiles.filter((f) => !existingNames.has(f.name));
+
+ if (newFiles.length === 0) {
+ toast.error("이미 선택된 파일입니다");
+ return;
+ }
+
+ setFiles((prev) => {
+ const updated = [...prev, ...newFiles];
+ options.onFilesAdded?.(updated);
+ return updated;
+ });
+ toast.success(`${newFiles.length}개 파일이 선택되었습니다`);
+ }
+ }, [files, validateFiles, options]);
+
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ onDrop,
+ multiple: true,
+ maxSize: 1024 * 1024 * 1024, // 1GB
+ });
+
+ // 파일 제거
+ const removeFile = useCallback((index: number) => {
+ setFiles((prev) => prev.filter((_, i) => i !== index));
+ }, []);
+
+ // 전체 파일 제거
+ const clearFiles = useCallback(() => {
+ setFiles([]);
+ }, []);
+
+ // 파일 배열 직접 설정
+ const setFileList = useCallback((newFiles: File[]) => {
+ setFiles(newFiles);
+ }, []);
+
+ return {
+ files,
+ setFiles: setFileList,
+ removeFile,
+ clearFiles,
+ getRootProps,
+ getInputProps,
+ isDragActive,
+ };
+}
+