diff options
| author | joonhoekim <26rote@gmail.com> | 2025-11-24 10:41:46 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-11-24 10:41:46 +0900 |
| commit | 26365ef08588d53b8c5d9c7cfaefb244536e6743 (patch) | |
| tree | 979841b791d1c3925eb1c3efa3f901f2c0bef193 /lib/dolce/hooks | |
| parent | fd4909bba7be8abc1eeab9ae1b4621c66a61604a (diff) | |
(김준회) 돌체 재개발 - 2차: 업로드 개선, 다운로드 오류 수정
Diffstat (limited to 'lib/dolce/hooks')
| -rw-r--r-- | lib/dolce/hooks/use-file-upload-with-progress.ts | 136 | ||||
| -rw-r--r-- | lib/dolce/hooks/use-file-upload.ts | 107 |
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, + }; +} + |
