Feat: 이미지 업로드 퍼센트 및 진행 상황 보여주기

This commit is contained in:
김용수 2024-10-02 17:18:52 +09:00
parent 048d73868d
commit ee5489554c
7 changed files with 361 additions and 7 deletions

View File

@ -1,5 +1,6 @@
import api from '@/api/axiosConfig'; 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) { export async function getImage(imageId: number, memberId: number) {
return api.get(`/images/${imageId}`, { return api.get(`/images/${imageId}`, {
@ -22,7 +23,7 @@ export async function deleteImage(imageId: number, memberId: number) {
export async function changeImageStatus( export async function changeImageStatus(
imageId: number, imageId: number,
memberId: number, memberId: number,
statusChangeRequest: ImageStatusChangeRequest statusChangeRequest: ImageStatusChangeRequest,
) { ) {
return api return api
.put(`/images/${imageId}/status`, statusChangeRequest, { .put(`/images/${imageId}/status`, statusChangeRequest, {
@ -36,7 +37,7 @@ export async function uploadImageFile(
projectId: number, projectId: number,
folderId: number, folderId: number,
files: File[], files: File[],
processCallback: (progress: number) => void processCallback: (progress: number) => void,
) { ) {
const formData = new FormData(); const formData = new FormData();
files.forEach((file) => { files.forEach((file) => {
@ -61,7 +62,7 @@ export async function uploadImageFolderFile(
projectId: number, projectId: number,
folderId: number, folderId: number,
files: File[], files: File[],
processCallback: (progress: number) => void processCallback: (progress: number) => void,
) { ) {
const formData = new FormData(); const formData = new FormData();
files.forEach((file) => { files.forEach((file) => {
@ -86,7 +87,7 @@ export async function uploadImageFolder(
projectId: number, projectId: number,
folderId: number, folderId: number,
files: File[], files: File[],
processCallback: (progress: number) => void processCallback: (progress: number) => void,
) { ) {
const formData = new FormData(); const formData = new FormData();
files.forEach((file) => { files.forEach((file) => {
@ -111,7 +112,7 @@ export async function uploadImageZip(
projectId: number, projectId: number,
folderId: number, folderId: number,
file: File, file: File,
processCallback: (progress: number) => void processCallback: (progress: number) => void,
) { ) {
const formData = new FormData(); const formData = new FormData();
formData.append('folderZip', file); formData.append('folderZip', file);
@ -128,3 +129,63 @@ export async function uploadImageZip(
}) })
.then(({ data }) => data); .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}`);
}

View File

@ -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<File[]>([]);
const [isDragging, setIsDragging] = useState<boolean>(false);
const [isUploading, setIsUploading] = useState<boolean>(false);
const [isUploaded, setIsUploaded] = useState<boolean>(false);
const [isFailed, setIsFailed] = useState<boolean>(false);
const [uploadStatus, setUploadStatus] = useState<(boolean | null)[]>([]); // 각 파일의 성공/실패 여부 관리
const uploadImageFile = useUploadImagePresignedQuery();
const handleClose = () => {
onClose();
};
const handleRefetch = () => {
if (onRefetch) {
onRefetch();
}
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLDivElement>) => {
event.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
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 (
<div className="flex flex-col gap-5">
{!isUploading && (
<div
className={cn(
'relative flex h-[200px] w-full cursor-pointer items-center justify-center rounded-lg border-2 text-center',
isDragging ? 'border-solid border-primary bg-blue-200' : 'border-dashed border-gray-500 bg-gray-100',
)}
>
<input
type="file"
accept=".jpg,.jpeg,.png"
// webkitdirectory=""
multiple
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
onChange={handleChange}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
/>
{isDragging ? (
<p className="text-primary"> </p>
) : (
<p className="text-gray-500">
<br />
</p>
)}
</div>
)}
{files.length > 0 && (
<ul className="m-0 max-h-[260px] list-none overflow-y-auto p-0">
{files.map((file, index) => (
<li key={index} className="flex items-center justify-between p-1">
<span className="truncate">{file.name}</span>
{isUploading ? (
<div className="p-2">
{uploadStatus[index] === true ? (
<CircleCheckBig className="stroke-green-500" size={16} strokeWidth="2" />
) : uploadStatus[index] === false ? (
<CircleX className="stroke-red-500" size={16} strokeWidth="2" />
) : (
<CircleDashed className="stroke-gray-500" size={16} strokeWidth="2" />
)}
</div>
) : (
<button className={'cursor-pointer p-2'} onClick={() => handleRemoveFile(index)}>
<X color="red" size={16} strokeWidth="2" />
</button>
)}
</li>
))}
</ul>
)}
{isUploading ? (
<Button onClick={handleClose} variant={isFailed ? 'red' : 'blue'}>
{isFailed
? '업로드 실패 (닫기)'
: isUploaded
? '업로드 완료 (닫기)'
: totalProgress === 0
? '업로드 준비 중...'
: `업로드 중... ${totalProgress}%`}
</Button>
) : (
<Button
onClick={handleUpload}
variant="blue"
disabled={files.length === 0}
>
</Button>
)}
</div>
);
}

View File

@ -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<number>(0);
const handleOpen = () => setIsOpen(true);
const handleClose = () => setIsOpen(false);
const handleFileCount = (fileCount: number) => {
setFileCount(fileCount);
};
return (
<Dialog
open={isOpen}
onOpenChange={setIsOpen}
>
<DialogTrigger asChild>
<button
className="flex items-center justify-center p-2"
onClick={handleOpen}
>
<Plus size={20} />
</button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader title={fileCount > 0 ? `파일 업로드 (${fileCount}) PreSigned` : '파일 업로드 PreSigned'} />
<ImageUploadPresingedForm
onClose={handleClose}
onFileCount={handleFileCount}
projectId={projectId}
folderId={folderId}
/>
</DialogContent>
</Dialog>
);
}

View File

@ -7,11 +7,12 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '../ui/dropdown-menu'; } from '../ui/dropdown-menu';
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from '../ui/dialogCustom'; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from '../ui/dialogCustom';
import ImageUploadFileForm from '../ImageUploadFileModal/ImageUploadFileForm';
import React from 'react'; import React from 'react';
import ImageUploadFileForm from '../ImageUploadFileModal/ImageUploadFileForm';
import ImageUploadFolderFileForm from '../ImageUploadFolderFileModal/ImageUploadFolderFileForm'; import ImageUploadFolderFileForm from '../ImageUploadFolderFileModal/ImageUploadFolderFileForm';
import ImageUploadFolderForm from '../ImageUploadFolderModal/ImageUploadFolderForm'; import ImageUploadFolderForm from '../ImageUploadFolderModal/ImageUploadFolderForm';
import ImageUploadZipForm from '../ImageUploadZipModal/ImageUploadZipForm'; import ImageUploadZipForm from '../ImageUploadZipModal/ImageUploadZipForm';
import ImageUploadPresignedForm from '../ImageUploadPresignedModal/ImageUploadPresignedForm.tsx'
export default function WorkspaceDropdownMenu({ export default function WorkspaceDropdownMenu({
projectId, projectId,
@ -26,6 +27,7 @@ export default function WorkspaceDropdownMenu({
const [fileCount, setFileCount] = React.useState<number>(0); const [fileCount, setFileCount] = React.useState<number>(0);
const [isOpenUploadFolderFile, setIsOpenUploadFolderFile] = React.useState<boolean>(false); const [isOpenUploadFolderFile, setIsOpenUploadFolderFile] = React.useState<boolean>(false);
const [isOpenUploadFolder, setIsOpenUploadFolder] = React.useState<boolean>(false); const [isOpenUploadFolder, setIsOpenUploadFolder] = React.useState<boolean>(false);
const [isOpenUploadPresigned, setIsOpenUploadPresigned] = React.useState<boolean>(false);
const [isOpenUploadZip, setIsOpenUploadZip] = React.useState<boolean>(false); const [isOpenUploadZip, setIsOpenUploadZip] = React.useState<boolean>(false);
const handleOpenUploadFile = () => setIsOpenUploadFile(true); const handleOpenUploadFile = () => setIsOpenUploadFile(true);
@ -46,6 +48,12 @@ export default function WorkspaceDropdownMenu({
setIsOpenUploadFolder(false); setIsOpenUploadFolder(false);
}; };
const handleOpenUploadPresigned = () => setIsOpenUploadPresigned(true);
const handleCloseUploadPresigned = () => {
setIsOpenUploadPresigned(false);
};
const handleOpenUploadZip = () => setIsOpenUploadZip(true); const handleOpenUploadZip = () => setIsOpenUploadZip(true);
const handleCloseUploadZip = () => { const handleCloseUploadZip = () => {
@ -77,6 +85,7 @@ export default function WorkspaceDropdownMenu({
<DropdownMenuItem onClick={handleOpenUploadFile}> </DropdownMenuItem> <DropdownMenuItem onClick={handleOpenUploadFile}> </DropdownMenuItem>
<DropdownMenuItem onClick={handleOpenUploadFolderFile}> ( API )</DropdownMenuItem> <DropdownMenuItem onClick={handleOpenUploadFolderFile}> ( API )</DropdownMenuItem>
<DropdownMenuItem onClick={handleOpenUploadFolder}> ( )</DropdownMenuItem> <DropdownMenuItem onClick={handleOpenUploadFolder}> ( )</DropdownMenuItem>
<DropdownMenuItem onClick={handleOpenUploadPresigned}> (PresignedUrl )</DropdownMenuItem>
<DropdownMenuItem onClick={handleOpenUploadZip}> </DropdownMenuItem> <DropdownMenuItem onClick={handleOpenUploadZip}> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -130,6 +139,23 @@ export default function WorkspaceDropdownMenu({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog
open={isOpenUploadPresigned}
onOpenChange={setIsOpenUploadPresigned}
>
<DialogTrigger asChild></DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader title='파일 업로드' />
<ImageUploadPresignedForm
onClose={handleCloseUploadPresigned}
onRefetch={onRefetch}
onFileCount={handleFileCount}
projectId={projectId}
folderId={folderId}
/>
</DialogContent>
</Dialog>
<Dialog <Dialog
open={isOpenUploadZip} open={isOpenUploadZip}
onOpenChange={setIsOpenUploadZip} onOpenChange={setIsOpenUploadZip}

View File

@ -0,0 +1,20 @@
import { uploadImagePresigned } from '@/api/imageApi';
import { useMutation } from '@tanstack/react-query';
export default function useUploadImagePresignedQuery() {
return useMutation({
mutationFn: ({
memberId,
projectId,
folderId,
files,
progressCallback,
}: {
memberId: number;
projectId: number;
folderId: number;
files: File[];
progressCallback: (index: number) => void;
}) => uploadImagePresigned(memberId, projectId, folderId, files, progressCallback),
});
}

View File

@ -31,3 +31,8 @@ export interface ImageFolderRequest {
parentId: number; parentId: number;
files: File[]; files: File[];
} }
export interface ImagePresignedUrlResponse{
id: number;
presignedUrl: string;
}

6
package-lock.json generated Normal file
View File

@ -0,0 +1,6 @@
{
"name": "S11P21S002",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}