From bea9853efe30c393b0d030bc552c1f5bbb838835 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 17 Oct 2025 08:09:35 +0000 Subject: (대표님) 데이터룸 관련 개발사항 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/file-manager/FileManager.tsx | 874 +++++++++++++++++++++++--------- 1 file changed, 626 insertions(+), 248 deletions(-) (limited to 'components/file-manager/FileManager.tsx') diff --git a/components/file-manager/FileManager.tsx b/components/file-manager/FileManager.tsx index f92f6b04..1af29e74 100644 --- a/components/file-manager/FileManager.tsx +++ b/components/file-manager/FileManager.tsx @@ -115,6 +115,7 @@ interface FileItem { viewCount?: number; parentId?: string | null; children?: FileItem[]; + filePath?: string; } interface UploadingFile { @@ -151,6 +152,7 @@ const TreeItem: React.FC<{ onDelete: (ids: string[]) => void; onShare: (item: FileItem) => void; onRename: (item: FileItem) => void; + onCategoryChange: (item: FileItem) => void; isInternalUser: boolean; }> = ({ item, @@ -166,6 +168,7 @@ const TreeItem: React.FC<{ onDelete, onShare, onRename, + onCategoryChange, isInternalUser }) => { const hasChildren = item.type === 'folder' && item.children && item.children.length > 0; @@ -184,113 +187,180 @@ const TreeItem: React.FC<{ return ( <> -
onSelectItem(item.id)} - onDoubleClick={() => onDoubleClick(item)} - > -
- {item.type === 'folder' && ( - )} - - )} - {item.type === 'file' && ( -
- )} -
+ {item.type === 'file' && ( +
+ )} +
- {item.type === 'folder' ? ( - - ) : ( - - )} - - {item.name} - - - - {categoryLabel} - - - - {formatFileSize(item.size)} - - - {new Date(item.updatedAt).toLocaleDateString()} - - - - - - - - {item.type === 'file' && ( - <> - onView(item)}> - - View - - {item.permissions?.canDownload && ( - onDownload(item)}> + {item.type === 'folder' ? ( + + ) : ( + + )} + + {item.name} + + + + {categoryLabel} + + + + {formatFileSize(item.size)} + + + {new Date(item.updatedAt).toLocaleDateString()} + + + + + + + + {item.type === 'file' && ( + <> + onView(item)}> + + View + + {item.permissions?.canDownload && ( + onDownload(item)}> + + Download + + )} + + )} + + {item.type === 'folder' && ( + onDownloadFolder(item)}> - Download + Download Folder )} - - )} - {item.type === 'folder' && ( - onDownloadFolder(item)}> + {isInternalUser && ( + <> + + {item.permissions?.canEdit && ( + onRename(item)}> + + Rename + + )} + onCategoryChange(item)}> + + Change Category + + + )} + + {item.permissions?.canDelete && ( + <> + + onDelete([item.id])} + > + + Delete + + + )} + + +
+ + + + {item.type === 'file' && ( + <> + onView(item)}> + + View + + {item.permissions?.canDownload && ( + onDownload(item)}> + + Download + + )} + + )} + + {item.type === 'folder' && ( + <> + onDoubleClick(item)}> + + {isExpanded ? 'Collapse' : 'Expand'} + + onDownloadFolder(item)}> Download Folder - - )} - - {isInternalUser && ( - <> + + + )} - {item.permissions?.canEdit && ( - onRename(item)}> - - Rename - - )} - - )} + {isInternalUser && ( + <> + + {item.permissions?.canEdit && ( + onRename(item)}> + + Rename + + )} + onCategoryChange(item)}> + + Change Category + + + )} - {item.permissions?.canDelete && ( - <> - - onDelete([item.id])} - > - - Delete - - - )} - - -
+ {item.permissions?.canDelete && ( + <> + + onDelete([item.id])} + > + + Delete + + + )} + + {item.type === 'folder' && isExpanded && item.children && (
@@ -310,6 +380,7 @@ const TreeItem: React.FC<{ onDelete={onDelete} onShare={onShare} onRename={onRename} + onCategoryChange={onCategoryChange} isInternalUser={isInternalUser} /> ))} @@ -331,12 +402,12 @@ export function FileManager({ projectId }: FileManagerProps) { const [searchQuery, setSearchQuery] = useState(''); const [loading, setLoading] = useState(false); - console.log(items, "items") - // Upload states const [uploadDialogOpen, setUploadDialogOpen] = useState(false); const [uploadingFiles, setUploadingFiles] = useState([]); + const [selectedFilesForUpload, setSelectedFilesForUpload] = useState([]); const [uploadCategory, setUploadCategory] = useState('confidential'); + const [preserveFolderStructure, setPreserveFolderStructure] = useState(false); // Dialog states const [folderDialogOpen, setFolderDialogOpen] = useState(false); @@ -345,6 +416,9 @@ export function FileManager({ projectId }: FileManagerProps) { const [renameDialogOpen, setRenameDialogOpen] = useState(false); const [viewerDialogOpen, setViewerDialogOpen] = useState(false); const [viewerFileUrl, setViewerFileUrl] = useState(null); + const [categoryDialogOpen, setCategoryDialogOpen] = useState(false); + const [applyToChildren, setApplyToChildren] = useState(false); + const [newCategory, setNewCategory] = useState('confidential'); // Dialog data const [dialogValue, setDialogValue] = useState(''); @@ -473,8 +547,8 @@ export function FileManager({ projectId }: FileManagerProps) { } }; - // Handle file upload - const handleFileUpload = async (files: FileList | File[]) => { + // Handle file upload - 실제 업로드 처리 + const handleFileUpload = async (files: File[]) => { const fileArray = Array.from(files); // Initialize uploading file list @@ -486,63 +560,64 @@ export function FileManager({ projectId }: FileManagerProps) { setUploadingFiles(newUploadingFiles); - // Process each file upload - for (let i = 0; i < fileArray.length; i++) { - const file = fileArray[i]; - - try { - // Update status: uploading - setUploadingFiles(prev => prev.map((f, idx) => - idx === i ? { ...f, status: 'uploading', progress: 20 } : f - )); - - // DRM decryption - setUploadingFiles(prev => prev.map((f, idx) => - idx === i ? { ...f, status: 'processing', progress: 40 } : f - )); - - const decryptedData = await decryptWithServerAction(file); - - // Create FormData - const formData = new FormData(); - const blob = new Blob([decryptedData], { type: file.type }); - formData.append('file', blob, file.name); - formData.append('category', uploadCategory); - formData.append('fileSize', file.size.toString()); // Pass file size - if (currentParentId) { - formData.append('parentId', currentParentId); - } - - // Update upload progress - setUploadingFiles(prev => prev.map((f, idx) => - idx === i ? { ...f, progress: 60 } : f - )); - - // API call - const response = await fetch(`/api/data-room/${projectId}/upload`, { - method: 'POST', - body: formData, - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Upload failed'); + // 폴더 구조 보존 옵션이 켜져있고 상대 경로가 있는 경우 + if (preserveFolderStructure && fileArray.some((file: any) => file.webkitRelativePath)) { + // 폴더 구조를 먼저 생성 + const folderMap = new Map(); // path -> folderId + + for (let i = 0; i < fileArray.length; i++) { + const file = fileArray[i]; + const relativePath = (file as any).webkitRelativePath; + + if (relativePath) { + const pathParts = relativePath.split('/'); + const folders = pathParts.slice(0, -1); // 파일명 제외 + + let currentFolderPath = ''; + let parentId = currentParentId; + + // 각 폴더를 순차적으로 생성 + for (const folderName of folders) { + currentFolderPath = currentFolderPath ? `${currentFolderPath}/${folderName}` : folderName; + + if (!folderMap.has(currentFolderPath)) { + // 폴더 생성 API 호출 + try { + const response = await fetch(`/api/data-room/${projectId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: folderName, + type: 'folder', + category: uploadCategory, + parentId: parentId, + }), + }); + + if (response.ok) { + const newFolder = await response.json(); + folderMap.set(currentFolderPath, newFolder.id); + parentId = newFolder.id; + } + } catch (error) { + console.error('Failed to create folder:', folderName); + } + } else { + parentId = folderMap.get(currentFolderPath) || null; + } + } + + // 폴더가 생성되었으면 해당 폴더에 파일 업로드 + await uploadSingleFile(file, i, parentId); + } else { + // 상대 경로가 없으면 일반 업로드 + await uploadSingleFile(file, i, currentParentId); } - - // Success - setUploadingFiles(prev => prev.map((f, idx) => - idx === i ? { ...f, status: 'completed', progress: 100 } : f - )); - - } catch (error: any) { - // Failure - setUploadingFiles(prev => prev.map((f, idx) => - idx === i ? { - ...f, - status: 'error', - error: error.message || 'Upload failed' - } : f - )); + } + } else { + // 일반 업로드 (폴더 구조 보존 없음) + for (let i = 0; i < fileArray.length; i++) { + await uploadSingleFile(fileArray[i], i, currentParentId); } } @@ -550,7 +625,7 @@ export function FileManager({ projectId }: FileManagerProps) { await fetchItems(); // Show toast if any files succeeded - const successCount = newUploadingFiles.filter(f => f.status === 'completed').length; + const successCount = uploadingFiles.filter(f => f.status === 'completed').length; if (successCount > 0) { toast({ title: 'Upload Complete', @@ -559,6 +634,72 @@ export function FileManager({ projectId }: FileManagerProps) { } }; + // 단일 파일 업로드 함수 + const uploadSingleFile = async (file: File, index: number, parentId: string | null) => { + try { + // Update status: uploading + setUploadingFiles(prev => prev.map((f, idx) => + idx === index ? { ...f, status: 'uploading', progress: 20 } : f + )); + + // DRM decryption + setUploadingFiles(prev => prev.map((f, idx) => + idx === index ? { ...f, status: 'processing', progress: 40 } : f + )); + + const decryptedData = await decryptWithServerAction(file); + + // Create FormData + const formData = new FormData(); + const blob = new Blob([decryptedData], { type: file.type }); + formData.append('file', blob, file.name); + formData.append('category', uploadCategory); + formData.append('fileSize', file.size.toString()); + if (parentId) { + formData.append('parentId', parentId); + } + + // Update upload progress + setUploadingFiles(prev => prev.map((f, idx) => + idx === index ? { ...f, progress: 60 } : f + )); + + // API call + const response = await fetch(`/api/data-room/${projectId}/upload`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Upload failed'); + } + + // Success + setUploadingFiles(prev => prev.map((f, idx) => + idx === index ? { ...f, status: 'completed', progress: 100 } : f + )); + + } catch (error: any) { + // Failure + setUploadingFiles(prev => prev.map((f, idx) => + idx === index ? { + ...f, + status: 'error', + error: error.message || 'Upload failed' + } : f + )); + } + }; + + // 업로드 시작 함수 + const startUpload = async () => { + if (selectedFilesForUpload.length === 0) return; + await handleFileUpload(selectedFilesForUpload); + // 업로드 완료 후 선택된 파일 초기화 + setSelectedFilesForUpload([]); + }; + // Download folder const downloadFolder = async (folder: FileItem) => { if (folder.type !== 'folder') return; @@ -620,14 +761,13 @@ export function FileManager({ projectId }: FileManagerProps) { } }; - // Download multiple files const downloadMultipleFiles = async (itemIds: string[]) => { // Filter only actual files (exclude folders) that can be downloaded const filesToDownload = items.filter(item => itemIds.includes(item.id) && item.type === 'file' && - item.permissions?.canDownload === 'true' + item.permissions?.canDownload ); if (filesToDownload.length === 0) { @@ -712,15 +852,9 @@ export function FileManager({ projectId }: FileManagerProps) { // View file with PDFTron const viewFile = async (file: FileItem) => { try { - - - - setViewerFileUrl(file.filePath); + setViewerFileUrl(file.filePath || ''); setSelectedFile(file); setViewerDialogOpen(true); - - - } catch (error) { toast({ title: 'Error', @@ -858,11 +992,6 @@ export function FileManager({ projectId }: FileManagerProps) { } }; - // Category change dialog states - const [categoryDialogOpen, setCategoryDialogOpen] = useState(false); - const [applyToChildren, setApplyToChildren] = useState(false); - const [newCategory, setNewCategory] = useState('confidential'); - // Handle folder double click const handleFolderOpen = (folder: FileItem) => { if (viewMode === 'grid') { @@ -952,7 +1081,7 @@ export function FileManager({ projectId }: FileManagerProps) { onClick={() => { // 현재 폴더의 카테고리를 기본값으로 설정 if (currentParentId) { - const currentFolder = items.find(item => item.parentId === currentParentId); + const currentFolder = items.find(item => item.id === currentParentId); if (currentFolder) { setUploadCategory(currentFolder.category); } @@ -972,7 +1101,7 @@ export function FileManager({ projectId }: FileManagerProps) { {items.filter(item => selectedItems.has(item.id) && item.type === 'file' && - item.permissions?.canDownload === 'true' + item.permissions?.canDownload ).length > 0 && (
- ) : filteredItems.length === 0 ? ( + ) : filteredItems.length === 0 && filteredTreeItems.length === 0 ? (

Empty

@@ -1081,6 +1210,8 @@ export function FileManager({ projectId }: FileManagerProps) { onDoubleClick={() => { if (item.type === 'folder') { handleFolderOpen(item); + } else { + viewFile(item); } }} > @@ -1114,7 +1245,72 @@ export function FileManager({ projectId }: FileManagerProps) {
- {/* ... ContextMenuContent는 동일 ... */} + + {item.type === 'file' && ( + <> + viewFile(item)}> + + View + + {item.permissions?.canDownload && ( + downloadFile(item)}> + + Download + + )} + + )} + + {item.type === 'folder' && ( + <> + handleFolderOpen(item)}> + + Open + + downloadFolder(item)}> + + Download Folder + + + )} + + {isInternalUser && ( + <> + + {item.permissions?.canEdit && ( + { + setSelectedFile(item); + setDialogValue(item.name); + setRenameDialogOpen(true); + }}> + + Rename + + )} + { + setSelectedFile(item); + setNewCategory(item.category); + setCategoryDialogOpen(true); + }}> + + Change Category + + + )} + + {item.permissions?.canDelete && ( + <> + + deleteItems([item.id])} + > + + Delete + + + )} + ); })} @@ -1145,6 +1341,11 @@ export function FileManager({ projectId }: FileManagerProps) { setDialogValue(item.name); setRenameDialogOpen(true); }} + onCategoryChange={(item) => { + setSelectedFile(item); + setNewCategory(item.category); + setCategoryDialogOpen(true); + }} isInternalUser={isInternalUser} /> ))} @@ -1154,13 +1355,20 @@ export function FileManager({ projectId }: FileManagerProps) { - {/* Upload Dialog */} - + {/* Upload Dialog - 수정된 부분 */} + { + setUploadDialogOpen(open); + if (!open) { + setSelectedFilesForUpload([]); + setUploadingFiles([]); + setPreserveFolderStructure(false); + } + }}> Upload Files - Drag and drop files or click to select. + Drag and drop files or click to select. You can also select entire folders. @@ -1176,15 +1384,12 @@ export function FileManager({ projectId }: FileManagerProps) { {Object.entries(categoryConfig) .filter(([key]) => { - // 현재 폴더가 있는 경우 if (currentParentId) { - const currentFolder = items.find(item => item.parentId === currentParentId); - // 현재 폴더가 public이 아니면 public 옵션 제외 + const currentFolder = items.find(item => item.id === currentParentId); if (currentFolder && currentFolder.category !== 'public') { return key !== 'public'; } } - // 루트 폴더이거나 현재 폴더가 public인 경우 모든 옵션 표시 return true; }) .map(([key, config]) => ( @@ -1197,57 +1402,223 @@ export function FileManager({ projectId }: FileManagerProps) { ))} - {/* 현재 폴더 정보 표시 (선택사항) */} - {currentParentId && (() => { - const currentFolder = items.find(item => item.parentId === currentParentId); - if (currentFolder && currentFolder.category !== 'public') { - return ( -

- - Current folder is {categoryConfig[currentFolder.category].label}. - Public uploads are not allowed. -

- ); - } - })()} - {/* Dropzone */} - { - handleFileUpload(acceptedFiles); - }} - accept={{ - 'application/pdf': ['.pdf'], - 'application/msword': ['.doc'], - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], - 'application/vnd.ms-excel': ['.xls'], - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], - 'application/vnd.ms-powerpoint': ['.ppt'], - 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'], - 'text/plain': ['.txt'], - 'text/csv': ['.csv'], - 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'], - 'application/zip': ['.zip'], - 'application/x-rar-compressed': ['.rar'], - 'application/x-7z-compressed': ['.7z'], - 'application/x-dwg': ['.dwg'], - 'application/x-dxf': ['.dxf'], - }} - multiple={true} - disabled={false} - > - - -
- - Drag files or click to upload - Multiple files can be uploaded simultaneously + {/* Preserve Folder Structure Option - 폴더가 선택된 경우만 표시 */} + {selectedFilesForUpload.some((file: any) => file.webkitRelativePath) && ( +
+ + +
+ )} + + {/* Dropzone - 파일이 선택되지 않았을 때만 표시 */} + {selectedFilesForUpload.length === 0 && uploadingFiles.length === 0 && ( +
{ + e.preventDefault(); + e.stopPropagation(); + e.currentTarget.classList.add('border-primary', 'bg-accent'); + }} + onDragLeave={(e) => { + e.preventDefault(); + e.stopPropagation(); + e.currentTarget.classList.remove('border-primary', 'bg-accent'); + }} + onDrop={async (e) => { + e.preventDefault(); + e.stopPropagation(); + e.currentTarget.classList.remove('border-primary', 'bg-accent'); + + const items = e.dataTransfer.items; + const files: File[] = []; + const filePromises: Promise[] = []; + + // 폴더 구조 감지를 위한 플래그 + let hasFolderStructure = false; + + // DataTransferItem을 통한 폴더 처리 + for (let i = 0; i < items.length; i++) { + const item = items[i]; + + if (item.kind === 'file') { + const entry = item.webkitGetAsEntry(); + + if (entry) { + filePromises.push( + new Promise(async (resolve) => { + const traverseFileTree = async (item: any, path: string = '') => { + if (item.isFile) { + await new Promise((fileResolve) => { + item.file((file: File) => { + // 파일에 상대 경로 추가 + Object.defineProperty(file, 'webkitRelativePath', { + value: path + file.name, + writable: false + }); + files.push(file); + fileResolve(); + }); + }); + } else if (item.isDirectory) { + hasFolderStructure = true; + const dirReader = item.createReader(); + await new Promise((dirResolve) => { + dirReader.readEntries(async (entries: any[]) => { + for (const entry of entries) { + await traverseFileTree(entry, path + item.name + '/'); + } + dirResolve(); + }); + }); + } + }; + + await traverseFileTree(entry); + resolve(); + }) + ); + } + } + } + + // 모든 파일 처리 완료 대기 + await Promise.all(filePromises); + + // 파일이 없으면 일반 파일 처리 (폴더가 아닌 경우) + if (files.length === 0) { + const droppedFiles = Array.from(e.dataTransfer.files); + if (droppedFiles.length > 0) { + setSelectedFilesForUpload(droppedFiles); + } + } else { + // 폴더 구조가 있으면 자동으로 옵션 활성화 + if (hasFolderStructure) { + setPreserveFolderStructure(true); + } + setSelectedFilesForUpload(files); + } + }} + > + { + const files = Array.from(e.target.files || []); + setSelectedFilesForUpload(files); + }} + /> + +
+

Drag folders or files here

+

+ Folders will maintain their structure +

- - + {/*
+ + +
*/} +
+ )} - {/* Uploading File List */} + {/* Selected Files List - 업로드 전 */} + {selectedFilesForUpload.length > 0 && uploadingFiles.length === 0 && ( +
+
+

+ Selected Files ({selectedFilesForUpload.length}) +

+ +
+
+ {selectedFilesForUpload.map((file, index) => { + const relativePath = (file as any).webkitRelativePath || file.name; + return ( +
+ +
+

+ {relativePath} +

+ + {formatFileSize(file.size)} + +
+ +
+ ); + })} +
+
+ )} + + {/* Uploading File List - 업로드 중 */} {uploadingFiles.length > 0 && (
@@ -1290,19 +1661,6 @@ export function FileManager({ projectId }: FileManagerProps) { )}
- {uploadFile.status === 'error' && ( - - )}
))}
@@ -1316,11 +1674,30 @@ export function FileManager({ projectId }: FileManagerProps) { variant="outline" onClick={() => { setUploadDialogOpen(false); + setSelectedFilesForUpload([]); setUploadingFiles([]); + setPreserveFolderStructure(false); }} > - Close + Cancel + {/* 파일이 선택되었고 업로드 중이 아닐 때만 Upload 버튼 표시 */} + {selectedFilesForUpload.length > 0 && uploadingFiles.length === 0 && ( + + )} + {/* 업로드 완료 후 Done 버튼 */} + {uploadingFiles.length > 0 && uploadingFiles.every(f => f.status === 'completed' || f.status === 'error') && ( + + )}
@@ -1375,7 +1752,6 @@ export function FileManager({ projectId }: FileManagerProps) {
- {/* Rename Dialog */} @@ -1429,13 +1805,13 @@ export function FileManager({ projectId }: FileManagerProps) { - {/* Category Change Dialog (for folders) */} + {/* Category Change Dialog */} Change Category - Changing category for {selectedFile?.name} folder. + Changing category for {selectedFile?.name}. @@ -1506,7 +1882,9 @@ export function FileManager({ projectId }: FileManagerProps) {