1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
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,
};
}
|