diff --git a/frontend/src/api/imageApi.ts b/frontend/src/api/imageApi.ts index d7c1ecf..f9d187f 100644 --- a/frontend/src/api/imageApi.ts +++ b/frontend/src/api/imageApi.ts @@ -1,5 +1,6 @@ import api from '@/api/axiosConfig'; -import { ImageMoveRequest, ImageStatusChangeRequest } from '@/types'; +import { ImageMoveRequest, ImageStatusChangeRequest, ImagePresignedUrlResponse } from '@/types'; +import axios from 'axios'; export async function getImage(imageId: number, memberId: number) { return api.get(`/images/${imageId}`, { @@ -22,7 +23,7 @@ export async function deleteImage(imageId: number, memberId: number) { export async function changeImageStatus( imageId: number, memberId: number, - statusChangeRequest: ImageStatusChangeRequest + statusChangeRequest: ImageStatusChangeRequest, ) { return api .put(`/images/${imageId}/status`, statusChangeRequest, { @@ -36,7 +37,7 @@ export async function uploadImageFile( projectId: number, folderId: number, files: File[], - processCallback: (progress: number) => void + processCallback: (progress: number) => void, ) { const formData = new FormData(); files.forEach((file) => { @@ -61,7 +62,7 @@ export async function uploadImageFolderFile( projectId: number, folderId: number, files: File[], - processCallback: (progress: number) => void + processCallback: (progress: number) => void, ) { const formData = new FormData(); files.forEach((file) => { @@ -86,7 +87,7 @@ export async function uploadImageFolder( projectId: number, folderId: number, files: File[], - processCallback: (progress: number) => void + processCallback: (progress: number) => void, ) { const formData = new FormData(); files.forEach((file) => { @@ -111,7 +112,7 @@ export async function uploadImageZip( projectId: number, folderId: number, file: File, - processCallback: (progress: number) => void + processCallback: (progress: number) => void, ) { const formData = new FormData(); formData.append('folderZip', file); @@ -128,3 +129,63 @@ export async function uploadImageZip( }) .then(({ data }) => data); } + +export async function uploadImagePresigned( + memberId: number, + projectId: number, + folderId: number, + files: File[], + processCallback: (index: number) => void, +) { + // 업로드 시작 시간 기록 + const startTime = new Date().getTime(); + + // 파일 메타데이터 생성 + const imageMetaList = files.map((file: File, index: number) => ({ + id: index, + fileName: file.name, + })); + + // 서버로부터 presigned URL 리스트 받아옴 + const { data: presignedUrlList }: { data: ImagePresignedUrlResponse[] } = await api.post( + `/projects/${projectId}/folders/${folderId}/images/presigned`, + imageMetaList, + { + params: { memberId }, + }, + ); + + +// 각 파일을 presigned URL에 맞춰서 업로드 (axios 직접 사용) + for (const presignedUrlInfo of presignedUrlList) { + const file = files[presignedUrlInfo.id]; + + try { + // S3 presigned URL로 개별 파일 업로드 + await axios.put(presignedUrlInfo.presignedUrl, file, { + headers: { + 'Content-Type': file.type, // 파일의 타입 설정 + }, + onUploadProgress: (progressEvent) => { + if (progressEvent.total) { + processCallback(presignedUrlInfo.id); // 성공 시 진행 상황 업데이트 + } + }, + }); + + // 파일이 성공적으로 업로드되면 로그 출력 + } catch (error) { + // 업로드 실패 시 로그 출력 + console.error(`업로드 실패: ${file.name}`, error); + } + } + + // 업로드 완료 시간 기록 + const endTime = new Date().getTime(); + + // 소요 시간 계산 (초 단위로 변환) + const durationInSeconds = (endTime - startTime) / 1000; + + // 소요 시간 콘솔 출력 + console.log(`모든 파일 업로드 완료. 총 소요 시간: ${durationInSeconds}초`); +} diff --git a/frontend/src/components/ImageUploadPresignedModal/ImageUploadPresignedForm.tsx b/frontend/src/components/ImageUploadPresignedModal/ImageUploadPresignedForm.tsx new file mode 100644 index 0000000..e5ae981 --- /dev/null +++ b/frontend/src/components/ImageUploadPresignedModal/ImageUploadPresignedForm.tsx @@ -0,0 +1,196 @@ +import { useEffect, useState } 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 useUploadImagePresignedQuery from '@/queries/projects/useUploadImagePresignedQuery.ts'; + +export default function ImageUploadPresignedForm({ + onClose, + onRefetch, + onFileCount, + projectId, + folderId, + }: { + onClose: () => void; + onRefetch?: () => void; + onFileCount: (fileCount: number) => void; + projectId: number; + folderId: number; +}) { + const profile = useAuthStore((state) => state.profile); + const memberId = profile?.id || 0; + + const [files, setFiles] = useState([]); + const [isDragging, setIsDragging] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [isUploaded, setIsUploaded] = useState(false); + const [isFailed, setIsFailed] = useState(false); + const [uploadStatus, setUploadStatus] = useState<(boolean | null)[]>([]); // 각 파일의 성공/실패 여부 관리 + + const uploadImageFile = useUploadImagePresignedQuery(); + + + const handleClose = () => { + onClose(); + }; + + const handleRefetch = () => { + if (onRefetch) { + onRefetch(); + } + }; + + const handleChange = (event: React.ChangeEvent) => { + const newFiles = event.target.files; + + if (newFiles) { + const newImages = Array.from(newFiles).filter((file) => { + const fileExtension = file.name.split('.').pop()?.toLowerCase() ?? ''; + return ['jpg', 'png', 'jpeg'].includes(fileExtension); + }); + + setFiles((prevFiles) => [...prevFiles, ...newImages]); + setUploadStatus((prevState) => [...prevState, ...newImages.map(()=> null)]); + } + + event.target.value = ''; + }; + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (event: React.DragEvent) => { + event.preventDefault(); + setIsDragging(false); + }; + + const handleDrop = () => { + setIsDragging(false); + }; + + const handleRemoveFile = (index: number) => { + setFiles(files.filter((_, i) => i != index)); + }; + + const handleUpload = async () => { + setIsUploading(true); + + uploadImageFile.mutate( + { + memberId, + projectId, + folderId, + files, + progressCallback: (index: number) => { + // 업로드 성공하면 상태 업데이트 + setUploadStatus((prevStatus) => { + const newStatus = [...prevStatus]; + newStatus[index] = true; // 업로드 성공 시 true + return newStatus; + }); + }, + }, + { + onSuccess: () => { + handleRefetch(); + setIsUploaded(true); + }, + onError: () => { + setIsFailed(true); + setUploadStatus((prevStatus) => + prevStatus.map((status) => (status === null ? false : status)) + ); // 실패 시 처리 + }, + }, + ); + }; + + // 전체 진행 상황 계산 + const totalProgress = Math.round( + (uploadStatus.filter((status) => status !== null).length / files.length) * 100 + ); + + useEffect(() => { + onFileCount(files.length); + }, [files, onFileCount]); + + return ( +
+ {!isUploading && ( +
+ + {isDragging ? ( +

드래그한 파일을 여기에 놓으세요

+ ) : ( +

+ 파일을 업로드하려면 여기를 클릭하거나 +
+ 파일을 드래그하여 여기에 놓으세요 +

+ )} +
+ )} + {files.length > 0 && ( +
    + {files.map((file, index) => ( +
  • + {file.name} + {isUploading ? ( +
    + {uploadStatus[index] === true ? ( + + ) : uploadStatus[index] === false ? ( + + ) : ( + + )} +
    + ) : ( + + )} +
  • + ))} +
+ )} + {isUploading ? ( + + ) : ( + + )} +
+ ); +} diff --git a/frontend/src/components/ImageUploadPresignedModal/index.tsx b/frontend/src/components/ImageUploadPresignedModal/index.tsx new file mode 100644 index 0000000..bf134e3 --- /dev/null +++ b/frontend/src/components/ImageUploadPresignedModal/index.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from '../ui/dialogCustom'; +import { Plus } from 'lucide-react'; +import ImageUploadPresingedForm from '@/components/ImageUploadPresignedModal/ImageUploadPresignedForm.tsx'; + +export default function ImageUploadPresignedModal({ projectId, folderId }: { projectId: number; folderId: number }) { + const [isOpen, setIsOpen] = React.useState(false); + const [fileCount, setFileCount] = React.useState(0); + + const handleOpen = () => setIsOpen(true); + const handleClose = () => setIsOpen(false); + const handleFileCount = (fileCount: number) => { + setFileCount(fileCount); + }; + + return ( + + + + + + 0 ? `파일 업로드 (${fileCount}) PreSigned` : '파일 업로드 PreSigned'} /> + + + + ); +} diff --git a/frontend/src/components/WorkspaceDropdownMenu/index.tsx b/frontend/src/components/WorkspaceDropdownMenu/index.tsx index 0800ae3..d2c271d 100644 --- a/frontend/src/components/WorkspaceDropdownMenu/index.tsx +++ b/frontend/src/components/WorkspaceDropdownMenu/index.tsx @@ -7,11 +7,12 @@ import { DropdownMenuTrigger, } from '../ui/dropdown-menu'; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from '../ui/dialogCustom'; -import ImageUploadFileForm from '../ImageUploadFileModal/ImageUploadFileForm'; import React from 'react'; +import ImageUploadFileForm from '../ImageUploadFileModal/ImageUploadFileForm'; import ImageUploadFolderFileForm from '../ImageUploadFolderFileModal/ImageUploadFolderFileForm'; import ImageUploadFolderForm from '../ImageUploadFolderModal/ImageUploadFolderForm'; import ImageUploadZipForm from '../ImageUploadZipModal/ImageUploadZipForm'; +import ImageUploadPresignedForm from '../ImageUploadPresignedModal/ImageUploadPresignedForm.tsx' export default function WorkspaceDropdownMenu({ projectId, @@ -26,6 +27,7 @@ export default function WorkspaceDropdownMenu({ const [fileCount, setFileCount] = React.useState(0); const [isOpenUploadFolderFile, setIsOpenUploadFolderFile] = React.useState(false); const [isOpenUploadFolder, setIsOpenUploadFolder] = React.useState(false); + const [isOpenUploadPresigned, setIsOpenUploadPresigned] = React.useState(false); const [isOpenUploadZip, setIsOpenUploadZip] = React.useState(false); const handleOpenUploadFile = () => setIsOpenUploadFile(true); @@ -46,6 +48,12 @@ export default function WorkspaceDropdownMenu({ setIsOpenUploadFolder(false); }; + const handleOpenUploadPresigned = () => setIsOpenUploadPresigned(true); + + const handleCloseUploadPresigned = () => { + setIsOpenUploadPresigned(false); + }; + const handleOpenUploadZip = () => setIsOpenUploadZip(true); const handleCloseUploadZip = () => { @@ -77,6 +85,7 @@ export default function WorkspaceDropdownMenu({ 파일 업로드 폴더 업로드 (파일 업로드 API 이용) 폴더 업로드 (백엔드 구현 필요) + 폴더 업로드 (PresignedUrl 이용) 폴더 압축파일 업로드 @@ -130,6 +139,23 @@ export default function WorkspaceDropdownMenu({ + + + + + + + + void; + }) => uploadImagePresigned(memberId, projectId, folderId, files, progressCallback), + }); +} \ No newline at end of file diff --git a/frontend/src/types/imageTypes.ts b/frontend/src/types/imageTypes.ts index 4fe094f..820d73b 100644 --- a/frontend/src/types/imageTypes.ts +++ b/frontend/src/types/imageTypes.ts @@ -31,3 +31,8 @@ export interface ImageFolderRequest { parentId: number; files: File[]; } + +export interface ImagePresignedUrlResponse{ + id: number; + presignedUrl: string; +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..32a2357 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "S11P21S002", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}