From 26365ef08588d53b8c5d9c7cfaefb244536e6743 Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Mon, 24 Nov 2025 10:41:46 +0900
Subject: (김준회) 돌체 재개발 - 2차: 업로드 개선, 다운로드 오류 수정
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
lib/dolce/components/file-upload-progress-list.tsx | 98 +++++++++
lib/dolce/crypto-utils-legacy.ts | 88 +++-----
lib/dolce/crypto-utils.ts | 107 +++++----
lib/dolce/dialogs/add-detail-drawing-dialog.tsx | 129 +++++++----
.../dialogs/upload-files-to-detail-dialog.tsx | 242 ++++++++-------------
lib/dolce/hooks/use-file-upload-with-progress.ts | 136 ++++++++++++
lib/dolce/hooks/use-file-upload.ts | 107 +++++++++
lib/dolce/utils/upload-with-progress.ts | 128 +++++++++++
8 files changed, 726 insertions(+), 309 deletions(-)
create mode 100644 lib/dolce/components/file-upload-progress-list.tsx
create mode 100644 lib/dolce/hooks/use-file-upload-with-progress.ts
create mode 100644 lib/dolce/hooks/use-file-upload.ts
create mode 100644 lib/dolce/utils/upload-with-progress.ts
(limited to 'lib')
diff --git a/lib/dolce/components/file-upload-progress-list.tsx b/lib/dolce/components/file-upload-progress-list.tsx
new file mode 100644
index 00000000..e016402d
--- /dev/null
+++ b/lib/dolce/components/file-upload-progress-list.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import { FileText, CheckCircle2, XCircle, Loader2 } from "lucide-react";
+import { Progress } from "@/components/ui/progress";
+import { FileUploadProgress } from "../hooks/use-file-upload-with-progress";
+
+interface FileUploadProgressListProps {
+ fileProgresses: FileUploadProgress[];
+}
+
+export function FileUploadProgressList({ fileProgresses }: FileUploadProgressListProps) {
+ if (fileProgresses.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ 파일 업로드 진행 상황 ({fileProgresses.length}개)
+
+
+ {fileProgresses.map((fileProgress, index) => (
+
+ ))}
+
+
+ );
+}
+
+interface FileUploadProgressItemProps {
+ fileProgress: FileUploadProgress;
+}
+
+function FileUploadProgressItem({ fileProgress }: FileUploadProgressItemProps) {
+ const { file, progress, status, error } = fileProgress;
+
+ const getStatusIcon = () => {
+ switch (status) {
+ case "completed":
+ return ;
+ case "error":
+ return ;
+ case "uploading":
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ const getStatusColor = () => {
+ switch (status) {
+ case "completed":
+ return "border-green-200 bg-green-50/50";
+ case "error":
+ return "border-red-200 bg-red-50/50";
+ case "uploading":
+ return "border-primary/30 bg-primary/5";
+ default:
+ return "border-muted bg-muted/30";
+ }
+ };
+
+ return (
+
+
+ {getStatusIcon()}
+
+
{file.name}
+
+ {(file.size / 1024 / 1024).toFixed(2)} MB
+
+
+ {status !== "pending" && (
+
+ {status === "completed" ? (
+ 완료
+ ) : status === "error" ? (
+ 실패
+ ) : (
+ {Math.round(progress)}%
+ )}
+
+ )}
+
+
+ {/* Progress Bar */}
+ {status === "uploading" && (
+
+ )}
+
+ {/* 에러 메시지 */}
+ {status === "error" && error && (
+
{error}
+ )}
+
+ );
+}
+
diff --git a/lib/dolce/crypto-utils-legacy.ts b/lib/dolce/crypto-utils-legacy.ts
index c5ee496f..6bec9bca 100644
--- a/lib/dolce/crypto-utils-legacy.ts
+++ b/lib/dolce/crypto-utils-legacy.ts
@@ -1,64 +1,35 @@
/**
* DOLCE 파일 다운로드용 DES 암호화 유틸리티
- * Node.js crypto 모듈 사용 (OpenSSL 3.0 호환)
+ * crypto-js 사용 (OpenSSL 독립적, 순수 JavaScript 구현)
*/
-import crypto from "crypto";
+import CryptoJS from "crypto-js";
-// 암호화 키 (8바이트)
-const DES_KEY = Buffer.from("4fkkdijg", "ascii");
-
-/**
- * DES ECB 수동 구현 (OpenSSL 3.0 호환)
- * createCipher 대신 수동 블록 처리
- */
-function desEncryptBlock(block: Buffer, key: Buffer): Buffer {
- // DES 블록 암호화 (8바이트 블록)
- const cipher = crypto.createCipheriv("des-ecb", key, Buffer.alloc(0));
- cipher.setAutoPadding(false); // 수동 패딩
- return Buffer.concat([cipher.update(block), cipher.final()]);
-}
-
-/**
- * PKCS7 패딩 추가
- */
-function addPKCS7Padding(data: Buffer): Buffer {
- const blockSize = 8;
- const paddingLength = blockSize - (data.length % blockSize);
- const padding = Buffer.alloc(paddingLength, paddingLength);
- return Buffer.concat([data, padding]);
-}
-
-/**
- * PKCS7 패딩 제거
- */
-function removePKCS7Padding(data: Buffer): Buffer {
- const paddingLength = data[data.length - 1];
- return data.slice(0, data.length - paddingLength);
-}
+// 암호화 키 (8바이트 ASCII)
+const DES_KEY_STRING = "4fkkdijg";
/**
* DES ECB 모드로 문자열 암호화
+ * C# DESCryptoServiceProvider와 호환
*/
export function encryptDES(plainText: string): string {
try {
- // UTF-8로 인코딩
- let data = Buffer.from(plainText, "utf8");
+ // UTF-8 문자열을 WordArray로 변환
+ const message = CryptoJS.enc.Utf8.parse(plainText);
- // PKCS7 패딩 추가
- data = addPKCS7Padding(data);
+ // ASCII 키를 WordArray로 변환 (8바이트)
+ const key = CryptoJS.enc.Latin1.parse(DES_KEY_STRING);
- // 8바이트 블록으로 분할하여 암호화
- const encrypted: Buffer[] = [];
- for (let i = 0; i < data.length; i += 8) {
- const block = data.slice(i, i + 8);
- encrypted.push(desEncryptBlock(block, DES_KEY));
- }
+ // DES ECB 암호화 (PKCS7 패딩 자동)
+ const encrypted = CryptoJS.DES.encrypt(message, key, {
+ mode: CryptoJS.mode.ECB,
+ padding: CryptoJS.pad.Pkcs7,
+ });
- // Base64 인코딩
- const base64 = Buffer.concat(encrypted).toString("base64");
+ // Base64 인코딩된 결과
+ const base64 = encrypted.toString();
- // + 문자를 ||| 로 변환
+ // + 문자를 ||| 로 변환 (C# 호환)
return base64.replace(/\+/g, "|||");
} catch (error) {
console.error("DES 암호화 실패:", error);
@@ -74,23 +45,20 @@ export function decryptDES(encryptedText: string): string {
// ||| 를 + 로 복원
const restored = encryptedText.replace(/\|\|\|/g, "+");
- // Base64 디코딩
- const data = Buffer.from(restored, "base64");
+ // ASCII 키를 WordArray로 변환
+ const key = CryptoJS.enc.Latin1.parse(DES_KEY_STRING);
- // 8바이트 블록으로 분할하여 복호화
- const decrypted: Buffer[] = [];
- for (let i = 0; i < data.length; i += 8) {
- const block = data.slice(i, i + 8);
- const decipher = crypto.createDecipheriv("des-ecb", DES_KEY, Buffer.alloc(0));
- decipher.setAutoPadding(false);
- decrypted.push(Buffer.concat([decipher.update(block), decipher.final()]));
- }
+ // DES ECB 복호화
+ const decrypted = CryptoJS.DES.decrypt(restored, key, {
+ mode: CryptoJS.mode.ECB,
+ padding: CryptoJS.pad.Pkcs7,
+ });
- // PKCS7 패딩 제거
- const unpaddedData = removePKCS7Padding(Buffer.concat(decrypted));
+ // UTF-8 문자열로 변환
+ const plainText = decrypted.toString(CryptoJS.enc.Utf8);
- // UTF-8 디코딩
- return unpaddedData.toString("utf8").replace(/\0+$/, "");
+ // null 바이트 제거 (혹시 모를 패딩 잔여물)
+ return plainText.replace(/\0+$/, "");
} catch (error) {
console.error("DES 복호화 실패:", error);
throw new Error(`복호화 중 오류가 발생했습니다: ${error}`);
diff --git a/lib/dolce/crypto-utils.ts b/lib/dolce/crypto-utils.ts
index 1fb310b2..0a3f18c5 100644
--- a/lib/dolce/crypto-utils.ts
+++ b/lib/dolce/crypto-utils.ts
@@ -4,7 +4,8 @@
*
* OpenSSL 3.0 호환 처리:
* - Node.js 17+ 환경에서 DES는 레거시 알고리즘으로 분류됨
- * - 에러 발생 시 crypto-utils-legacy.ts의 수동 구현 사용
+ * - 기본적으로 crypto-js 기반 구현 사용 (OpenSSL 독립적)
+ * - 환경 변수로 Native crypto 사용 가능: USE_NATIVE_CRYPTO=true
*/
import crypto from "crypto";
@@ -12,8 +13,11 @@ import crypto from "crypto";
// 암호화 키 (8바이트)
const DES_KEY = Buffer.from("4fkkdijg", "ascii");
+// OpenSSL Native Crypto 사용 여부 (기본: false, crypto-js 사용)
+const USE_NATIVE_CRYPTO = process.env.USE_NATIVE_CRYPTO === "true";
+
// OpenSSL 3.0 환경 감지
-let useModernCrypto = true;
+let useModernCrypto = USE_NATIVE_CRYPTO;
/**
* DES ECB 모드로 문자열 암호화
@@ -21,40 +25,44 @@ let useModernCrypto = true;
* @returns Base64 인코딩된 암호문
*/
export function encryptDES(plainText: string): string {
- // 처음 호출 시 OpenSSL 지원 여부 확인
- if (useModernCrypto) {
- try {
- const cipher = crypto.createCipheriv("des-ecb", DES_KEY, Buffer.alloc(0));
- cipher.setAutoPadding(true);
-
- let encrypted = cipher.update(plainText, "utf8", "base64");
- encrypted += cipher.final("base64");
-
- return encrypted.replace(/\+/g, "|||");
- } catch (error: any) {
- // OpenSSL 3.0 에러 감지
- if (error.message?.includes("unsupported") || error.message?.includes("digital envelope")) {
- console.warn("[DOLCE] OpenSSL 3.0 감지, 레거시 구현으로 전환합니다.");
- useModernCrypto = false;
- // 레거시 구현으로 재시도
- return encryptDESLegacy(plainText);
- }
- throw error;
- }
- } else {
+ // 기본적으로 레거시 구현(crypto-js) 사용
+ if (!useModernCrypto) {
return encryptDESLegacy(plainText);
}
+
+ // Native crypto 시도 (USE_NATIVE_CRYPTO=true인 경우만)
+ try {
+ const cipher = crypto.createCipheriv("des-ecb", DES_KEY, Buffer.alloc(0));
+ cipher.setAutoPadding(true);
+
+ let encrypted = cipher.update(plainText, "utf8", "base64");
+ encrypted += cipher.final("base64");
+
+ return encrypted.replace(/\+/g, "|||");
+ } catch (error: any) {
+ // OpenSSL 에러 감지 - 레거시로 폴백
+ if (
+ error.code === "ERR_OSSL_EVP_UNSUPPORTED" ||
+ error.message?.includes("unsupported") ||
+ error.message?.includes("digital envelope")
+ ) {
+ console.warn("[DOLCE] OpenSSL DES 미지원, crypto-js로 전환합니다.");
+ useModernCrypto = false;
+ return encryptDESLegacy(plainText);
+ }
+ throw error;
+ }
}
/**
- * 레거시 DES 암호화 (OpenSSL 3.0 대체)
+ * 레거시 DES 암호화 (crypto-js 기반, OpenSSL 독립적)
*/
function encryptDESLegacy(plainText: string): string {
try {
const { encryptDES: legacyEncrypt } = require("./crypto-utils-legacy");
return legacyEncrypt(plainText);
} catch (error) {
- console.error("DES 암호화 실패:", error);
+ console.error("[DOLCE] DES 암호화 실패:", error);
throw new Error(`암호화 중 오류가 발생했습니다: ${error}`);
}
}
@@ -65,38 +73,45 @@ function encryptDESLegacy(plainText: string): string {
* @returns 복호화된 평문
*/
export function decryptDES(encryptedText: string): string {
- if (useModernCrypto) {
- try {
- const restored = encryptedText.replace(/\|\|\|/g, "+");
- const decipher = crypto.createDecipheriv("des-ecb", DES_KEY, Buffer.alloc(0));
- decipher.setAutoPadding(true);
-
- let decrypted = decipher.update(restored, "base64", "utf8");
- decrypted += decipher.final("utf8");
-
- return decrypted.replace(/\0+$/, "");
- } catch (error: any) {
- if (error.message?.includes("unsupported") || error.message?.includes("digital envelope")) {
- console.warn("[DOLCE] OpenSSL 3.0 감지, 레거시 구현으로 전환합니다.");
- useModernCrypto = false;
- return decryptDESLegacy(encryptedText);
- }
- throw error;
- }
- } else {
+ // 기본적으로 레거시 구현(crypto-js) 사용
+ if (!useModernCrypto) {
return decryptDESLegacy(encryptedText);
}
+
+ // Native crypto 시도 (USE_NATIVE_CRYPTO=true인 경우만)
+ try {
+ const restored = encryptedText.replace(/\|\|\|/g, "+");
+ const decipher = crypto.createDecipheriv("des-ecb", DES_KEY, Buffer.alloc(0));
+ decipher.setAutoPadding(true);
+
+ let decrypted = decipher.update(restored, "base64", "utf8");
+ decrypted += decipher.final("utf8");
+
+ return decrypted.replace(/\0+$/, "");
+ } catch (error: any) {
+ // OpenSSL 에러 감지 - 레거시로 폴백
+ if (
+ error.code === "ERR_OSSL_EVP_UNSUPPORTED" ||
+ error.message?.includes("unsupported") ||
+ error.message?.includes("digital envelope")
+ ) {
+ console.warn("[DOLCE] OpenSSL DES 미지원, crypto-js로 전환합니다.");
+ useModernCrypto = false;
+ return decryptDESLegacy(encryptedText);
+ }
+ throw error;
+ }
}
/**
- * 레거시 DES 복호화 (OpenSSL 3.0 대체)
+ * 레거시 DES 복호화 (crypto-js 기반, OpenSSL 독립적)
*/
function decryptDESLegacy(encryptedText: string): string {
try {
const { decryptDES: legacyDecrypt } = require("./crypto-utils-legacy");
return legacyDecrypt(encryptedText);
} catch (error) {
- console.error("DES 복호화 실패:", error);
+ console.error("[DOLCE] DES 복호화 실패:", error);
throw new Error(`복호화 중 오류가 발생했습니다: ${error}`);
}
}
diff --git a/lib/dolce/dialogs/add-detail-drawing-dialog.tsx b/lib/dolce/dialogs/add-detail-drawing-dialog.tsx
index 290a226b..34d06368 100644
--- a/lib/dolce/dialogs/add-detail-drawing-dialog.tsx
+++ b/lib/dolce/dialogs/add-detail-drawing-dialog.tsx
@@ -1,7 +1,6 @@
"use client";
-import { useState, useCallback } from "react";
-import { useDropzone } from "react-dropzone";
+import { useState } from "react";
import {
Dialog,
DialogContent,
@@ -22,8 +21,11 @@ import {
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Upload, X, FileIcon, Info } from "lucide-react";
import { toast } from "sonner";
-import { UnifiedDwgReceiptItem, editDetailDwgReceipt, uploadFilesToDetailDrawing } from "../actions";
+import { UnifiedDwgReceiptItem, editDetailDwgReceipt } from "../actions";
import { v4 as uuidv4 } from "uuid";
+import { useFileUploadWithProgress } from "../hooks/use-file-upload-with-progress";
+import { uploadFilesWithProgress } from "../utils/upload-with-progress";
+import { FileUploadProgressList } from "../components/file-upload-progress-list";
interface AddDetailDrawingDialogProps {
open: boolean;
@@ -80,30 +82,26 @@ export function AddDetailDrawingDialog({
const [drawingUsage, setDrawingUsage] = useState("");
const [registerKind, setRegisterKind] = useState("");
const [revision, setRevision] = useState("");
- const [files, setFiles] = useState([]);
const [isSubmitting, setIsSubmitting] = useState(false);
- // 파일 드롭 핸들러
- const onDrop = useCallback((acceptedFiles: File[]) => {
- setFiles((prev) => [...prev, ...acceptedFiles]);
- }, []);
-
- const { getRootProps, getInputProps, isDragActive } = useDropzone({
- onDrop,
- multiple: true,
- });
-
- // 파일 제거
- const removeFile = (index: number) => {
- setFiles((prev) => prev.filter((_, i) => i !== index));
- };
+ // 파일 업로드 훅 사용 (진행도 추적)
+ const {
+ fileProgresses,
+ files,
+ removeFile,
+ clearFiles,
+ updateFileProgress,
+ getRootProps,
+ getInputProps,
+ isDragActive,
+ } = useFileUploadWithProgress();
// 폼 초기화
const resetForm = () => {
setDrawingUsage("");
setRegisterKind("");
setRevision("");
- setFiles([]);
+ clearFiles();
};
// 제출
@@ -169,16 +167,27 @@ export function AddDetailDrawingDialog({
if (files.length > 0) {
toast.info(`${files.length}개 파일 업로드를 진행합니다...`);
- const formData = new FormData();
- formData.append("uploadId", uploadId);
- formData.append("userId", userId);
- formData.append("fileCount", String(files.length));
-
- files.forEach((file, index) => {
- formData.append(`file_${index}`, file);
+ // 모든 파일 상태를 uploading으로 변경
+ files.forEach((_, index) => {
+ updateFileProgress(index, 0, "uploading");
});
- const uploadResult = await uploadFilesToDetailDrawing(formData);
+ const uploadResult = await uploadFilesWithProgress({
+ uploadId,
+ userId,
+ files,
+ callbacks: {
+ onProgress: (fileIndex, progress) => {
+ updateFileProgress(fileIndex, progress, "uploading");
+ },
+ onFileComplete: (fileIndex) => {
+ updateFileProgress(fileIndex, 100, "completed");
+ },
+ onFileError: (fileIndex, error) => {
+ updateFileProgress(fileIndex, 0, "error", error);
+ },
+ },
+ });
if (uploadResult.success) {
toast.success(`상세도면 추가 및 ${uploadResult.uploadedCount}개 파일 업로드 완료`);
@@ -189,8 +198,10 @@ export function AddDetailDrawingDialog({
toast.success("상세도면이 추가되었습니다");
}
+ // API 호출 성공 시 무조건 다이얼로그 닫기 (파일 업로드 성공 여부와 무관)
resetForm();
onComplete();
+ onOpenChange(false);
} else {
toast.error("상세도면 추가에 실패했습니다");
}
@@ -318,7 +329,7 @@ export function AddDetailDrawingDialog({
파일을 드래그하거나 클릭하여 선택
- 여러 파일을 한 번에 업로드할 수 있습니다
+ 여러 파일을 한 번에 업로드할 수 있습니다 (최대 1GB/파일)
@@ -337,25 +348,49 @@ export function AddDetailDrawingDialog({
{/* 선택된 파일 목록 */}
{files.length > 0 && (
- {files.map((file, index) => (
-
-
- {file.name}
-
- {(file.size / 1024).toFixed(2)} KB
-
-
-
- ))}
+ {isSubmitting ? (
+ // 업로드 중: 진행도 표시
+
+ ) : (
+ // 대기 중: 삭제 버튼 표시
+ <>
+
+
+ 선택된 파일 ({files.length}개)
+
+
+
+
+ {files.map((file, index) => (
+
+
+
+
{file.name}
+
+ {(file.size / 1024 / 1024).toFixed(2)} MB
+
+
+
+
+ ))}
+
+ >
+ )}
)}
diff --git a/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx b/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx
index 1d8ac582..af73aea6 100644
--- a/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx
+++ b/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx
@@ -14,7 +14,9 @@ import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Upload, FolderOpen, Loader2, X, FileText, AlertCircle } from "lucide-react";
import { toast } from "sonner";
-import { uploadFilesToDetailDrawing, type UploadFilesResult } from "../actions";
+import { useFileUploadWithProgress } from "../hooks/use-file-upload-with-progress";
+import { uploadFilesWithProgress, type UploadResult } from "../utils/upload-with-progress";
+import { FileUploadProgressList } from "../components/file-upload-progress-list";
interface UploadFilesToDetailDialogProps {
open: boolean;
@@ -35,101 +37,26 @@ export function UploadFilesToDetailDialog({
userId,
onUploadComplete,
}: UploadFilesToDetailDialogProps) {
- const [selectedFiles, setSelectedFiles] = useState([]);
const [isUploading, setIsUploading] = useState(false);
- const [isDragging, setIsDragging] = useState(false);
+
+ // 파일 업로드 훅 사용 (진행도 추적)
+ const {
+ fileProgresses,
+ files: selectedFiles,
+ removeFile,
+ clearFiles,
+ updateFileProgress,
+ getRootProps,
+ getInputProps,
+ isDragActive,
+ } = useFileUploadWithProgress();
// 다이얼로그 닫을 때 초기화
React.useEffect(() => {
if (!open) {
- setSelectedFiles([]);
- setIsDragging(false);
- }
- }, [open]);
-
- // 파일 선택 핸들러
- const handleFilesChange = (files: File[]) => {
- if (files.length === 0) return;
-
- // 파일 크기 및 확장자 검증
- 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[] = [];
-
- files.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);
- });
-
- if (invalidFiles.length > 0) {
- invalidFiles.forEach((msg) => toast.error(msg));
- }
-
- if (validFiles.length > 0) {
- // 중복 제거
- const existingNames = new Set(selectedFiles.map((f) => f.name));
- const newFiles = validFiles.filter((f) => !existingNames.has(f.name));
-
- if (newFiles.length === 0) {
- toast.error("이미 선택된 파일입니다");
- return;
- }
-
- setSelectedFiles((prev) => [...prev, ...newFiles]);
- toast.success(`${newFiles.length}개 파일이 선택되었습니다`);
- }
- };
-
- // Drag & Drop 핸들러
- const handleDragEnter = (e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- setIsDragging(true);
- };
-
- const handleDragLeave = (e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- if (e.currentTarget === e.target) {
- setIsDragging(false);
- }
- };
-
- const handleDragOver = (e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- e.dataTransfer.dropEffect = "copy";
- };
-
- const handleDrop = (e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- setIsDragging(false);
-
- const droppedFiles = Array.from(e.dataTransfer.files);
- if (droppedFiles.length > 0) {
- handleFilesChange(droppedFiles);
+ clearFiles();
}
- };
-
- // 파일 제거
- const handleRemoveFile = (index: number) => {
- setSelectedFiles((prev) => prev.filter((_, i) => i !== index));
- };
+ }, [open, clearFiles]);
// 업로드 처리
const handleUpload = async () => {
@@ -141,18 +68,28 @@ export function UploadFilesToDetailDialog({
setIsUploading(true);
try {
- // FormData 생성
- const formData = new FormData();
- formData.append("uploadId", uploadId);
- formData.append("userId", userId);
- formData.append("fileCount", String(selectedFiles.length));
-
- selectedFiles.forEach((file, index) => {
- formData.append(`file_${index}`, file);
+ // 모든 파일 상태를 uploading으로 변경
+ selectedFiles.forEach((_, index) => {
+ updateFileProgress(index, 0, "uploading");
});
- // 서버 액션 호출
- const result: UploadFilesResult = await uploadFilesToDetailDrawing(formData);
+ // 진행도 추적 업로드 호출
+ const result: UploadResult = await uploadFilesWithProgress({
+ uploadId,
+ userId,
+ files: selectedFiles,
+ callbacks: {
+ onProgress: (fileIndex, progress) => {
+ updateFileProgress(fileIndex, progress, "uploading");
+ },
+ onFileComplete: (fileIndex) => {
+ updateFileProgress(fileIndex, 100, "completed");
+ },
+ onFileError: (fileIndex, error) => {
+ updateFileProgress(fileIndex, 0, "error", error);
+ },
+ },
+ });
if (result.success) {
toast.success(`${result.uploadedCount}개 파일 업로드 완료`);
@@ -192,92 +129,85 @@ export function UploadFilesToDetailDialog({
{/* 파일 선택 영역 */}
{/* 선택된 파일 목록 */}
{selectedFiles.length > 0 && (
-
-
- 선택된 파일 ({selectedFiles.length}개)
-
-
-
-
- {selectedFiles.map((file, index) => (
-
-
-
-
-
{file.name}
-
- {(file.size / 1024 / 1024).toFixed(2)} MB
-
-
-
+ {isUploading ? (
+ // 업로드 중: 진행도 표시
+
+ ) : (
+ // 대기 중: 삭제 버튼 표시
+ <>
+
+
+ 선택된 파일 ({selectedFiles.length}개)
+
- ))}
-
+
+ {selectedFiles.map((file, index) => (
+
+
+
+
+
{file.name}
+
+ {(file.size / 1024 / 1024).toFixed(2)} MB
+
+
+
+
+
+ ))}
+
+ >
+ )}
)}
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([]);
+
+ // 파일 검증
+ 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([]);
+
+ // 파일 검증
+ 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,
+ };
+}
+
diff --git a/lib/dolce/utils/upload-with-progress.ts b/lib/dolce/utils/upload-with-progress.ts
new file mode 100644
index 00000000..8e36afe4
--- /dev/null
+++ b/lib/dolce/utils/upload-with-progress.ts
@@ -0,0 +1,128 @@
+/**
+ * XMLHttpRequest를 사용하여 파일 업로드 진행도 추적
+ */
+export interface UploadProgressCallback {
+ onProgress: (fileIndex: number, progress: number) => void;
+ onFileComplete: (fileIndex: number) => void;
+ onFileError: (fileIndex: number, error: string) => void;
+}
+
+export interface UploadFilesWithProgressOptions {
+ uploadId: string;
+ userId: string;
+ files: File[];
+ callbacks: UploadProgressCallback;
+}
+
+export interface UploadResult {
+ success: boolean;
+ uploadedCount?: number;
+ error?: string;
+}
+
+/**
+ * 진행도 추적을 지원하는 파일 업로드 함수
+ */
+export async function uploadFilesWithProgress({
+ uploadId,
+ userId,
+ files,
+ callbacks,
+}: UploadFilesWithProgressOptions): Promise {
+ return new Promise((resolve) => {
+ const formData = new FormData();
+ formData.append("uploadId", uploadId);
+ formData.append("userId", userId);
+ formData.append("fileCount", String(files.length));
+
+ files.forEach((file, index) => {
+ formData.append(`file_${index}`, file);
+ });
+
+ const xhr = new XMLHttpRequest();
+
+ // 전체 업로드 진행도 (단순화: 전체 진행도를 각 파일에 분배)
+ xhr.upload.addEventListener("progress", (event) => {
+ if (event.lengthComputable) {
+ const totalProgress = (event.loaded / event.total) * 100;
+
+ // 현재 업로드 중인 파일 인덱스 추정
+ const filesCompleted = Math.floor((totalProgress / 100) * files.length);
+ const currentFileIndex = Math.min(filesCompleted, files.length - 1);
+
+ // 각 파일별 진행도 계산
+ files.forEach((_, index) => {
+ if (index < filesCompleted) {
+ callbacks.onProgress(index, 100);
+ callbacks.onFileComplete(index);
+ } else if (index === currentFileIndex) {
+ const fileProgress = ((totalProgress / 100) * files.length - filesCompleted) * 100;
+ callbacks.onProgress(index, Math.min(fileProgress, 99));
+ } else {
+ callbacks.onProgress(index, 0);
+ }
+ });
+ }
+ });
+
+ xhr.addEventListener("load", () => {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ try {
+ const response = JSON.parse(xhr.responseText);
+
+ // 모든 파일 완료 처리
+ files.forEach((_, index) => {
+ callbacks.onProgress(index, 100);
+ callbacks.onFileComplete(index);
+ });
+
+ resolve(response);
+ } catch (error) {
+ const errorMsg = "응답 파싱 실패";
+ files.forEach((_, index) => {
+ callbacks.onFileError(index, errorMsg);
+ });
+ resolve({
+ success: false,
+ error: errorMsg,
+ });
+ }
+ } else {
+ const errorMsg = `업로드 실패: ${xhr.status} ${xhr.statusText}`;
+ files.forEach((_, index) => {
+ callbacks.onFileError(index, errorMsg);
+ });
+ resolve({
+ success: false,
+ error: errorMsg,
+ });
+ }
+ });
+
+ xhr.addEventListener("error", () => {
+ const errorMsg = "네트워크 오류";
+ files.forEach((_, index) => {
+ callbacks.onFileError(index, errorMsg);
+ });
+ resolve({
+ success: false,
+ error: errorMsg,
+ });
+ });
+
+ xhr.addEventListener("abort", () => {
+ const errorMsg = "업로드가 취소되었습니다";
+ files.forEach((_, index) => {
+ callbacks.onFileError(index, errorMsg);
+ });
+ resolve({
+ success: false,
+ error: errorMsg,
+ });
+ });
+
+ xhr.open("POST", "/api/dolce/upload-files");
+ xhr.send(formData);
+ });
+}
+
--
cgit v1.2.3