Merge branch 'fe/refactor/upload' into 'fe/develop'

Refacotr: 업로드 부분 리팩토링 및 드래그 앱 드랍 살림.

See merge request s11-s-project/S11P21S002!295
This commit is contained in:
홍창기 2024-10-06 00:20:02 +09:00
commit 2fcb333d65
8 changed files with 481 additions and 77 deletions

View File

@ -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 { UseMutationResult } from '@tanstack/react-query';
import { UploadZipParams, UploadFolderParams } from '@/types/uploadTypes';
interface UploadFormProps {
onClose: () => void;
onRefetch?: () => void;
onFileCount: (fileCount: number) => void;
projectId: number;
folderId: number;
isFolderUpload?: boolean;
isZipUpload?: boolean;
isFolderBackendUpload?: boolean;
uploadImageZipMutation: UseMutationResult<unknown, Error, UploadZipParams, unknown>;
uploadImageFolderFileMutation: UseMutationResult<unknown, Error, UploadFolderParams, unknown>;
uploadImageFileMutation: UseMutationResult<unknown, Error, UploadFolderParams, unknown>;
uploadImageFolderMutation: UseMutationResult<unknown, Error, UploadFolderParams, unknown>;
}
export default function ImageUploadForm({
onClose,
onRefetch,
onFileCount,
projectId,
folderId,
isFolderUpload = false,
isZipUpload = false,
isFolderBackendUpload = false,
uploadImageZipMutation,
uploadImageFolderFileMutation,
uploadImageFileMutation,
uploadImageFolderMutation,
}: UploadFormProps) {
const profile = useAuthStore((state) => state.profile);
const memberId = profile?.id || 0;
const [files, setFiles] = useState<File[]>([]);
const [inputKey, setInputKey] = useState<number>(0);
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 [progress, setProgress] = useState<number>(0);
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<HTMLInputElement>) => {
const newFiles = event.target.files;
if (newFiles) {
setFiles((prevFiles) => [...prevFiles, ...Array.from(newFiles)]);
}
event.target.value = '';
};
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
};
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
const droppedFiles = event.dataTransfer.files;
if (droppedFiles) {
setFiles((prevFiles) => [...prevFiles, ...Array.from(droppedFiles)]);
}
};
const handleRemoveFile = (index: number) => {
if (isFolderUpload) {
setFiles([]);
setInputKey((prevKey) => prevKey + 1);
} else {
setFiles(files.filter((_, i) => i !== index));
}
};
const handleUpload = () => {
if (files.length > 0) {
setIsUploading(true);
setIsUploaded(false);
setIsFailed(false);
const progressCallback = (progress: number) => {
setProgress(progress);
};
if (isZipUpload) {
const variables: UploadZipParams = {
memberId,
projectId,
folderId,
file: files[0],
progressCallback,
};
uploadImageZipMutation.mutate(variables, {
onSuccess: () => {
handleRefetch();
setIsUploaded(true);
},
onError: () => {
setIsFailed(true);
},
});
} else {
const variables: UploadFolderParams = {
memberId,
projectId,
folderId,
files,
progressCallback,
};
const mutation = isFolderBackendUpload
? uploadImageFolderMutation
: isFolderUpload
? uploadImageFolderFileMutation
: uploadImageFileMutation;
mutation.mutate(variables, {
onSuccess: () => {
handleRefetch();
setIsUploaded(true);
},
onError: () => {
setIsFailed(true);
},
});
}
}
};
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'
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
key={inputKey}
type="file"
{...(isFolderUpload ? { webkitdirectory: '' } : { multiple: !isZipUpload })}
accept={isZipUpload ? '.zip' : undefined}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
onChange={handleChange}
/>
{isDragging ? (
<p className="text-primary">
{isFolderUpload ? '드래그한 폴더를 여기에 놓으세요' : '드래그한 파일을 여기에 놓으세요'}
</p>
) : (
<p className="text-gray-500">
{isFolderUpload
? '폴더를 업로드하려면 여기를 클릭하거나 폴더를 드래그하여 여기에 놓으세요'
: '파일을 업로드하려면 여기를 클릭하거나 파일을 드래그하여 여기에 놓으세요'}
</p>
)}
</div>
)}
{files.length > 0 && (
<ul className="m-0 max-h-[260px] list-none overflow-y-auto p-0">
<FixedSizeList
height={260}
itemCount={files.length}
itemSize={40}
width="100%"
>
{({ index, style }) => (
<li
key={index}
className="flex items-center justify-between p-1"
style={style}
>
<span className="truncate">{files[index].webkitRelativePath || files[index].name}</span>
{isUploading ? (
<div className="p-2">
{isUploaded ? (
<CircleCheckBig
className="stroke-green-500"
size={16}
strokeWidth="2"
/>
) : isFailed ? (
<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>
)}
</FixedSizeList>
</ul>
)}
{isUploading ? (
<Button
onClick={handleClose}
variant={isFailed ? 'red' : 'blue'}
disabled={!isUploaded && !isFailed}
>
{isFailed ? '업로드 실패 (닫기)' : isUploaded ? '업로드 완료 (닫기)' : `업로드 중... ${progress}%`}
</Button>
) : (
<Button
onClick={handleUpload}
variant="blue"
disabled={files.length === 0}
>
</Button>
)}
</div>
);
}

View File

@ -0,0 +1,70 @@
import React from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from '../ui/dialogCustom';
import { Plus } from 'lucide-react';
import ImageUploadForm from './ImageUploadForm';
import useUploadImageFileQuery from '@/queries/projects/useUploadImageFileQuery';
import useUploadImageFolderFileQuery from '@/queries/projects/useUploadImageFolderFileQuery';
import useUploadImageZipQuery from '@/queries/projects/useUploadImageZipQuery';
import useUploadImageFolderQuery from '@/queries/projects/useUploadImageFolderQuery';
interface ImageUploadModalProps {
projectId: number;
folderId: number;
isFolderUpload?: boolean;
isZipUpload?: boolean;
isFolderBackendUpload?: boolean;
}
export default function ImageUploadModal({
projectId,
folderId,
isFolderUpload = false,
isZipUpload = false,
isFolderBackendUpload = false,
}: ImageUploadModalProps) {
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);
};
const uploadImageZipMutation = useUploadImageZipQuery();
const uploadImageFolderFileMutation = useUploadImageFolderFileQuery();
const uploadImageFileMutation = useUploadImageFileQuery();
const uploadImageFolderMutation = useUploadImageFolderQuery();
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})` : '파일 업로드'} />
<ImageUploadForm
onClose={handleClose}
onFileCount={handleFileCount}
projectId={projectId}
folderId={folderId}
isFolderUpload={isFolderUpload}
isZipUpload={isZipUpload}
isFolderBackendUpload={isFolderBackendUpload}
uploadImageZipMutation={uploadImageZipMutation}
uploadImageFolderFileMutation={uploadImageFolderFileMutation}
uploadImageFileMutation={uploadImageFileMutation}
uploadImageFolderMutation={uploadImageFolderMutation}
/>
</DialogContent>
</Dialog>
);
}

View File

@ -6,12 +6,12 @@ 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,
onRefetch,
onFileCount,
projectId,
folderId,
}: {
onClose: () => void;
onRefetch?: () => void;
onFileCount: (fileCount: number) => void;
@ -26,11 +26,10 @@ export default function ImageUploadPresignedForm({
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 [uploadStatus, setUploadStatus] = useState<(boolean | null)[]>([]);
const uploadImageFile = useUploadImagePresignedQuery();
const handleClose = () => {
onClose();
};
@ -51,7 +50,7 @@ export default function ImageUploadPresignedForm({
});
setFiles((prevFiles) => [...prevFiles, ...newImages]);
setUploadStatus((prevState) => [...prevState, ...newImages.map(()=> null)]);
setUploadStatus((prevState) => [...prevState, ...newImages.map(() => null)]);
}
event.target.value = '';
@ -59,20 +58,38 @@ export default function ImageUploadPresignedForm({
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
};
const handleDrop = () => {
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
const droppedFiles = event.dataTransfer.files;
if (droppedFiles) {
const newImages = Array.from(droppedFiles).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)]);
}
};
const handleRemoveFile = (index: number) => {
setFiles(files.filter((_, i) => i != index));
setFiles(files.filter((_, i) => i !== index));
setUploadStatus((prevState) => prevState.filter((_, i) => i !== index));
};
const handleUpload = async () => {
@ -85,7 +102,6 @@ export default function ImageUploadPresignedForm({
folderId,
files,
progressCallback: (index: number) => {
// 업로드 성공하면 상태 업데이트
setUploadStatus((prevStatus) => {
const newStatus = [...prevStatus];
newStatus[index] = true; // 업로드 성공 시 true
@ -100,18 +116,14 @@ export default function ImageUploadPresignedForm({
},
onError: () => {
setIsFailed(true);
setUploadStatus((prevStatus) =>
prevStatus.map((status) => (status === null ? false : status))
); // 실패 시 처리
setUploadStatus((prevStatus) => prevStatus.map((status) => (status === null ? false : status))); // 실패 시 처리
},
},
}
);
};
// 전체 진행 상황 계산
const totalProgress = Math.round(
(uploadStatus.filter((status) => status !== null).length / files.length) * 100
);
const totalProgress = Math.round((uploadStatus.filter((status) => status !== null).length / files.length) * 100);
useEffect(() => {
onFileCount(files.length);
@ -123,8 +135,11 @@ export default function ImageUploadPresignedForm({
<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',
isDragging ? 'border-solid border-primary bg-blue-200' : 'border-dashed border-gray-500 bg-gray-100'
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
type="file"
@ -133,9 +148,6 @@ export default function ImageUploadPresignedForm({
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>
@ -151,21 +163,43 @@ export default function ImageUploadPresignedForm({
{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">
<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" />
<CircleCheckBig
className="stroke-green-500"
size={16}
strokeWidth="2"
/>
) : uploadStatus[index] === false ? (
<CircleX className="stroke-red-500" size={16} strokeWidth="2" />
<CircleX
className="stroke-red-500"
size={16}
strokeWidth="2"
/>
) : (
<CircleDashed className="stroke-gray-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
className={'cursor-pointer p-2'}
onClick={() => handleRemoveFile(index)}
>
<X
color="red"
size={16}
strokeWidth="2"
/>
</button>
)}
</li>
@ -173,7 +207,10 @@ export default function ImageUploadPresignedForm({
</ul>
)}
{isUploading ? (
<Button onClick={handleClose} variant={isFailed ? 'red' : 'blue'}>
<Button
onClick={handleClose}
variant={isFailed ? 'red' : 'blue'}
>
{isFailed
? '업로드 실패 (닫기)'
: isUploaded

View File

@ -1,3 +1,4 @@
import React from 'react';
import { Menu } from 'lucide-react';
import {
DropdownMenu,
@ -7,12 +8,12 @@ import {
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from '../ui/dialogCustom';
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';
import ImageUploadForm from '../ImageUploadModal/ImageUploadForm';
import ImageUploadPresignedForm from '../ImageUploadPresignedModal/ImageUploadPresignedForm';
import useUploadImageFileQuery from '@/queries/projects/useUploadImageFileQuery';
import useUploadImageFolderFileQuery from '@/queries/projects/useUploadImageFolderFileQuery';
import useUploadImageZipQuery from '@/queries/projects/useUploadImageZipQuery';
import useUploadImageFolderQuery from '@/queries/projects/useUploadImageFolderQuery';
export default function WorkspaceDropdownMenu({
projectId,
@ -31,6 +32,19 @@ export default function WorkspaceDropdownMenu({
const [isOpenUploadFolder, setIsOpenUploadFolder] = React.useState<boolean>(false);
const [isOpenUploadZip, setIsOpenUploadZip] = React.useState<boolean>(false);
const uploadImageZipMutation = useUploadImageZipQuery();
const uploadImageFolderFileMutation = useUploadImageFolderFileQuery();
const uploadImageFileMutation = useUploadImageFileQuery();
const uploadImageFolderMutation = useUploadImageFolderQuery();
const handleCloseUploadFile = () => setIsOpenUploadFile(false);
const handleCloseUploadFolderFile = () => setIsOpenUploadFolderFile(false);
const handleCloseUploadFolder = () => setIsOpenUploadFolder(false);
const handleCloseUploadZip = () => setIsOpenUploadZip(false);
const handleFileCount = (fileCount: number) => {
setFileCount(fileCount);
};
return (
<>
<DropdownMenu>
@ -38,13 +52,7 @@ export default function WorkspaceDropdownMenu({
<Menu size={16} />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuItem
onClick={() => {
console.log('프로젝트 이름 수정');
}}
>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => console.log('프로젝트 이름 수정')}> </DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setIsOpenUploadFile(true)}> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setIsOpenUploadPresigned(true)}>
@ -67,12 +75,16 @@ export default function WorkspaceDropdownMenu({
<DialogTrigger asChild></DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader title={fileCount > 0 ? `파일 업로드 (${fileCount})` : '파일 업로드'} />
<ImageUploadFileForm
onClose={() => setIsOpenUploadFile(false)}
<ImageUploadForm
onClose={handleCloseUploadFile}
onRefetch={onRefetch}
onFileCount={(fileCount: number) => setFileCount(fileCount)}
projectId={projectId}
folderId={folderId}
uploadImageZipMutation={uploadImageZipMutation}
uploadImageFolderFileMutation={uploadImageFolderFileMutation}
uploadImageFileMutation={uploadImageFileMutation}
uploadImageFolderMutation={uploadImageFolderMutation}
/>
</DialogContent>
</Dialog>
@ -103,11 +115,17 @@ export default function WorkspaceDropdownMenu({
<DialogTrigger asChild></DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader title="폴더 업로드 (파일 업로드 API 이용)" />
<ImageUploadFolderFileForm
onClose={() => setIsOpenUploadFolderFile(false)}
<ImageUploadForm
onClose={handleCloseUploadFolderFile}
onRefetch={onRefetch}
onFileCount={handleFileCount}
projectId={projectId}
folderId={folderId}
isFolderUpload={true}
uploadImageZipMutation={uploadImageZipMutation}
uploadImageFolderFileMutation={uploadImageFolderFileMutation}
uploadImageFileMutation={uploadImageFileMutation}
uploadImageFolderMutation={uploadImageFolderMutation}
/>
</DialogContent>
</Dialog>
@ -119,11 +137,17 @@ export default function WorkspaceDropdownMenu({
<DialogTrigger asChild></DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader title="폴더 업로드 (백엔드 구현 필요)" />
<ImageUploadFolderForm
onClose={() => setIsOpenUploadFolder(false)}
<ImageUploadForm
onClose={handleCloseUploadFolder}
onRefetch={onRefetch}
onFileCount={handleFileCount}
projectId={projectId}
folderId={folderId}
isFolderBackendUpload={true}
uploadImageZipMutation={uploadImageZipMutation}
uploadImageFolderFileMutation={uploadImageFolderFileMutation}
uploadImageFileMutation={uploadImageFileMutation}
uploadImageFolderMutation={uploadImageFolderMutation}
/>
</DialogContent>
</Dialog>
@ -135,11 +159,17 @@ export default function WorkspaceDropdownMenu({
<DialogTrigger asChild></DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader title="폴더 압축파일 업로드" />
<ImageUploadZipForm
onClose={() => setIsOpenUploadZip(false)}
<ImageUploadForm
onClose={handleCloseUploadZip}
onRefetch={onRefetch}
onFileCount={handleFileCount}
projectId={projectId}
folderId={folderId}
isZipUpload={true}
uploadImageZipMutation={uploadImageZipMutation}
uploadImageFolderFileMutation={uploadImageFolderFileMutation}
uploadImageFileMutation={uploadImageFileMutation}
uploadImageFolderMutation={uploadImageFolderMutation}
/>
</DialogContent>
</Dialog>

View File

@ -1,20 +1,10 @@
import { uploadImageFolderFile } from '@/api/imageApi';
import { useMutation } from '@tanstack/react-query';
import { UploadFolderParams } from '@/types/uploadTypes';
export default function useUploadImageFolderFileQuery() {
return useMutation({
mutationFn: ({
memberId,
projectId,
folderId,
files,
progressCallback,
}: {
memberId: number;
projectId: number;
folderId: number;
files: File[];
progressCallback: (progress: number) => void;
}) => uploadImageFolderFile(memberId, projectId, folderId, files, progressCallback),
mutationFn: ({ memberId, projectId, folderId, files, progressCallback }: UploadFolderParams) =>
uploadImageFolderFile(memberId, projectId, folderId, files, progressCallback),
});
}

View File

@ -1,20 +1,10 @@
import { uploadImageZip } from '@/api/imageApi';
import { useMutation } from '@tanstack/react-query';
import { UploadZipParams } from '@/types/uploadTypes';
export default function useUploadImageZipQuery() {
return useMutation({
mutationFn: ({
memberId,
projectId,
folderId,
file,
progressCallback,
}: {
memberId: number;
projectId: number;
folderId: number;
file: File;
progressCallback: (progress: number) => void;
}) => uploadImageZip(memberId, projectId, folderId, file, progressCallback),
mutationFn: ({ memberId, projectId, folderId, file, progressCallback }: UploadZipParams) =>
uploadImageZip(memberId, projectId, folderId, file, progressCallback),
});
}

View File

@ -11,3 +11,4 @@ export * from './projectTypes';
export * from './labelTypes';
export * from './fileTypes';
export * from './reportTypes';
export * from './uploadTypes';

View File

@ -0,0 +1,15 @@
export interface UploadZipParams {
memberId: number;
projectId: number;
folderId: number;
file: File;
progressCallback: (progress: number) => void;
}
export interface UploadFolderParams {
memberId: number;
projectId: number;
folderId: number;
files: File[];
progressCallback: (progress: number) => void;
}