Feat: presinged url을 통해, 폴더 압축파일, 파일 모두 처리할 수 있는 컴포넌트 구현

This commit is contained in:
정현조 2024-10-06 19:56:55 +09:00
parent e12051d043
commit 6d10ab21f2
5 changed files with 446 additions and 1 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 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<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 { 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<HTMLInputElement>) => {
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<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
};
const handleDrop = async (event: React.DragEvent<HTMLDivElement>) => {
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 (
<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"
webkitdirectory={uploadType === 'folder' ? '' : undefined}
multiple={uploadType !== 'zip'}
accept={uploadType === 'zip' ? '.zip' : undefined}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
onChange={handleChange}
/>
{isDragging ? (
<p className="text-primary">
{uploadType === 'folder' ? '드래그한 폴더를 여기에 놓으세요' : '드래그한 파일을 여기에 놓으세요'}
</p>
) : (
<p className="text-gray-500">
{uploadType === 'folder'
? '폴더를 업로드하려면 여기를 클릭하거나 폴더를 드래그하여 여기에 놓으세요'
: uploadType === 'zip'
? 'ZIP 파일을 업로드하려면 여기를 클릭하거나 ZIP 파일을 드래그하여 여기에 놓으세요'
: '파일을 업로드하려면 여기를 클릭하거나 파일을 드래그하여 여기에 놓으세요'}
</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].path}</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

@ -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,

View File

@ -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<boolean>(false);
const [isOpenUploadFolder, setIsOpenUploadFolder] = React.useState<boolean>(false);
const [isOpenUploadZip, setIsOpenUploadZip] = React.useState<boolean>(false);
const [isOpenTestUpload, setIsOpenTestUpload] = React.useState<boolean>(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 (
<>
<DropdownMenu>
@ -65,9 +69,14 @@ export default function WorkspaceDropdownMenu({
( )
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setIsOpenUploadZip(true)}> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setIsOpenTestUpload(true)}>
(PresignedUrl )
</DropdownMenuItem>{' '}
{/* 새로운 메뉴 항목 추가 */}
</DropdownMenuContent>
</DropdownMenu>
{/* 기존 Dialogs */}
<Dialog
open={isOpenUploadFile}
onOpenChange={setIsOpenUploadFile}
@ -173,6 +182,25 @@ export default function WorkspaceDropdownMenu({
/>
</DialogContent>
</Dialog>
{/* 테스트 업로드 Dialog */}
<Dialog
open={isOpenTestUpload}
onOpenChange={setIsOpenTestUpload}
>
<DialogTrigger asChild></DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader title="테스트 업로드 (PresignedUrl 이용)" />
<ImagePreSignedForm
onClose={handleCloseTestUpload}
onRefetch={onRefetch}
onFileCount={(fileCount: number) => setFileCount(fileCount)}
projectId={projectId}
folderId={folderId}
uploadType="folder" // zip flie 가능
/>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -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 };
}

View File

@ -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([]);
}
});
}