'use client'; import React, { useState, useEffect, useCallback } from 'react'; import { Folder, File, FolderPlus, Upload, Trash2, Edit2, Download, Share2, Eye, EyeOff, Lock, Unlock, Globe, Shield, AlertCircle, MoreVertical, ChevronRight, ChevronDown, Search, Grid, List, Copy, X } from 'lucide-react'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, } from '@/components/ui/context-menu'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, } from '@/components/ui/dropdown-menu'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, } from '@/components/ui/dialog'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Badge } from '@/components/ui/badge'; import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList } from '@/components/ui/breadcrumb'; import { useToast } from '@/hooks/use-toast'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { cn } from '@/lib/utils'; import { useSession } from 'next-auth/react'; import { Dropzone, DropzoneDescription, DropzoneInput, DropzoneTitle, DropzoneUploadIcon, DropzoneZone, } from "@/components/ui/dropzone"; import { FileList, FileListAction, FileListDescription, FileListHeader, FileListIcon, FileListInfo, FileListItem, FileListName, FileListSize, } from "@/components/ui/file-list"; import { decryptWithServerAction } from '@/components/drm/drmUtils'; import { Progress } from '@/components/ui/progress'; interface FileItem { id: string; name: string; type: 'file' | 'folder'; size?: number; mimeType?: string; category: 'public' | 'restricted' | 'confidential' | 'internal'; externalAccessLevel?: 'view_only' | 'view_download' | 'full_access'; updatedAt: Date; permissions?: { canView: boolean; canDownload: boolean; canEdit: boolean; canDelete: boolean; }; downloadCount?: number; viewCount?: number; parentId?: string | null; children?: FileItem[]; } interface UploadingFile { file: File; progress: number; status: 'pending' | 'uploading' | 'processing' | 'completed' | 'error'; error?: string; } interface FileManagerProps { projectId: string; } // 카테고리별 아이콘과 색상 const categoryConfig = { public: { icon: Globe, color: 'text-green-500', label: '공개' }, restricted: { icon: Eye, color: 'text-yellow-500', label: '제한' }, confidential: { icon: Lock, color: 'text-red-500', label: '기밀' }, internal: { icon: Shield, color: 'text-blue-500', label: '내부' }, }; // Tree Item Component const TreeItem: React.FC<{ item: FileItem; level: number; expandedFolders: Set; selectedItems: Set; onToggleExpand: (id: string) => void; onSelectItem: (id: string) => void; onDoubleClick: (item: FileItem) => void; onDownload: (item: FileItem) => void; onDownloadFolder: (item: FileItem) => void; onDelete: (ids: string[]) => void; onShare: (item: FileItem) => void; onRename: (item: FileItem) => void; isInternalUser: boolean; }> = ({ item, level, expandedFolders, selectedItems, onToggleExpand, onSelectItem, onDoubleClick, onDownload, onDownloadFolder, onDelete, onShare, onRename, isInternalUser }) => { const hasChildren = item.type === 'folder' && item.children && item.children.length > 0; const isExpanded = expandedFolders.has(item.id); const isSelected = selectedItems.has(item.id); const CategoryIcon = categoryConfig[item.category].icon; const categoryColor = categoryConfig[item.category].color; const categoryLabel = categoryConfig[item.category].label; const formatFileSize = (bytes?: number) => { if (!bytes) return '-'; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`; }; return ( <>
onSelectItem(item.id)} onDoubleClick={() => onDoubleClick(item)} >
{item.type === 'folder' && ( )} {item.type === 'file' && (
)}
{item.type === 'folder' ? ( ) : ( )} {item.name} {categoryLabel} {formatFileSize(item.size)} {new Date(item.updatedAt).toLocaleDateString()} {item.type === 'file' && item.permissions?.canDownload && ( onDownload(item)}> 다운로드 )} {item.type === 'folder' && ( onDownloadFolder(item)}> 폴더 전체 다운로드 )} {isInternalUser && ( <> onShare(item)}> 공유 {item.permissions?.canEdit && ( onRename(item)}> 이름 변경 )} )} {item.permissions?.canDelete && ( <> onDelete([item.id])} > 삭제 )}
{item.type === 'folder' && isExpanded && item.children && (
{item.children.map((child) => ( ))}
)} ); }; export function FileManager({ projectId }: FileManagerProps) { const { data: session } = useSession(); const [items, setItems] = useState([]); const [treeItems, setTreeItems] = useState([]); const [currentPath, setCurrentPath] = useState([]); const [currentParentId, setCurrentParentId] = useState(null); const [selectedItems, setSelectedItems] = useState>(new Set()); const [expandedFolders, setExpandedFolders] = useState>(new Set()); const [viewMode, setViewMode] = useState<'grid' | 'list'>('list'); const [searchQuery, setSearchQuery] = useState(''); const [loading, setLoading] = useState(false); // 업로드 상태 const [uploadDialogOpen, setUploadDialogOpen] = useState(false); const [uploadingFiles, setUploadingFiles] = useState([]); const [uploadCategory, setUploadCategory] = useState('confidential'); // 다이얼로그 상태 const [folderDialogOpen, setFolderDialogOpen] = useState(false); const [shareDialogOpen, setShareDialogOpen] = useState(false); const [permissionDialogOpen, setPermissionDialogOpen] = useState(false); const [renameDialogOpen, setRenameDialogOpen] = useState(false); // 다이얼로그 데이터 const [dialogValue, setDialogValue] = useState(''); const [selectedCategory, setSelectedCategory] = useState('confidential'); const [selectedFile, setSelectedFile] = useState(null); const [shareSettings, setShareSettings] = useState({ accessLevel: 'view_only', password: '', expiresAt: '', maxDownloads: '', }); const { toast } = useToast(); // 사용자가 내부 사용자인지 확인 const isInternalUser = session?.user?.domain !== 'partners'; // 트리 구조 생성 함수 const buildTree = (flatItems: FileItem[]): FileItem[] => { const itemMap = new Map(); const rootItems: FileItem[] = []; // 모든 아이템을 맵에 저장 (children 초기화) flatItems.forEach(item => { itemMap.set(item.id, { ...item, children: [] }); }); // 부모-자식 관계 설정 flatItems.forEach(item => { const mappedItem = itemMap.get(item.id)!; if (!item.parentId) { // parentId가 없으면 루트 아이템 rootItems.push(mappedItem); } else { // parentId가 있으면 부모의 children에 추가 const parent = itemMap.get(item.parentId); if (parent) { if (!parent.children) parent.children = []; parent.children.push(mappedItem); } else { // 부모를 찾을 수 없으면 루트로 처리 rootItems.push(mappedItem); } } }); return rootItems; }; // 파일 목록 가져오기 const fetchItems = useCallback(async () => { setLoading(true); try { const params = new URLSearchParams(); // 트리 뷰일 때는 전체 목록을 가져옴 if (viewMode === 'list') { params.append('viewMode', 'tree'); // 트리 뷰에서도 현재 경로 정보는 유지 (하이라이팅 등에 사용) if (currentParentId) params.append('currentParentId', currentParentId); } else { // 그리드 뷰일 때는 현재 폴더의 내용만 가져옴 if (currentParentId) params.append('parentId', currentParentId); } const response = await fetch(`/api/data-room/${projectId}?${params}`); if (!response.ok) throw new Error('Failed to fetch files'); const data = await response.json(); setItems(data); // 트리 구조 생성 if (viewMode === 'list') { const tree = buildTree(data); setTreeItems(tree); } } catch (error) { toast({ title: '오류', description: '파일을 불러오는데 실패했습니다.', variant: 'destructive', }); } finally { setLoading(false); } }, [projectId, currentParentId, viewMode, toast]); useEffect(() => { fetchItems(); }, [fetchItems]); // 폴더 생성 const createFolder = async () => { try { const response = await fetch(`/api/data-room/${projectId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: dialogValue, type: 'folder', category: selectedCategory, parentId: currentParentId, }), }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to create folder'); } await fetchItems(); setFolderDialogOpen(false); setDialogValue(''); toast({ title: '성공', description: '폴더가 생성되었습니다.', }); } catch (error: any) { toast({ title: '오류', description: error.message || '폴더 생성에 실패했습니다.', variant: 'destructive', }); } }; // 파일 업로드 처리 const handleFileUpload = async (files: FileList | File[]) => { const fileArray = Array.from(files); // 업로드 파일 목록 초기화 const newUploadingFiles: UploadingFile[] = fileArray.map(file => ({ file, progress: 0, status: 'pending' as const })); setUploadingFiles(newUploadingFiles); // 각 파일 업로드 처리 for (let i = 0; i < fileArray.length; i++) { const file = fileArray[i]; try { // 상태 업데이트: 업로드 중 setUploadingFiles(prev => prev.map((f, idx) => idx === i ? { ...f, status: 'uploading', progress: 20 } : f )); // DRM 복호화 setUploadingFiles(prev => prev.map((f, idx) => idx === i ? { ...f, status: 'processing', progress: 40 } : f )); const decryptedData = await decryptWithServerAction(file); // 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 (currentParentId) { formData.append('parentId', currentParentId); } // 업로드 진행률 업데이트 setUploadingFiles(prev => prev.map((f, idx) => idx === i ? { ...f, progress: 60 } : f )); // API 호출 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'); } // 성공 setUploadingFiles(prev => prev.map((f, idx) => idx === i ? { ...f, status: 'completed', progress: 100 } : f )); } catch (error: any) { // 실패 setUploadingFiles(prev => prev.map((f, idx) => idx === i ? { ...f, status: 'error', error: error.message || '업로드 실패' } : f )); } } // 모든 업로드 완료 후 목록 새로고침 await fetchItems(); // 성공한 파일이 있으면 토스트 표시 const successCount = newUploadingFiles.filter(f => f.status === 'completed').length; if (successCount > 0) { toast({ title: '업로드 완료', description: `${successCount}개 파일이 업로드되었습니다.`, }); } }; // 폴더 다운로드 const downloadFolder = async (folder: FileItem) => { if (folder.type !== 'folder') return; try { toast({ title: '권한 확인 중', description: '폴더 내 파일들의 다운로드 권한을 확인하고 있습니다...', }); // 폴더 다운로드 API 호출 const response = await fetch(`/api/data-room/${projectId}/download-folder/${folder.id}`, { method: 'GET', }); if (!response.ok) { const error = await response.json(); // 권한이 없는 파일이 있는 경우 상세 정보 제공 if (error.unauthorizedFiles) { toast({ title: '다운로드 권한 부족', description: `${error.unauthorizedFiles.length}개 파일에 대한 권한이 없습니다: ${error.unauthorizedFiles.join(', ')}`, variant: 'destructive', }); return; } throw new Error(error.error || '폴더 다운로드 실패'); } const blob = await response.blob(); const url = window.URL.createObjectURL(blob); // 폴더명을 파일명에 포함 const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const fileName = `${folder.name}_${timestamp}.zip`; const a = document.createElement('a'); a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); toast({ title: '다운로드 완료', description: `${folder.name} 폴더가 다운로드되었습니다.`, }); } catch (error: any) { toast({ title: '오류', description: error.message || '폴더 다운로드에 실패했습니다.', variant: 'destructive', }); } }; // 파일 공유 const shareFile = async () => { if (!selectedFile) return; try { const response = await fetch(`/api/data-room/${projectId}/share`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileId: selectedFile.id, ...shareSettings, }), }); if (!response.ok) { throw new Error('Failed to create share link'); } const data = await response.json(); // 공유 링크 복사 await navigator.clipboard.writeText(data.shareUrl); toast({ title: '공유 링크 생성됨', description: '링크가 클립보드에 복사되었습니다.', }); setShareDialogOpen(false); setSelectedFile(null); } catch (error) { toast({ title: '오류', description: '공유 링크 생성에 실패했습니다.', variant: 'destructive', }); } }; // 다중 파일 다운로드 const downloadMultipleFiles = async (itemIds: string[]) => { // 선택된 파일들 중 실제 파일만 필터링 (폴더 제외) const filesToDownload = items.filter(item => itemIds.includes(item.id) && item.type === 'file' && item.permissions?.canDownload ); if (filesToDownload.length === 0) { toast({ title: '알림', description: '다운로드 가능한 파일이 없습니다.', variant: 'default', }); return; } // 단일 파일인 경우 일반 다운로드 사용 if (filesToDownload.length === 1) { await downloadFile(filesToDownload[0]); return; } try { toast({ title: '다운로드 준비 중', description: `${filesToDownload.length}개 파일을 압축하고 있습니다...`, }); // 여러 파일 다운로드 API 호출 const response = await fetch(`/api/data-room/${projectId}/download-multiple`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileIds: filesToDownload.map(f => f.id) }) }); if (!response.ok) { throw new Error('다운로드 실패'); } const blob = await response.blob(); const url = window.URL.createObjectURL(blob); // 현재 날짜시간을 파일명에 포함 const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const fileName = `files_${timestamp}.zip`; const a = document.createElement('a'); a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); toast({ title: '다운로드 완료', description: `${filesToDownload.length}개 파일이 다운로드되었습니다.`, }); } catch (error) { console.error('다중 다운로드 오류:', error); // 실패 시 개별 다운로드 옵션 제공 toast({ title: '압축 다운로드 실패', description: '개별 다운로드를 시도하시겠습니까?', action: ( ), }); } }; // 파일 다운로드 const downloadFile = async (file: FileItem) => { try { const response = await fetch(`/api/data-room/${projectId}/${file.id}/download`); if (!response.ok) { throw new Error('Download failed'); } const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = file.name; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); } catch (error) { toast({ title: '오류', description: '다운로드에 실패했습니다.', variant: 'destructive', }); } }; // 파일 삭제 const deleteItems = async (itemIds: string[]) => { try { await Promise.all( itemIds.map(id => fetch(`/api/data-room/${projectId}/${id}`, { method: 'DELETE' }) ) ); await fetchItems(); setSelectedItems(new Set()); toast({ title: '성공', description: '선택한 항목이 삭제되었습니다.', }); } catch (error) { toast({ title: '오류', description: '삭제에 실패했습니다.', variant: 'destructive', }); } }; // 폴더 더블클릭 처리 const handleFolderOpen = (folder: FileItem) => { if (viewMode === 'grid') { setCurrentPath([...currentPath, folder.name]); setCurrentParentId(folder.id); } else { // 트리 뷰에서는 expand/collapse const newExpanded = new Set(expandedFolders); if (newExpanded.has(folder.id)) { newExpanded.delete(folder.id); } else { newExpanded.add(folder.id); } setExpandedFolders(newExpanded); } setSelectedItems(new Set()); }; // 폴더 확장 토글 const toggleFolderExpand = (folderId: string) => { const newExpanded = new Set(expandedFolders); if (newExpanded.has(folderId)) { newExpanded.delete(folderId); } else { newExpanded.add(folderId); } setExpandedFolders(newExpanded); }; // 아이템 선택 const toggleItemSelection = (itemId: string) => { const newSelected = new Set(selectedItems); if (newSelected.has(itemId)) { newSelected.delete(itemId); } else { newSelected.add(itemId); } setSelectedItems(newSelected); }; // 경로 탐색 const navigateToPath = (index: number) => { if (index === -1) { setCurrentPath([]); setCurrentParentId(null); } else { setCurrentPath(currentPath.slice(0, index + 1)); // parentId 업데이트 로직 필요 } }; // 필터링된 아이템 const filteredItems = items.filter(item => item.name.toLowerCase().includes(searchQuery.toLowerCase()) ); const filteredTreeItems = treeItems.filter(item => item.name.toLowerCase().includes(searchQuery.toLowerCase()) ); // 파일 크기 포맷 const formatFileSize = (bytes?: number) => { if (!bytes) return '-'; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`; }; return (
{/* 툴바 */}
{isInternalUser && ( <> )} {selectedItems.size > 0 && ( <> {/* 다중 다운로드 버튼 */} {items.filter(item => selectedItems.has(item.id) && item.type === 'file' && item.permissions?.canDownload ).length > 0 && ( )} {/* 삭제 버튼 */} {items.find(item => selectedItems.has(item.id))?.permissions?.canDelete && ( )} )} {!isInternalUser && ( 외부 사용자 )}
setSearchQuery(e.target.value)} />
{/* Breadcrumb */} navigateToPath(-1)}> Home {currentPath.map((path, index) => ( navigateToPath(index)}> {path} ))}
{/* 파일 목록 */} {loading ? (
로딩 중...
) : filteredItems.length === 0 ? (

비어있음

) : viewMode === 'grid' ? (
{filteredItems.map((item) => { const CategoryIcon = categoryConfig[item.category].icon; const categoryColor = categoryConfig[item.category].color; return (
toggleItemSelection(item.id)} onDoubleClick={() => { if (item.type === 'folder') { handleFolderOpen(item); } }} >
{item.type === 'folder' ? ( ) : ( )}
{item.name} {item.viewCount !== undefined && (
{item.viewCount} {item.downloadCount !== undefined && ( {item.downloadCount} )}
)}
{item.type === 'folder' && ( <> handleFolderOpen(item)}> 열기 downloadFolder(item)}> 폴더 전체 다운로드 )} {item.type === 'file' && item.permissions?.canDownload && ( downloadFile(item)}> 다운로드 )} {isInternalUser && ( <> 카테고리 변경 {Object.entries(categoryConfig).map(([key, config]) => ( {config.label} ))} { setSelectedFile(item); setShareDialogOpen(true); }} > 공유 {item.permissions?.canEdit && ( { setSelectedFile(item); setDialogValue(item.name); setRenameDialogOpen(true); }}> 이름 변경 )} )} {item.permissions?.canDelete && ( <> deleteItems([item.id])} > 삭제 )}
); })}
) : ( // Tree View
{filteredTreeItems.map((item) => ( { setSelectedFile(item); setShareDialogOpen(true); }} onRename={(item) => { setSelectedFile(item); setDialogValue(item.name); setRenameDialogOpen(true); }} isInternalUser={isInternalUser} /> ))}
)}
{/* 업로드 다이얼로그 */} 파일 업로드 파일을 드래그 앤 드롭하거나 클릭하여 선택하세요.
{/* 카테고리 선택 */}
{/* 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} >
파일을 드래그하거나 클릭하여 업로드 여러 파일을 동시에 업로드할 수 있습니다
{/* 업로드 중인 파일 목록 */} {uploadingFiles.length > 0 && ( 업로드 중인 파일 {uploadingFiles.map((uploadFile, index) => ( {uploadFile.file.name}
{uploadFile.file.size} {uploadFile.status === 'uploading' && 업로드 중...} {uploadFile.status === 'processing' && 처리 중...} {uploadFile.status === 'completed' && ( 완료 )} {uploadFile.status === 'error' && ( {uploadFile.error} )}
{(uploadFile.status === 'uploading' || uploadFile.status === 'processing') && ( )}
{uploadFile.status === 'error' && ( )}
))}
)}
{/* 폴더 생성 다이얼로그 */} 새 폴더 만들기 폴더 이름과 접근 권한 카테고리를 설정하세요.
setDialogValue(e.target.value)} placeholder="폴더 이름 입력" />
{/* 파일 공유 다이얼로그 */} 파일 공유 {selectedFile?.name}을(를) 공유합니다. 링크 공유 권한 설정
setShareSettings({...shareSettings, password: e.target.value})} placeholder="비밀번호 입력" />
setShareSettings({...shareSettings, expiresAt: e.target.value})} />
setShareSettings({...shareSettings, maxDownloads: e.target.value})} placeholder="무제한" />
); }