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 | |
| parent | fd4909bba7be8abc1eeab9ae1b4621c66a61604a (diff) | |
(김준회) 돌체 재개발 - 2차: 업로드 개선, 다운로드 오류 수정
| -rw-r--r-- | app/api/dolce/upload-files/route.ts | 229 | ||||
| -rw-r--r-- | ecosystem.config.js | 1 | ||||
| -rw-r--r-- | lib/dolce/components/file-upload-progress-list.tsx | 98 | ||||
| -rw-r--r-- | lib/dolce/crypto-utils-legacy.ts | 88 | ||||
| -rw-r--r-- | lib/dolce/crypto-utils.ts | 107 | ||||
| -rw-r--r-- | lib/dolce/dialogs/add-detail-drawing-dialog.tsx | 129 | ||||
| -rw-r--r-- | lib/dolce/dialogs/upload-files-to-detail-dialog.tsx | 242 | ||||
| -rw-r--r-- | lib/dolce/hooks/use-file-upload-with-progress.ts | 136 | ||||
| -rw-r--r-- | lib/dolce/hooks/use-file-upload.ts | 107 | ||||
| -rw-r--r-- | lib/dolce/utils/upload-with-progress.ts | 128 | ||||
| -rw-r--r-- | package-lock.json | 15 | ||||
| -rw-r--r-- | package.json | 11 |
12 files changed, 976 insertions, 315 deletions
diff --git a/app/api/dolce/upload-files/route.ts b/app/api/dolce/upload-files/route.ts new file mode 100644 index 00000000..1d302cb2 --- /dev/null +++ b/app/api/dolce/upload-files/route.ts @@ -0,0 +1,229 @@ +import { NextRequest, NextResponse } from "next/server"; +import fs from "fs/promises"; +import { createReadStream } from "fs"; +import path from "path"; +import os from "os"; + +const DOLCE_API_URL = process.env.DOLCE_API_URL || "http://60.100.99.217:1111"; + +/** + * 임시 파일 저장 및 정리 헬퍼 + */ +async function saveToTempFile(file: File): Promise<{ filepath: string; cleanup: () => Promise<void> }> { + const tempDir = os.tmpdir(); + const tempFilePath = path.join(tempDir, `upload-${Date.now()}-${file.name}`); + + const arrayBuffer = await file.arrayBuffer(); + await fs.writeFile(tempFilePath, Buffer.from(arrayBuffer)); + + return { + filepath: tempFilePath, + cleanup: async () => { + try { + await fs.unlink(tempFilePath); + } catch (error) { + console.error(`임시 파일 삭제 실패: ${tempFilePath}`, error); + } + }, + }; +} + +/** + * 스트리밍 방식으로 파일 업로드 + * Node.js ReadableStream을 Web ReadableStream으로 변환하여 fetch 사용 + */ +async function uploadFileStream( + filepath: string, + uploadId: string, + fileId: string, + fileSize: number +): Promise<string> { + const uploadUrl = `${DOLCE_API_URL}/PWPUploadService.ashx?UploadId=${uploadId}&FileId=${fileId}`; + + // Node.js ReadableStream 생성 + const nodeStream = createReadStream(filepath, { + highWaterMark: 64 * 1024, // 64KB 청크로 읽기 + }); + + // Node.js Stream을 Web ReadableStream으로 변환 + const webStream = new ReadableStream({ + start(controller) { + nodeStream.on("data", (chunk: Buffer) => { + controller.enqueue(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)); + }); + + nodeStream.on("end", () => { + controller.close(); + }); + + nodeStream.on("error", (error) => { + controller.error(error); + }); + }, + cancel() { + nodeStream.destroy(); + }, + }); + + // 스트리밍 업로드 + const uploadResponse = await fetch(uploadUrl, { + method: "POST", + headers: { + "Content-Type": "application/octet-stream", + "Content-Length": fileSize.toString(), + }, + body: webStream as unknown as BodyInit, + // @ts-expect-error - duplex is required for streaming uploads with ReadableStream + duplex: "half", + }); + + if (!uploadResponse.ok) { + throw new Error( + `파일 업로드 실패: ${uploadResponse.status} ${uploadResponse.statusText}` + ); + } + + const fileRelativePath = await uploadResponse.text(); + return fileRelativePath; +} + +/** + * 상세도면 파일 업로드 API + * 스트리밍 처리로 메모리 효율적 업로드 + */ +export async function POST(request: NextRequest) { + const tempFiles: Array<{ filepath: string; cleanup: () => Promise<void> }> = []; + + try { + // FormData 파싱 + const formData = await request.formData(); + + const uploadId = formData.get("uploadId") as string; + const userId = formData.get("userId") as string; + const fileCount = parseInt(formData.get("fileCount") as string); + + if (!uploadId || !userId || !fileCount) { + return NextResponse.json( + { success: false, error: "필수 파라미터가 누락되었습니다" }, + { status: 400 } + ); + } + + const uploadResults: Array<{ + FileId: string; + UploadId: string; + FileSeq: number; + FileName: string; + FileRelativePath: string; + FileSize: number; + FileCreateDT: string; + FileWriteDT: string; + OwnerUserId: string; + }> = []; + + // 기존 파일 개수 조회 + const existingFilesResponse = await fetch( + `${DOLCE_API_URL}/PorjectWebProxyService.ashx?service=FileInfoList`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + uploadId: uploadId, + }), + } + ); + + if (!existingFilesResponse.ok) { + throw new Error("기존 파일 조회 실패"); + } + + const existingFilesData = await existingFilesResponse.json(); + const startSeq = (existingFilesData.FileInfoListResult?.length || 0) + 1; + + // 파일 수집 + const files: File[] = []; + for (let i = 0; i < fileCount; i++) { + const file = formData.get(`file_${i}`) as File; + if (file) { + files.push(file); + } + } + + // 각 파일을 임시 디렉터리에 저장 후 스트리밍 업로드 + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const fileId = crypto.randomUUID(); + + // 임시 파일로 저장 (메모리 압박 감소) + const tempFile = await saveToTempFile(file); + tempFiles.push(tempFile); + + // 스트리밍 방식으로 DOLCE API에 업로드 + const fileRelativePath = await uploadFileStream( + tempFile.filepath, + uploadId, + fileId, + file.size + ); + + uploadResults.push({ + FileId: fileId, + UploadId: uploadId, + FileSeq: startSeq + i, + FileName: file.name, + FileRelativePath: fileRelativePath, + FileSize: file.size, + FileCreateDT: new Date().toISOString(), + FileWriteDT: new Date().toISOString(), + OwnerUserId: userId, + }); + + // 처리 완료된 임시 파일 즉시 삭제 + await tempFile.cleanup(); + } + + // 업로드 완료 통지 + const resultServiceUrl = `${DOLCE_API_URL}/PWPUploadResultService.ashx?`; + + const resultResponse = await fetch(resultServiceUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(uploadResults), + }); + + if (!resultResponse.ok) { + throw new Error( + `업로드 완료 통지 실패: ${resultResponse.status} ${resultResponse.statusText}` + ); + } + + const resultText = await resultResponse.text(); + if (resultText !== "Success") { + throw new Error(`업로드 완료 통지 실패: ${resultText}`); + } + + return NextResponse.json({ + success: true, + uploadedCount: uploadResults.length, + }); + } catch (error) { + console.error("파일 업로드 실패:", error); + + // 에러 발생 시 남아있는 임시 파일 모두 정리 + for (const tempFile of tempFiles) { + await tempFile.cleanup(); + } + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다", + }, + { status: 500 } + ); + } +} diff --git a/ecosystem.config.js b/ecosystem.config.js index 60f1330a..7ff4d71a 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -11,7 +11,6 @@ module.exports = { max_memory_restart: '8G', env: { NODE_ENV: 'production', - NODE_OPTIONS: '--openssl-legacy-provider' }, // 로그 설정 error_file: '~/.pm2/logs/evcp-error.log', 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 ( + <div className="space-y-3"> + <h4 className="text-sm font-medium"> + 파일 업로드 진행 상황 ({fileProgresses.length}개) + </h4> + <div className="max-h-64 overflow-auto space-y-2"> + {fileProgresses.map((fileProgress, index) => ( + <FileUploadProgressItem key={index} fileProgress={fileProgress} /> + ))} + </div> + </div> + ); +} + +interface FileUploadProgressItemProps { + fileProgress: FileUploadProgress; +} + +function FileUploadProgressItem({ fileProgress }: FileUploadProgressItemProps) { + const { file, progress, status, error } = fileProgress; + + const getStatusIcon = () => { + switch (status) { + case "completed": + return <CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />; + case "error": + return <XCircle className="h-4 w-4 text-red-600 shrink-0" />; + case "uploading": + return <Loader2 className="h-4 w-4 text-primary shrink-0 animate-spin" />; + default: + return <FileText className="h-4 w-4 text-muted-foreground shrink-0" />; + } + }; + + 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 ( + <div className={`border rounded-lg p-3 space-y-2 ${getStatusColor()}`}> + <div className="flex items-center gap-2"> + {getStatusIcon()} + <div className="flex-1 min-w-0"> + <p className="text-sm font-medium truncate">{file.name}</p> + <p className="text-xs text-muted-foreground"> + {(file.size / 1024 / 1024).toFixed(2)} MB + </p> + </div> + {status !== "pending" && ( + <div className="text-sm font-medium"> + {status === "completed" ? ( + <span className="text-green-600">완료</span> + ) : status === "error" ? ( + <span className="text-red-600">실패</span> + ) : ( + <span className="text-primary">{Math.round(progress)}%</span> + )} + </div> + )} + </div> + + {/* Progress Bar */} + {status === "uploading" && ( + <Progress value={progress} className="h-1.5" /> + )} + + {/* 에러 메시지 */} + {status === "error" && error && ( + <p className="text-xs text-red-600">{error}</p> + )} + </div> + ); +} + 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<string>(""); const [registerKind, setRegisterKind] = useState<string>(""); const [revision, setRevision] = useState<string>(""); - const [files, setFiles] = useState<File[]>([]); 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({ 파일을 드래그하거나 클릭하여 선택 </p> <p className="text-xs text-muted-foreground"> - 여러 파일을 한 번에 업로드할 수 있습니다 + 여러 파일을 한 번에 업로드할 수 있습니다 (최대 1GB/파일) </p> </div> </div> @@ -337,25 +348,49 @@ export function AddDetailDrawingDialog({ {/* 선택된 파일 목록 */} {files.length > 0 && ( <div className="space-y-2 mt-4"> - {files.map((file, index) => ( - <div - key={index} - className="flex items-center gap-2 p-2 border rounded-lg" - > - <FileIcon className="h-4 w-4 text-muted-foreground" /> - <span className="flex-1 text-sm truncate">{file.name}</span> - <span className="text-xs text-muted-foreground"> - {(file.size / 1024).toFixed(2)} KB - </span> - <Button - variant="ghost" - size="icon" - onClick={() => removeFile(index)} - > - <X className="h-4 w-4" /> - </Button> - </div> - ))} + {isSubmitting ? ( + // 업로드 중: 진행도 표시 + <FileUploadProgressList fileProgresses={fileProgresses} /> + ) : ( + // 대기 중: 삭제 버튼 표시 + <> + <div className="flex items-center justify-between mb-2"> + <h4 className="text-sm font-medium"> + 선택된 파일 ({files.length}개) + </h4> + <Button + variant="ghost" + size="sm" + onClick={clearFiles} + > + 전체 제거 + </Button> + </div> + <div className="max-h-48 overflow-auto space-y-2"> + {files.map((file, index) => ( + <div + key={index} + className="flex items-center gap-2 p-2 border rounded-lg bg-muted/50" + > + <FileIcon className="h-4 w-4 text-muted-foreground shrink-0" /> + <div className="flex-1 min-w-0"> + <p className="text-sm truncate">{file.name}</p> + <p className="text-xs text-muted-foreground"> + {(file.size / 1024 / 1024).toFixed(2)} MB + </p> + </div> + <Button + variant="ghost" + size="sm" + onClick={() => removeFile(index)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + </> + )} </div> )} </div> 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<File[]>([]); 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({ {/* 파일 선택 영역 */} <div - className={`border-2 border-dashed rounded-lg p-8 transition-all duration-200 ${ - isDragging + {...getRootProps()} + className={`border-2 border-dashed rounded-lg p-8 transition-all duration-200 cursor-pointer ${ + isDragActive ? "border-primary bg-primary/5 scale-[1.02]" : "border-muted-foreground/30 hover:border-muted-foreground/50" }`} - onDragEnter={handleDragEnter} - onDragLeave={handleDragLeave} - onDragOver={handleDragOver} - onDrop={handleDrop} > - <input - type="file" - multiple - accept=".pdf,.doc,.docx,.xls,.xlsx,.dwg,.dxf,.zip" - onChange={(e) => handleFilesChange(Array.from(e.target.files || []))} - className="hidden" - id="detail-file-upload" - /> - <label - htmlFor="detail-file-upload" - className="flex flex-col items-center justify-center cursor-pointer" - > + <input {...getInputProps()} /> + <div className="flex flex-col items-center justify-center"> <FolderOpen className={`h-12 w-12 mb-3 transition-colors ${ - isDragging ? "text-primary" : "text-muted-foreground" + isDragActive ? "text-primary" : "text-muted-foreground" }`} /> <p className={`text-sm transition-colors ${ - isDragging + isDragActive ? "text-primary font-medium" : "text-muted-foreground" }`} > - {isDragging + {isDragActive ? "파일을 여기에 놓으세요" : "클릭하거나 파일을 드래그하여 선택"} </p> <p className="text-xs text-muted-foreground mt-1"> - PDF, DOC, DOCX, XLS, XLSX, DWG, DXF, ZIP (max 1GB per file) + 여러 파일을 한 번에 업로드할 수 있습니다 (최대 1GB/파일) </p> - </label> + </div> </div> {/* 선택된 파일 목록 */} {selectedFiles.length > 0 && ( <div className="border rounded-lg p-4"> - <div className="flex items-center justify-between mb-3"> - <h4 className="text-sm font-medium"> - 선택된 파일 ({selectedFiles.length}개) - </h4> - <Button - variant="ghost" - size="sm" - onClick={() => setSelectedFiles([])} - disabled={isUploading} - > - 전체 제거 - </Button> - </div> - <div className="max-h-48 overflow-auto space-y-2"> - {selectedFiles.map((file, index) => ( - <div - key={index} - className="flex items-center justify-between p-2 rounded bg-muted/50" - > - <div className="flex items-center gap-2 flex-1 min-w-0"> - <FileText className="h-4 w-4 text-muted-foreground shrink-0" /> - <div className="flex-1 min-w-0"> - <p className="text-sm truncate">{file.name}</p> - <p className="text-xs text-muted-foreground"> - {(file.size / 1024 / 1024).toFixed(2)} MB - </p> - </div> - </div> + {isUploading ? ( + // 업로드 중: 진행도 표시 + <FileUploadProgressList fileProgresses={fileProgresses} /> + ) : ( + // 대기 중: 삭제 버튼 표시 + <> + <div className="flex items-center justify-between mb-3"> + <h4 className="text-sm font-medium"> + 선택된 파일 ({selectedFiles.length}개) + </h4> <Button variant="ghost" size="sm" - onClick={() => handleRemoveFile(index)} - disabled={isUploading} + onClick={clearFiles} > - <X className="h-4 w-4" /> + 전체 제거 </Button> </div> - ))} - </div> + <div className="max-h-48 overflow-auto space-y-2"> + {selectedFiles.map((file, index) => ( + <div + key={index} + className="flex items-center justify-between p-2 rounded bg-muted/50" + > + <div className="flex items-center gap-2 flex-1 min-w-0"> + <FileText className="h-4 w-4 text-muted-foreground shrink-0" /> + <div className="flex-1 min-w-0"> + <p className="text-sm truncate">{file.name}</p> + <p className="text-xs text-muted-foreground"> + {(file.size / 1024 / 1024).toFixed(2)} MB + </p> + </div> + </div> + <Button + variant="ghost" + size="sm" + onClick={() => removeFile(index)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + </> + )} </div> )} </div> 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, + }; +} + 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<UploadResult> { + 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); + }); +} + diff --git a/package-lock.json b/package-lock.json index 0f566a9e..a02db5c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -114,6 +114,7 @@ "clsx": "^2.1.1", "cmdk": "^1.0.4", "codemirror": "^6.0.2", + "crypto-js": "^4.2.0", "date-fns": "^3.6.0", "dns": "^0.2.2", "docusign-esign": "^8.0.1", @@ -179,6 +180,7 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@faker-js/faker": "^9.3.0", + "@types/crypto-js": "^4.2.2", "@types/file-saver": "^2.0.7", "@types/jsonwebtoken": "^9.0.7", "@types/node": "^20", @@ -5771,6 +5773,13 @@ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "license": "MIT" }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-array": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", @@ -8199,6 +8208,12 @@ "node": ">=0.8.0" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, "node_modules/css-box-model": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", diff --git a/package.json b/package.json index a50805e9..9dc98aac 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,11 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "export NODE_OPTIONS=--openssl-legacy-provider && ./node_modules/next/dist/bin/next dev --turbopack", - "build": "export NODE_OPTIONS=--openssl-legacy-provider && ./node_modules/next/dist/bin/next build", - "start": "export NODE_OPTIONS=--openssl-legacy-provider && ./node_modules/next/dist/bin/next start", + "dev": "./node_modules/next/dist/bin/next dev --turbopack", + "build": "./node_modules/next/dist/bin/next build", + "start": "./node_modules/next/dist/bin/next start", "lint": "./node_modules/next/dist/bin/next lint", - "db:seed_2": "tsx db/seeds_2/seed.ts", - "seed:sap-po": "tsx db/seeds/sap-ecc-po-seed.ts" + "db:seed_2": "tsx db/seeds_2/seed.ts" }, "dependencies": { "@codemirror/commands": "^6.8.1", @@ -117,6 +116,7 @@ "clsx": "^2.1.1", "cmdk": "^1.0.4", "codemirror": "^6.0.2", + "crypto-js": "^4.2.0", "date-fns": "^3.6.0", "dns": "^0.2.2", "docusign-esign": "^8.0.1", @@ -182,6 +182,7 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@faker-js/faker": "^9.3.0", + "@types/crypto-js": "^4.2.2", "@types/file-saver": "^2.0.7", "@types/jsonwebtoken": "^9.0.7", "@types/node": "^20", |
