From 6d10ab21f2b8eaf24002a366ce53e7a02c95627d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=ED=98=84=EC=A1=B0?= Date: Sun, 6 Oct 2024 19:56:55 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20presinged=20url=EC=9D=84=20=ED=86=B5?= =?UTF-8?q?=ED=95=B4,=20=ED=8F=B4=EB=8D=94=20=EC=95=95=EC=B6=95=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC,=20=ED=8C=8C=EC=9D=BC=20=EB=AA=A8=EB=91=90=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8A=94=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/ImagePreSignedForm/index.tsx | 271 ++++++++++++++++++ .../ImageUploadPresignedForm.tsx | 2 +- .../WorkspaceDropdownMenu/index.tsx | 28 ++ frontend/src/hooks/useUploadFiles.ts | 68 +++++ frontend/src/utils/fileUtils.ts | 78 +++++ 5 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/ImagePreSignedForm/index.tsx create mode 100644 frontend/src/hooks/useUploadFiles.ts create mode 100644 frontend/src/utils/fileUtils.ts diff --git a/frontend/src/components/ImagePreSignedForm/index.tsx b/frontend/src/components/ImagePreSignedForm/index.tsx new file mode 100644 index 0000000..9cd6ae9 --- /dev/null +++ b/frontend/src/components/ImagePreSignedForm/index.tsx @@ -0,0 +1,271 @@ +import { useState, useEffect } from 'react'; +import { Button } from '../ui/button'; +import { cn } from '@/lib/utils'; +import useAuthStore from '@/stores/useAuthStore'; +import { CircleCheckBig, CircleDashed, CircleX, X } from 'lucide-react'; +import { FixedSizeList } from 'react-window'; +import useUploadFiles from '@/hooks/useUploadFiles'; +import { unzipFilesWithPath, extractFilesRecursivelyWithPath } from '@/utils/fileUtils'; + +interface ImagePreSignedFormProps { + onClose: () => void; + onRefetch?: () => void; + onFileCount: (fileCount: number) => void; + projectId: number; + folderId: number; + uploadType: 'file' | 'folder' | 'zip'; +} + +export default function ImagePreSignedForm({ + onClose, + onRefetch, + onFileCount, + projectId, + folderId, + uploadType, +}: ImagePreSignedFormProps) { + const profile = useAuthStore((state) => state.profile); + const memberId = profile?.id || 0; + + const [files, setFiles] = useState<{ path: string; file: File }[]>([]); + const [inputKey, setInputKey] = useState(0); + const [isDragging, setIsDragging] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [isUploaded, setIsUploaded] = useState(false); + const [isFailed, setIsFailed] = useState(false); + const [progress, setProgress] = useState(0); + + const { uploadFiles } = useUploadFiles(); + + const handleClose = () => { + onClose(); + setFiles([]); + setInputKey((prevKey) => prevKey + 1); + setIsUploading(false); + setIsUploaded(false); + setIsFailed(false); + setProgress(0); + }; + + const handleRefetch = () => { + if (onRefetch) { + onRefetch(); + } + }; + + const handleChange = (event: React.ChangeEvent) => { + const newFiles = event.target.files; + + if (newFiles) { + const processedFiles: { path: string; file: File }[] = []; + + for (const file of Array.from(newFiles)) { + const path = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name; + processedFiles.push({ path, file }); + } + + setFiles((prevFiles) => [...prevFiles, ...processedFiles]); + } + + event.target.value = ''; + }; + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsDragging(true); + }; + + const handleDragLeave = (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsDragging(false); + }; + + const handleDrop = async (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsDragging(false); + + if (uploadType === 'folder') { + const droppedItems = event.dataTransfer.items; + let processedFiles: { path: string; file: File }[] = []; + + for (let i = 0; i < droppedItems.length; i++) { + const item = droppedItems[i]; + if (item.kind === 'file') { + const entry = item.webkitGetAsEntry(); + if (entry) { + const filesFromEntry = await extractFilesRecursivelyWithPath(entry, entry.name); + processedFiles = [...processedFiles, ...filesFromEntry]; + } + } + } + + setFiles((prevFiles) => [...prevFiles, ...processedFiles]); + } else { + const droppedFiles = event.dataTransfer.files; + if (droppedFiles) { + const processedFiles: { path: string; file: File }[] = []; + for (const file of Array.from(droppedFiles)) { + processedFiles.push({ path: file.name, file }); + } + setFiles((prevFiles) => [...prevFiles, ...processedFiles]); + } + } + }; + + const handleRemoveFile = (index: number) => { + setFiles(files.filter((_, i) => i !== index)); + }; + + const handleUpload = async () => { + if (files.length > 0) { + setIsUploading(true); + setIsUploaded(false); + setIsFailed(false); + + let finalFiles: { path: string; file: File }[] = []; + + for (const file of files) { + if (file.file.type === 'application/zip' || file.file.type === 'application/x-zip-compressed') { + console.log('업로드 전에 ZIP 파일 해제:', file.file.name); + const unzippedFiles = await unzipFilesWithPath(file.file); + console.log('해제된 파일:', unzippedFiles); + finalFiles = [...finalFiles, ...unzippedFiles]; + } else { + finalFiles.push(file); + } + } + + try { + await uploadFiles({ + files: finalFiles, + projectId, + folderId, + memberId, + onProgress: (progressValue) => setProgress(progressValue), + }); + setIsUploaded(true); + handleRefetch(); + } catch (error) { + setIsFailed(true); + console.error('업로드 실패:', error); + } + } + }; + + useEffect(() => { + onFileCount(files.length); + }, [files, onFileCount]); + + return ( +
+ {!isUploading && ( +
+ + {isDragging ? ( +

+ {uploadType === 'folder' ? '드래그한 폴더를 여기에 놓으세요' : '드래그한 파일을 여기에 놓으세요'} +

+ ) : ( +

+ {uploadType === 'folder' + ? '폴더를 업로드하려면 여기를 클릭하거나 폴더를 드래그하여 여기에 놓으세요' + : uploadType === 'zip' + ? 'ZIP 파일을 업로드하려면 여기를 클릭하거나 ZIP 파일을 드래그하여 여기에 놓으세요' + : '파일을 업로드하려면 여기를 클릭하거나 파일을 드래그하여 여기에 놓으세요'} +

+ )} +
+ )} + {files.length > 0 && ( +
    + + {({ index, style }) => ( +
  • + {files[index].path} + {isUploading ? ( +
    + {isUploaded ? ( + + ) : isFailed ? ( + + ) : ( + + )} +
    + ) : ( + + )} +
  • + )} +
    +
+ )} + {isUploading ? ( + + ) : ( + + )} +
+ ); +} diff --git a/frontend/src/components/ImageUploadPresignedModal/ImageUploadPresignedForm.tsx b/frontend/src/components/ImageUploadPresignedModal/ImageUploadPresignedForm.tsx index 3b09573..b247d4a 100644 --- a/frontend/src/components/ImageUploadPresignedModal/ImageUploadPresignedForm.tsx +++ b/frontend/src/components/ImageUploadPresignedModal/ImageUploadPresignedForm.tsx @@ -3,7 +3,7 @@ import { Button } from '../ui/button'; import { cn } from '@/lib/utils'; import useAuthStore from '@/stores/useAuthStore'; import { CircleCheckBig, CircleDashed, CircleX, X } from 'lucide-react'; -import useUploadImagePresignedQuery from '@/queries/projects/useUploadImagePresignedQuery.ts'; +import useUploadImagePresignedQuery from '@/queries/images/useUploadImagePresignedQuery'; export default function ImageUploadPresignedForm({ onClose, diff --git a/frontend/src/components/WorkspaceDropdownMenu/index.tsx b/frontend/src/components/WorkspaceDropdownMenu/index.tsx index 9ccd44b..551b25a 100644 --- a/frontend/src/components/WorkspaceDropdownMenu/index.tsx +++ b/frontend/src/components/WorkspaceDropdownMenu/index.tsx @@ -14,6 +14,7 @@ import useUploadImageFileQuery from '@/queries/projects/useUploadImageFileQuery' import useUploadImageFolderFileQuery from '@/queries/projects/useUploadImageFolderFileQuery'; import useUploadImageZipQuery from '@/queries/projects/useUploadImageZipQuery'; import useUploadImageFolderQuery from '@/queries/projects/useUploadImageFolderQuery'; +import ImagePreSignedForm from '../ImagePreSignedForm'; export default function WorkspaceDropdownMenu({ projectId, @@ -31,6 +32,7 @@ export default function WorkspaceDropdownMenu({ const [isOpenUploadFolderFile, setIsOpenUploadFolderFile] = React.useState(false); const [isOpenUploadFolder, setIsOpenUploadFolder] = React.useState(false); const [isOpenUploadZip, setIsOpenUploadZip] = React.useState(false); + const [isOpenTestUpload, setIsOpenTestUpload] = React.useState(false); const uploadImageZipMutation = useUploadImageZipQuery(); const uploadImageFolderFileMutation = useUploadImageFolderFileQuery(); @@ -41,10 +43,12 @@ export default function WorkspaceDropdownMenu({ const handleCloseUploadFolderFile = () => setIsOpenUploadFolderFile(false); const handleCloseUploadFolder = () => setIsOpenUploadFolder(false); const handleCloseUploadZip = () => setIsOpenUploadZip(false); + const handleCloseTestUpload = () => setIsOpenTestUpload(false); const handleFileCount = (fileCount: number) => { setFileCount(fileCount); }; + return ( <> @@ -65,9 +69,14 @@ export default function WorkspaceDropdownMenu({ 폴더 업로드 (백엔드 구현 필요) setIsOpenUploadZip(true)}>폴더 압축파일 업로드 + setIsOpenTestUpload(true)}> + 테스트 업로드 (PresignedUrl 이용) + {' '} + {/* 새로운 메뉴 항목 추가 */} + {/* 기존 Dialogs */} + + {/* 테스트 업로드 Dialog */} + + + + + setFileCount(fileCount)} + projectId={projectId} + folderId={folderId} + uploadType="folder" // zip flie 가능 + /> + + ); } diff --git a/frontend/src/hooks/useUploadFiles.ts b/frontend/src/hooks/useUploadFiles.ts new file mode 100644 index 0000000..7e64bf4 --- /dev/null +++ b/frontend/src/hooks/useUploadFiles.ts @@ -0,0 +1,68 @@ +import useCreateFolderQuery from '@/queries/folders/useCreateFolderQuery'; +import useUploadImagePresignedQuery from '@/queries/images/useUploadImagePresignedQuery'; + +export default function useUploadFiles() { + const uploadImageMutation = useUploadImagePresignedQuery(); + const createFolderMutation = useCreateFolderQuery(); + + const uploadFiles = async ({ + files, + projectId, + folderId, + memberId, + onProgress, + }: { + files: { path: string; file: File }[]; + projectId: number; + folderId: number; + memberId: number; + onProgress: (progress: number) => void; + }) => { + const folderIdMap: { [path: string]: number } = { '': folderId }; + + const foldersToCreate = Array.from(new Set(files.map(({ path }) => path.split('/').slice(0, -1).join('/')))); + foldersToCreate.sort(); + + for (const folderPath of foldersToCreate) { + if (folderPath) { + const pathSegments = folderPath.split('/'); + const parentPath = pathSegments.slice(0, -1).join('/'); + const folderName = pathSegments[pathSegments.length - 1]; + + const parentId = folderIdMap[parentPath] || folderId; + + const newFolder = await createFolderMutation.mutateAsync({ + projectId, + memberId, + folderData: { + title: folderName, + parentId: parentId, + }, + }); + + folderIdMap[folderPath] = newFolder.id; + } + } + + let progress = 0; + const totalFiles = files.length; + + for (const { path, file } of files) { + const folderPath = path.split('/').slice(0, -1).join('/'); + const targetFolderId = folderIdMap[folderPath] || folderId; + + await uploadImageMutation.mutateAsync({ + memberId, + projectId, + folderId: targetFolderId, + files: [file], + progressCallback: (value) => { + progress += value / totalFiles; + onProgress(Math.round(progress)); + }, + }); + } + }; + + return { uploadFiles }; +} diff --git a/frontend/src/utils/fileUtils.ts b/frontend/src/utils/fileUtils.ts new file mode 100644 index 0000000..5362de9 --- /dev/null +++ b/frontend/src/utils/fileUtils.ts @@ -0,0 +1,78 @@ +import JSZip from 'jszip'; + +export async function unzipFilesWithPath(file: File, maxDepth: number = 10): Promise<{ path: string; file: File }[]> { + const zip = await JSZip.loadAsync(file); + const files: { path: string; file: File }[] = []; + + const zipFolderName = file.name.replace(/\.zip$/i, ''); + + const extractFiles = async (zipObj: JSZip, currentPath: string = '', currentDepth: number = 0) => { + if (currentDepth > maxDepth) { + return; + } + + const promises = Object.keys(zipObj.files).map(async (filename) => { + const fileData = zipObj.files[filename]; + const fullPath = currentPath ? `${currentPath}/${filename}` : `${zipFolderName}/${filename}`; + + if (!fileData.dir) { + const blob = await fileData.async('blob'); + files.push({ path: fullPath, file: new File([blob], filename) }); + } else { + if (fileData.name !== filename) { + const folderZipObj = zipObj.folder(fileData.name); + if (folderZipObj) { + await extractFiles(folderZipObj, fullPath, currentDepth + 1); + } + } + } + }); + + await Promise.all(promises); + }; + + await extractFiles(zip); + + return files; +} + +export function extractFilesRecursivelyWithPath( + entry: FileSystemEntry, + currentPath: string = '' +): Promise<{ path: string; file: File }[]> { + return new Promise((resolve) => { + if (entry) { + if (entry.isDirectory) { + const dirReader = (entry as FileSystemDirectoryEntry).createReader(); + const files: { path: string; file: File }[] = []; + + const readEntries = () => { + dirReader.readEntries(async (entries) => { + if (entries.length > 0) { + const newFilesArrays = await Promise.all( + Array.from(entries).map((e) => { + const newPath = currentPath ? `${currentPath}/${e.name}` : e.name; + return extractFilesRecursivelyWithPath(e, newPath); + }) + ); + newFilesArrays.forEach((newFiles) => files.push(...newFiles)); + readEntries(); + } else { + resolve(files); + } + }); + }; + + readEntries(); + } else if (entry.isFile) { + (entry as FileSystemFileEntry).file((file: File) => { + resolve([{ path: currentPath, file }]); + }); + } else { + resolve([]); + } + } else { + resolve([]); + } + }); +}