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([]); // 파일 검증 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, }; }