Merge branch 'fe/refactor/upload' into 'fe/develop'
Feat: presinged url을 통해, 폴더 압축파일, 파일 모두 처리할 수 있는 컴포넌트 구현, 수정 필요 See merge request s11-s-project/S11P21S002!296
This commit is contained in:
commit
32537bff02
78
frontend/package-lock.json
generated
78
frontend/package-lock.json
generated
@ -27,6 +27,7 @@
|
|||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"firebase": "^10.13.2",
|
"firebase": "^10.13.2",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"konva": "^9.3.14",
|
"konva": "^9.3.14",
|
||||||
"lucide-react": "^0.436.0",
|
"lucide-react": "^0.436.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@ -8576,6 +8577,11 @@
|
|||||||
"url": "https://opencollective.com/core-js"
|
"url": "https://opencollective.com/core-js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/core-util-is": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
|
||||||
|
},
|
||||||
"node_modules/cosmiconfig": {
|
"node_modules/cosmiconfig": {
|
||||||
"version": "8.3.6",
|
"version": "8.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
|
||||||
@ -10588,6 +10594,11 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immediate": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
||||||
@ -10639,7 +10650,6 @@
|
|||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/internmap": {
|
"node_modules/internmap": {
|
||||||
@ -10897,6 +10907,11 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/isarray": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
|
||||||
|
},
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
@ -11082,6 +11097,44 @@
|
|||||||
"graceful-fs": "^4.1.6"
|
"graceful-fs": "^4.1.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jszip": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||||
|
"dependencies": {
|
||||||
|
"lie": "~3.3.0",
|
||||||
|
"pako": "~1.0.2",
|
||||||
|
"readable-stream": "~2.3.6",
|
||||||
|
"setimmediate": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jszip/node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jszip/node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||||
|
},
|
||||||
|
"node_modules/jszip/node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@ -11153,6 +11206,14 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lie": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"immediate": "~3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lilconfig": {
|
"node_modules/lilconfig": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
|
||||||
@ -11996,6 +12057,11 @@
|
|||||||
"integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==",
|
"integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==",
|
||||||
"license": "BlueOak-1.0.0"
|
"license": "BlueOak-1.0.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@ -12545,6 +12611,11 @@
|
|||||||
"node": ">= 0.6.0"
|
"node": ">= 0.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process-nextick-args": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
|
||||||
|
},
|
||||||
"node_modules/prompts": {
|
"node_modules/prompts": {
|
||||||
"version": "2.4.2",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||||
@ -13732,6 +13803,11 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/setimmediate": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
|
||||||
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
@ -33,6 +33,7 @@
|
|||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"firebase": "^10.13.2",
|
"firebase": "^10.13.2",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"konva": "^9.3.14",
|
"konva": "^9.3.14",
|
||||||
"lucide-react": "^0.436.0",
|
"lucide-react": "^0.436.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
271
frontend/src/components/ImagePreSignedForm/index.tsx
Normal file
271
frontend/src/components/ImagePreSignedForm/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -85,13 +85,47 @@ export default function ImageUploadForm({
|
|||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
const handleDrop = async (event: React.DragEvent<HTMLDivElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
const droppedFiles = event.dataTransfer.files;
|
|
||||||
if (droppedFiles) {
|
const droppedItems = event.dataTransfer.items;
|
||||||
setFiles((prevFiles) => [...prevFiles, ...Array.from(droppedFiles)]);
|
|
||||||
|
if (droppedItems) {
|
||||||
|
const allFiles: File[] = [];
|
||||||
|
|
||||||
|
const traverseFileTree = async (item: FileSystemEntry) => {
|
||||||
|
if (item.isFile) {
|
||||||
|
const file = await new Promise<File>((resolve, reject) => {
|
||||||
|
(item as FileSystemFileEntry).file(resolve, reject);
|
||||||
|
});
|
||||||
|
allFiles.push(file);
|
||||||
|
} else if (item.isDirectory) {
|
||||||
|
const directoryReader = (item as FileSystemDirectoryEntry).createReader();
|
||||||
|
const readEntries = () =>
|
||||||
|
new Promise<FileSystemEntry[]>((resolve, reject) => {
|
||||||
|
directoryReader.readEntries(resolve, reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
let entries = await readEntries();
|
||||||
|
while (entries.length > 0) {
|
||||||
|
for (const entry of entries) {
|
||||||
|
await traverseFileTree(entry);
|
||||||
|
}
|
||||||
|
entries = await readEntries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < droppedItems.length; i++) {
|
||||||
|
const item = droppedItems[i].webkitGetAsEntry();
|
||||||
|
if (item) {
|
||||||
|
await traverseFileTree(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFiles((prevFiles) => [...prevFiles, ...allFiles]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@ import { Button } from '../ui/button';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import useAuthStore from '@/stores/useAuthStore';
|
import useAuthStore from '@/stores/useAuthStore';
|
||||||
import { CircleCheckBig, CircleDashed, CircleX, X } from 'lucide-react';
|
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({
|
export default function ImageUploadPresignedForm({
|
||||||
onClose,
|
onClose,
|
||||||
|
@ -14,6 +14,7 @@ import useUploadImageFileQuery from '@/queries/projects/useUploadImageFileQuery'
|
|||||||
import useUploadImageFolderFileQuery from '@/queries/projects/useUploadImageFolderFileQuery';
|
import useUploadImageFolderFileQuery from '@/queries/projects/useUploadImageFolderFileQuery';
|
||||||
import useUploadImageZipQuery from '@/queries/projects/useUploadImageZipQuery';
|
import useUploadImageZipQuery from '@/queries/projects/useUploadImageZipQuery';
|
||||||
import useUploadImageFolderQuery from '@/queries/projects/useUploadImageFolderQuery';
|
import useUploadImageFolderQuery from '@/queries/projects/useUploadImageFolderQuery';
|
||||||
|
import ImagePreSignedForm from '../ImagePreSignedForm';
|
||||||
|
|
||||||
export default function WorkspaceDropdownMenu({
|
export default function WorkspaceDropdownMenu({
|
||||||
projectId,
|
projectId,
|
||||||
@ -31,6 +32,7 @@ export default function WorkspaceDropdownMenu({
|
|||||||
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 [isOpenUploadZip, setIsOpenUploadZip] = React.useState<boolean>(false);
|
const [isOpenUploadZip, setIsOpenUploadZip] = React.useState<boolean>(false);
|
||||||
|
const [isOpenTestUpload, setIsOpenTestUpload] = React.useState<boolean>(false);
|
||||||
|
|
||||||
const uploadImageZipMutation = useUploadImageZipQuery();
|
const uploadImageZipMutation = useUploadImageZipQuery();
|
||||||
const uploadImageFolderFileMutation = useUploadImageFolderFileQuery();
|
const uploadImageFolderFileMutation = useUploadImageFolderFileQuery();
|
||||||
@ -41,10 +43,12 @@ export default function WorkspaceDropdownMenu({
|
|||||||
const handleCloseUploadFolderFile = () => setIsOpenUploadFolderFile(false);
|
const handleCloseUploadFolderFile = () => setIsOpenUploadFolderFile(false);
|
||||||
const handleCloseUploadFolder = () => setIsOpenUploadFolder(false);
|
const handleCloseUploadFolder = () => setIsOpenUploadFolder(false);
|
||||||
const handleCloseUploadZip = () => setIsOpenUploadZip(false);
|
const handleCloseUploadZip = () => setIsOpenUploadZip(false);
|
||||||
|
const handleCloseTestUpload = () => setIsOpenTestUpload(false);
|
||||||
|
|
||||||
const handleFileCount = (fileCount: number) => {
|
const handleFileCount = (fileCount: number) => {
|
||||||
setFileCount(fileCount);
|
setFileCount(fileCount);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@ -65,9 +69,14 @@ export default function WorkspaceDropdownMenu({
|
|||||||
폴더 업로드 (백엔드 구현 필요)
|
폴더 업로드 (백엔드 구현 필요)
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setIsOpenUploadZip(true)}>폴더 압축파일 업로드</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => setIsOpenUploadZip(true)}>폴더 압축파일 업로드</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setIsOpenTestUpload(true)}>
|
||||||
|
테스트 업로드 (PresignedUrl 이용)
|
||||||
|
</DropdownMenuItem>{' '}
|
||||||
|
{/* 새로운 메뉴 항목 추가 */}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* 기존 Dialogs */}
|
||||||
<Dialog
|
<Dialog
|
||||||
open={isOpenUploadFile}
|
open={isOpenUploadFile}
|
||||||
onOpenChange={setIsOpenUploadFile}
|
onOpenChange={setIsOpenUploadFile}
|
||||||
@ -173,6 +182,25 @@ export default function WorkspaceDropdownMenu({
|
|||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
68
frontend/src/hooks/useUploadFiles.ts
Normal file
68
frontend/src/hooks/useUploadFiles.ts
Normal 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 };
|
||||||
|
}
|
10
frontend/src/queries/images/useUploadImagePresignedQuery.ts
Normal file
10
frontend/src/queries/images/useUploadImagePresignedQuery.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { uploadImagePresigned } from '@/api/imageApi';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { UploadFolderParams } from '@/types/uploadTypes';
|
||||||
|
|
||||||
|
export default function useUploadImagePresignedQuery() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ memberId, projectId, folderId, files, progressCallback }: UploadFolderParams) =>
|
||||||
|
uploadImagePresigned(memberId, projectId, folderId, files, progressCallback),
|
||||||
|
});
|
||||||
|
}
|
@ -1,20 +0,0 @@
|
|||||||
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),
|
|
||||||
});
|
|
||||||
}
|
|
78
frontend/src/utils/fileUtils.ts
Normal file
78
frontend/src/utils/fileUtils.ts
Normal 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([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user