summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-25 03:28:27 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-25 03:28:27 +0000
commit4c2d4c235bd80368e31cae9c375e9a585f6a6844 (patch)
tree7fd1847e1e30ef2052281453bfb7a1c45ac6627a /components
parentf69e125f1a0b47bbc22e2784208bf829bcdd24f8 (diff)
(대표님) archiver 추가, 데이터룸구현
Diffstat (limited to 'components')
-rw-r--r--components/file-manager/FileManager.tsx1447
-rw-r--r--components/file-manager/SharedFileViewer.tsx411
-rw-r--r--components/project/ProjectDashboard.tsx476
-rw-r--r--components/project/ProjectHeader.tsx84
-rw-r--r--components/project/ProjectList.tsx463
-rw-r--r--components/project/ProjectNav.tsx149
-rw-r--r--components/project/ProjectSidebar.tsx318
7 files changed, 3348 insertions, 0 deletions
diff --git a/components/file-manager/FileManager.tsx b/components/file-manager/FileManager.tsx
new file mode 100644
index 00000000..483ef773
--- /dev/null
+++ b/components/file-manager/FileManager.tsx
@@ -0,0 +1,1447 @@
+'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<string>;
+ selectedItems: Set<string>;
+ 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 (
+ <>
+ <div
+ className={cn(
+ "flex items-center p-2 rounded-lg cursor-pointer transition-colors",
+ "hover:bg-accent",
+ isSelected && "bg-accent"
+ )}
+ style={{ paddingLeft: `${level * 24 + 8}px` }}
+ onClick={() => onSelectItem(item.id)}
+ onDoubleClick={() => onDoubleClick(item)}
+ >
+ <div className="flex items-center mr-2">
+ {item.type === 'folder' && (
+ <button
+ onClick={(e) => {
+ e.stopPropagation();
+ onToggleExpand(item.id);
+ }}
+ className="p-0.5 hover:bg-gray-200 rounded"
+ >
+ {isExpanded ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <ChevronRight className="h-4 w-4" />
+ )}
+ </button>
+ )}
+ {item.type === 'file' && (
+ <div className="w-5" />
+ )}
+ </div>
+
+ {item.type === 'folder' ? (
+ <Folder className="h-5 w-5 text-blue-500 mr-2" />
+ ) : (
+ <File className="h-5 w-5 text-gray-500 mr-2" />
+ )}
+
+ <span className="flex-1">{item.name}</span>
+
+ <Badge variant="outline" className="mr-2">
+ <CategoryIcon className={cn("h-3 w-3 mr-1", categoryColor)} />
+ {categoryLabel}
+ </Badge>
+
+ <span className="text-sm text-muted-foreground mr-4">
+ {formatFileSize(item.size)}
+ </span>
+ <span className="text-sm text-muted-foreground mr-2">
+ {new Date(item.updatedAt).toLocaleDateString()}
+ </span>
+
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" size="sm">
+ <MoreVertical className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent>
+ {item.type === 'file' && item.permissions?.canDownload && (
+ <DropdownMenuItem onClick={() => onDownload(item)}>
+ <Download className="h-4 w-4 mr-2" />
+ 다운로드
+ </DropdownMenuItem>
+ )}
+
+ {item.type === 'folder' && (
+ <DropdownMenuItem onClick={() => onDownloadFolder(item)}>
+ <Download className="h-4 w-4 mr-2" />
+ 폴더 전체 다운로드
+ </DropdownMenuItem>
+ )}
+
+ {isInternalUser && (
+ <>
+ <DropdownMenuItem onClick={() => onShare(item)}>
+ <Share2 className="h-4 w-4 mr-2" />
+ 공유
+ </DropdownMenuItem>
+
+ {item.permissions?.canEdit && (
+ <DropdownMenuItem onClick={() => onRename(item)}>
+ <Edit2 className="h-4 w-4 mr-2" />
+ 이름 변경
+ </DropdownMenuItem>
+ )}
+ </>
+ )}
+
+ {item.permissions?.canDelete && (
+ <>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ className="text-destructive"
+ onClick={() => onDelete([item.id])}
+ >
+ <Trash2 className="h-4 w-4 mr-2" />
+ 삭제
+ </DropdownMenuItem>
+ </>
+ )}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+
+ {item.type === 'folder' && isExpanded && item.children && (
+ <div>
+ {item.children.map((child) => (
+ <TreeItem
+ key={child.id}
+ item={child}
+ level={level + 1}
+ expandedFolders={expandedFolders}
+ selectedItems={selectedItems}
+ onToggleExpand={onToggleExpand}
+ onSelectItem={onSelectItem}
+ onDoubleClick={onDoubleClick}
+ onDownload={onDownload}
+ onDownloadFolder={onDownloadFolder}
+ onDelete={onDelete}
+ onShare={onShare}
+ onRename={onRename}
+ isInternalUser={isInternalUser}
+ />
+ ))}
+ </div>
+ )}
+ </>
+ );
+};
+
+export function FileManager({ projectId }: FileManagerProps) {
+ const { data: session } = useSession();
+ const [items, setItems] = useState<FileItem[]>([]);
+ const [treeItems, setTreeItems] = useState<FileItem[]>([]);
+ const [currentPath, setCurrentPath] = useState<string[]>([]);
+ const [currentParentId, setCurrentParentId] = useState<string | null>(null);
+ const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
+ const [expandedFolders, setExpandedFolders] = useState<Set<string>>(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<UploadingFile[]>([]);
+ const [uploadCategory, setUploadCategory] = useState<string>('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<string>('confidential');
+ const [selectedFile, setSelectedFile] = useState<FileItem | null>(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<string, FileItem>();
+ 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: (
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => {
+ // 개별 다운로드 실행
+ filesToDownload.forEach(async (file, index) => {
+ // 다운로드 간격을 두어 브라우저 부하 감소
+ setTimeout(() => downloadFile(file), index * 500);
+ });
+ }}
+ >
+ 개별 다운로드
+ </Button>
+ ),
+ });
+ }
+ };
+
+ // 파일 다운로드
+ 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 (
+ <div className="flex flex-col h-full">
+ {/* 툴바 */}
+ <div className="border-b p-4">
+ <div className="flex items-center justify-between mb-3">
+ <div className="flex items-center gap-2">
+ {isInternalUser && (
+ <>
+ <Button
+ size="sm"
+ onClick={() => setFolderDialogOpen(true)}
+ >
+ <FolderPlus className="h-4 w-4 mr-1" />
+ 새 폴더
+ </Button>
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => setUploadDialogOpen(true)}
+ >
+ <Upload className="h-4 w-4 mr-1" />
+ 업로드
+ </Button>
+ </>
+ )}
+
+ {selectedItems.size > 0 && (
+ <>
+ {/* 다중 다운로드 버튼 */}
+ {items.filter(item =>
+ selectedItems.has(item.id) &&
+ item.type === 'file' &&
+ item.permissions?.canDownload
+ ).length > 0 && (
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => downloadMultipleFiles(Array.from(selectedItems))}
+ >
+ <Download className="h-4 w-4 mr-1" />
+ 다운로드 ({items.filter(item =>
+ selectedItems.has(item.id) && item.type === 'file'
+ ).length})
+ </Button>
+ )}
+
+ {/* 삭제 버튼 */}
+ {items.find(item => selectedItems.has(item.id))?.permissions?.canDelete && (
+ <Button
+ size="sm"
+ variant="destructive"
+ onClick={() => deleteItems(Array.from(selectedItems))}
+ >
+ <Trash2 className="h-4 w-4 mr-1" />
+ 삭제 ({selectedItems.size})
+ </Button>
+ )}
+ </>
+ )}
+
+ {!isInternalUser && (
+ <Badge variant="secondary" className="ml-2">
+ <Shield className="h-3 w-3 mr-1" />
+ 외부 사용자
+ </Badge>
+ )}
+ </div>
+
+ <div className="flex items-center gap-2">
+ <div className="relative">
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="검색..."
+ className="pl-8 w-64"
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ />
+ </div>
+
+ <Button
+ size="sm"
+ variant="ghost"
+ onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
+ >
+ {viewMode === 'grid' ? <List className="h-4 w-4" /> : <Grid className="h-4 w-4" />}
+ </Button>
+ </div>
+ </div>
+
+ {/* Breadcrumb */}
+ <Breadcrumb>
+ <BreadcrumbList>
+ <BreadcrumbItem>
+ <BreadcrumbLink onClick={() => navigateToPath(-1)}>
+ Home
+ </BreadcrumbLink>
+ </BreadcrumbItem>
+ {currentPath.map((path, index) => (
+ <BreadcrumbItem key={index}>
+ <ChevronRight className="h-4 w-4" />
+ <BreadcrumbLink onClick={() => navigateToPath(index)}>
+ {path}
+ </BreadcrumbLink>
+ </BreadcrumbItem>
+ ))}
+ </BreadcrumbList>
+ </Breadcrumb>
+ </div>
+
+ {/* 파일 목록 */}
+ <ScrollArea className="flex-1 p-4">
+ {loading ? (
+ <div className="flex justify-center items-center h-64">
+ <div className="text-muted-foreground">로딩 중...</div>
+ </div>
+ ) : filteredItems.length === 0 ? (
+ <div className="flex flex-col items-center justify-center h-64">
+ <Folder className="h-12 w-12 text-muted-foreground mb-2" />
+ <p className="text-muted-foreground">비어있음</p>
+ </div>
+ ) : viewMode === 'grid' ? (
+ <div className="grid grid-cols-6 gap-4">
+ {filteredItems.map((item) => {
+ const CategoryIcon = categoryConfig[item.category].icon;
+ const categoryColor = categoryConfig[item.category].color;
+
+ return (
+ <ContextMenu key={item.id}>
+ <ContextMenuTrigger>
+ <div
+ className={cn(
+ "flex flex-col items-center p-3 rounded-lg cursor-pointer transition-colors",
+ "hover:bg-accent",
+ selectedItems.has(item.id) && "bg-accent"
+ )}
+ onClick={() => toggleItemSelection(item.id)}
+ onDoubleClick={() => {
+ if (item.type === 'folder') {
+ handleFolderOpen(item);
+ }
+ }}
+ >
+ <div className="relative">
+ {item.type === 'folder' ? (
+ <Folder className="h-12 w-12 text-blue-500" />
+ ) : (
+ <File className="h-12 w-12 text-gray-500" />
+ )}
+ <CategoryIcon className={cn("h-4 w-4 absolute -bottom-1 -right-1", categoryColor)} />
+ </div>
+
+ <span className="mt-2 text-sm text-center truncate w-full">
+ {item.name}
+ </span>
+
+ {item.viewCount !== undefined && (
+ <div className="flex items-center gap-2 mt-1">
+ <span className="text-xs text-muted-foreground flex items-center">
+ <Eye className="h-3 w-3 mr-1" />
+ {item.viewCount}
+ </span>
+ {item.downloadCount !== undefined && (
+ <span className="text-xs text-muted-foreground flex items-center">
+ <Download className="h-3 w-3 mr-1" />
+ {item.downloadCount}
+ </span>
+ )}
+ </div>
+ )}
+ </div>
+ </ContextMenuTrigger>
+
+ <ContextMenuContent>
+ {item.type === 'folder' && (
+ <>
+ <ContextMenuItem onClick={() => handleFolderOpen(item)}>
+ 열기
+ </ContextMenuItem>
+ <ContextMenuItem onClick={() => downloadFolder(item)}>
+ <Download className="h-4 w-4 mr-2" />
+ 폴더 전체 다운로드
+ </ContextMenuItem>
+ </>
+ )}
+
+ {item.type === 'file' && item.permissions?.canDownload && (
+ <ContextMenuItem onClick={() => downloadFile(item)}>
+ <Download className="h-4 w-4 mr-2" />
+ 다운로드
+ </ContextMenuItem>
+ )}
+
+ {isInternalUser && (
+ <>
+ <ContextMenuSeparator />
+ <ContextMenuSub>
+ <ContextMenuSubTrigger>
+ <Shield className="h-4 w-4 mr-2" />
+ 카테고리 변경
+ </ContextMenuSubTrigger>
+ <ContextMenuSubContent>
+ {Object.entries(categoryConfig).map(([key, config]) => (
+ <ContextMenuItem key={key}>
+ <config.icon className={cn("h-4 w-4 mr-2", config.color)} />
+ {config.label}
+ </ContextMenuItem>
+ ))}
+ </ContextMenuSubContent>
+ </ContextMenuSub>
+
+ <ContextMenuItem
+ onClick={() => {
+ setSelectedFile(item);
+ setShareDialogOpen(true);
+ }}
+ >
+ <Share2 className="h-4 w-4 mr-2" />
+ 공유
+ </ContextMenuItem>
+
+ {item.permissions?.canEdit && (
+ <ContextMenuItem onClick={() => {
+ setSelectedFile(item);
+ setDialogValue(item.name);
+ setRenameDialogOpen(true);
+ }}>
+ <Edit2 className="h-4 w-4 mr-2" />
+ 이름 변경
+ </ContextMenuItem>
+ )}
+ </>
+ )}
+
+ {item.permissions?.canDelete && (
+ <>
+ <ContextMenuSeparator />
+ <ContextMenuItem
+ className="text-destructive"
+ onClick={() => deleteItems([item.id])}
+ >
+ <Trash2 className="h-4 w-4 mr-2" />
+ 삭제
+ </ContextMenuItem>
+ </>
+ )}
+ </ContextMenuContent>
+ </ContextMenu>
+ );
+ })}
+ </div>
+ ) : (
+ // Tree View
+ <div className="space-y-1">
+ {filteredTreeItems.map((item) => (
+ <TreeItem
+ key={item.id}
+ item={item}
+ level={0}
+ expandedFolders={expandedFolders}
+ selectedItems={selectedItems}
+ onToggleExpand={toggleFolderExpand}
+ onSelectItem={toggleItemSelection}
+ onDoubleClick={handleFolderOpen}
+ onDownload={downloadFile}
+ onDownloadFolder={downloadFolder}
+ onDelete={deleteItems}
+ onShare={(item) => {
+ setSelectedFile(item);
+ setShareDialogOpen(true);
+ }}
+ onRename={(item) => {
+ setSelectedFile(item);
+ setDialogValue(item.name);
+ setRenameDialogOpen(true);
+ }}
+ isInternalUser={isInternalUser}
+ />
+ ))}
+ </div>
+ )}
+ </ScrollArea>
+
+ {/* 업로드 다이얼로그 */}
+ <Dialog open={uploadDialogOpen} onOpenChange={setUploadDialogOpen}>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle>파일 업로드</DialogTitle>
+ <DialogDescription>
+ 파일을 드래그 앤 드롭하거나 클릭하여 선택하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 카테고리 선택 */}
+ <div>
+ <Label htmlFor="upload-category">카테고리</Label>
+ <Select value={uploadCategory} onValueChange={setUploadCategory}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {Object.entries(categoryConfig).map(([key, config]) => (
+ <SelectItem key={key} value={key}>
+ <div className="flex items-center">
+ <config.icon className={cn("h-4 w-4 mr-2", config.color)} />
+ <span>{config.label}</span>
+ </div>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* Dropzone */}
+ <Dropzone
+ onDrop={(acceptedFiles: File[]) => {
+ 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}
+ >
+ <DropzoneZone className="h-48 border-2 border-dashed border-gray-300 rounded-lg">
+ <DropzoneInput />
+ <div className="flex flex-col items-center justify-center h-full">
+ <DropzoneUploadIcon className="h-12 w-12 text-muted-foreground mb-4" />
+ <DropzoneTitle>파일을 드래그하거나 클릭하여 업로드</DropzoneTitle>
+ <DropzoneDescription>여러 파일을 동시에 업로드할 수 있습니다</DropzoneDescription>
+ </div>
+ </DropzoneZone>
+ </Dropzone>
+
+ {/* 업로드 중인 파일 목록 */}
+ {uploadingFiles.length > 0 && (
+ <FileList>
+ <FileListHeader>업로드 중인 파일</FileListHeader>
+ {uploadingFiles.map((uploadFile, index) => (
+ <FileListItem key={index}>
+ <FileListIcon>
+ <File className="h-4 w-4" />
+ </FileListIcon>
+ <FileListInfo>
+ <FileListName>{uploadFile.file.name}</FileListName>
+ <FileListDescription>
+ <div className="flex items-center gap-2">
+ <FileListSize>{uploadFile.file.size}</FileListSize>
+ {uploadFile.status === 'uploading' && <span>업로드 중...</span>}
+ {uploadFile.status === 'processing' && <span>처리 중...</span>}
+ {uploadFile.status === 'completed' && (
+ <span className="text-green-600">완료</span>
+ )}
+ {uploadFile.status === 'error' && (
+ <span className="text-red-600">{uploadFile.error}</span>
+ )}
+ </div>
+ {(uploadFile.status === 'uploading' || uploadFile.status === 'processing') && (
+ <Progress value={uploadFile.progress} className="h-1 mt-1" />
+ )}
+ </FileListDescription>
+ </FileListInfo>
+ <FileListAction>
+ {uploadFile.status === 'error' && (
+ <Button
+ size="sm"
+ variant="ghost"
+ onClick={() => {
+ setUploadingFiles(prev =>
+ prev.filter((_, i) => i !== index)
+ );
+ }}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </FileListAction>
+ </FileListItem>
+ ))}
+ </FileList>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setUploadDialogOpen(false);
+ setUploadingFiles([]);
+ }}
+ >
+ 닫기
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* 폴더 생성 다이얼로그 */}
+ <Dialog open={folderDialogOpen} onOpenChange={setFolderDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>새 폴더 만들기</DialogTitle>
+ <DialogDescription>
+ 폴더 이름과 접근 권한 카테고리를 설정하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div>
+ <Label htmlFor="folder-name">폴더 이름</Label>
+ <Input
+ id="folder-name"
+ value={dialogValue}
+ onChange={(e) => setDialogValue(e.target.value)}
+ placeholder="폴더 이름 입력"
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="folder-category">카테고리</Label>
+ <Select value={selectedCategory} onValueChange={setSelectedCategory}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {Object.entries(categoryConfig).map(([key, config]) => (
+ <SelectItem key={key} value={key}>
+ <div className="flex items-center">
+ <config.icon className={cn("h-4 w-4 mr-2", config.color)} />
+ <span>{config.label}</span>
+ </div>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setFolderDialogOpen(false)}>
+ 취소
+ </Button>
+ <Button onClick={createFolder}>생성</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* 파일 공유 다이얼로그 */}
+ <Dialog open={shareDialogOpen} onOpenChange={setShareDialogOpen}>
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <DialogTitle>파일 공유</DialogTitle>
+ <DialogDescription>
+ {selectedFile?.name}을(를) 공유합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Tabs defaultValue="link" className="w-full">
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="link">링크 공유</TabsTrigger>
+ <TabsTrigger value="permission">권한 설정</TabsTrigger>
+ </TabsList>
+
+ <TabsContent value="link" className="space-y-4">
+ <div>
+ <Label htmlFor="access-level">접근 레벨</Label>
+ <Select
+ value={shareSettings.accessLevel}
+ onValueChange={(value) => setShareSettings({...shareSettings, accessLevel: value})}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="view_only">
+ <div className="flex items-center">
+ <Eye className="h-4 w-4 mr-2" />
+ 보기만 가능
+ </div>
+ </SelectItem>
+ <SelectItem value="view_download">
+ <div className="flex items-center">
+ <Download className="h-4 w-4 mr-2" />
+ 보기 + 다운로드
+ </div>
+ </SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <Label htmlFor="password">비밀번호 (선택)</Label>
+ <Input
+ id="password"
+ type="password"
+ value={shareSettings.password}
+ onChange={(e) => setShareSettings({...shareSettings, password: e.target.value})}
+ placeholder="비밀번호 입력"
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="expires">만료일 (선택)</Label>
+ <Input
+ id="expires"
+ type="datetime-local"
+ value={shareSettings.expiresAt}
+ onChange={(e) => setShareSettings({...shareSettings, expiresAt: e.target.value})}
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="max-downloads">최대 다운로드 횟수 (선택)</Label>
+ <Input
+ id="max-downloads"
+ type="number"
+ value={shareSettings.maxDownloads}
+ onChange={(e) => setShareSettings({...shareSettings, maxDownloads: e.target.value})}
+ placeholder="무제한"
+ />
+ </div>
+ </TabsContent>
+
+ <TabsContent value="permission" className="space-y-4">
+ <div>
+ <Label htmlFor="target-domain">대상 도메인</Label>
+ <Select>
+ <SelectTrigger>
+ <SelectValue placeholder="도메인 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="partners">파트너</SelectItem>
+ <SelectItem value="internal">내부</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="space-y-2">
+ <Label>권한</Label>
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <Label htmlFor="can-view" className="text-sm font-normal">보기</Label>
+ <Switch id="can-view" defaultChecked />
+ </div>
+ <div className="flex items-center justify-between">
+ <Label htmlFor="can-download" className="text-sm font-normal">다운로드</Label>
+ <Switch id="can-download" />
+ </div>
+ <div className="flex items-center justify-between">
+ <Label htmlFor="can-edit" className="text-sm font-normal">수정</Label>
+ <Switch id="can-edit" />
+ </div>
+ <div className="flex items-center justify-between">
+ <Label htmlFor="can-share" className="text-sm font-normal">공유</Label>
+ <Switch id="can-share" />
+ </div>
+ </div>
+ </div>
+ </TabsContent>
+ </Tabs>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setShareDialogOpen(false)}>
+ 취소
+ </Button>
+ <Button onClick={shareFile}>
+ <Share2 className="h-4 w-4 mr-2" />
+ 공유 링크 생성
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/components/file-manager/SharedFileViewer.tsx b/components/file-manager/SharedFileViewer.tsx
new file mode 100644
index 00000000..a6e4eef5
--- /dev/null
+++ b/components/file-manager/SharedFileViewer.tsx
@@ -0,0 +1,411 @@
+// components/file-manager/SharedFileViewer.tsx
+'use client';
+
+import { useState, useEffect } from 'react';
+import {
+ Download,
+ Eye,
+ EyeOff,
+ FileText,
+ Image,
+ Film,
+ Music,
+ Archive,
+ Code,
+ File,
+ Lock,
+ AlertCircle,
+ Calendar,
+ Clock,
+ User
+} from 'lucide-react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import { Badge } from '@/components/ui/badge';
+import { Separator } from '@/components/ui/separator';
+import { useToast } from '@/hooks/use-toast';
+import { cn } from '@/lib/utils';
+
+interface SharedFile {
+ id: string;
+ name: string;
+ type: 'file' | 'folder';
+ size: number;
+ mimeType?: string;
+ category: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+interface SharedFileViewerProps {
+ token: string;
+}
+
+export function SharedFileViewer({ token }: SharedFileViewerProps) {
+ const [file, setFile] = useState<SharedFile | null>(null);
+ const [accessLevel, setAccessLevel] = useState<string>('');
+ const [passwordRequired, setPasswordRequired] = useState(false);
+ const [password, setPassword] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+ const [showContent, setShowContent] = useState(false);
+ const [downloading, setDownloading] = useState(false);
+
+ const { toast } = useToast();
+
+ useEffect(() => {
+ // 초기 접근 시도
+ checkAccess();
+ }, [token]);
+
+ const checkAccess = async (pwd?: string) => {
+ setLoading(true);
+ setError(null);
+
+ try {
+ const params = new URLSearchParams();
+ if (pwd) params.append('password', pwd);
+
+ const response = await fetch(`/api/shared/${token}?${params}`);
+ const data = await response.json();
+
+ if (!response.ok) {
+ if (data.error?.includes('비밀번호')) {
+ setPasswordRequired(true);
+ setError('비밀번호가 필요합니다');
+ } else if (data.error?.includes('만료')) {
+ setError('이 공유 링크는 만료되었습니다');
+ } else if (data.error?.includes('최대 다운로드')) {
+ setError('최대 다운로드 횟수를 초과했습니다');
+ } else {
+ setError(data.error || '파일에 접근할 수 없습니다');
+ }
+ return;
+ }
+
+ setFile(data.file);
+ setAccessLevel(data.accessLevel);
+ setShowContent(true);
+ setPasswordRequired(false);
+ } catch (err) {
+ setError('파일을 불러오는 중 오류가 발생했습니다');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handlePasswordSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ checkAccess(password);
+ };
+
+ const handleDownload = async () => {
+ if (!file || accessLevel !== 'view_download') return;
+
+ setDownloading(true);
+ try {
+ const response = await fetch(`/api/shared/${token}/download`, {
+ method: 'POST',
+ headers: password ? { 'X-Share-Password': password } : {},
+ });
+
+ if (!response.ok) {
+ throw new Error('다운로드 실패');
+ }
+
+ 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);
+
+ toast({
+ title: '다운로드 완료',
+ description: `${file.name} 파일이 다운로드되었습니다.`,
+ });
+ } catch (error) {
+ toast({
+ title: '다운로드 실패',
+ description: '파일 다운로드 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ });
+ } finally {
+ setDownloading(false);
+ }
+ };
+
+ const getFileIcon = (mimeType?: string, name?: string) => {
+ if (!mimeType && name) {
+ const ext = name.split('.').pop()?.toLowerCase();
+ if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext || '')) return Image;
+ if (['mp4', 'avi', 'mov', 'wmv'].includes(ext || '')) return Film;
+ if (['mp3', 'wav', 'flac'].includes(ext || '')) return Music;
+ if (['zip', 'rar', '7z', 'tar'].includes(ext || '')) return Archive;
+ if (['js', 'ts', 'py', 'java', 'cpp'].includes(ext || '')) return Code;
+ if (['pdf', 'doc', 'docx', 'txt'].includes(ext || '')) return FileText;
+ }
+
+ if (mimeType?.startsWith('image/')) return Image;
+ if (mimeType?.startsWith('video/')) return Film;
+ if (mimeType?.startsWith('audio/')) return Music;
+ if (mimeType?.includes('zip') || mimeType?.includes('compressed')) return Archive;
+ if (mimeType?.includes('pdf')) return FileText;
+
+ return File;
+ };
+
+ const formatFileSize = (bytes: number) => {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ };
+
+ if (loading) {
+ return (
+ <div className="min-h-screen flex items-center justify-center">
+ <div className="text-center">
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4" />
+ <p className="text-muted-foreground">파일 정보를 불러오는 중...</p>
+ </div>
+ </div>
+ );
+ }
+
+ if (error && !passwordRequired) {
+ return (
+ <div className="min-h-screen flex items-center justify-center p-4">
+ <Card className="max-w-md w-full">
+ <CardHeader>
+ <div className="h-12 w-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
+ <AlertCircle className="h-6 w-6 text-red-600" />
+ </div>
+ <CardTitle className="text-center">접근할 수 없습니다</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <Alert variant="destructive">
+ <AlertDescription>{error}</AlertDescription>
+ </Alert>
+ </CardContent>
+ </Card>
+ </div>
+ );
+ }
+
+ if (passwordRequired && !showContent) {
+ return (
+ <div className="min-h-screen flex items-center justify-center p-4">
+ <Card className="max-w-md w-full">
+ <CardHeader>
+ <div className="h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
+ <Lock className="h-6 w-6 text-blue-600" />
+ </div>
+ <CardTitle className="text-center">비밀번호 입력</CardTitle>
+ <CardDescription className="text-center">
+ 이 파일은 비밀번호로 보호되어 있습니다
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <form onSubmit={handlePasswordSubmit} className="space-y-4">
+ <div>
+ <Label htmlFor="password">비밀번호</Label>
+ <Input
+ id="password"
+ type="password"
+ value={password}
+ onChange={(e) => setPassword(e.target.value)}
+ placeholder="비밀번호를 입력하세요"
+ autoFocus
+ />
+ </div>
+ {error && (
+ <Alert variant="destructive">
+ <AlertDescription>{error}</AlertDescription>
+ </Alert>
+ )}
+ <Button type="submit" className="w-full">
+ <Lock className="h-4 w-4 mr-2" />
+ 확인
+ </Button>
+ </form>
+ </CardContent>
+ </Card>
+ </div>
+ );
+ }
+
+ if (!file) return null;
+
+ const FileIcon = getFileIcon(file.mimeType, file.name);
+
+ return (
+ <div className="min-h-screen bg-gray-50">
+ {/* 헤더 */}
+ <div className="bg-white border-b">
+ <div className="container mx-auto px-4 py-4">
+ <div className="flex items-center gap-3">
+ <div className="h-10 w-10 bg-blue-600 rounded-lg flex items-center justify-center">
+ <span className="text-white font-bold">FM</span>
+ </div>
+ <div>
+ <h1 className="text-lg font-semibold">공유된 파일</h1>
+ <p className="text-sm text-muted-foreground">File Manager Shared</p>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* 메인 컨텐츠 */}
+ <div className="container mx-auto px-4 py-8 max-w-4xl">
+ <Card>
+ <CardHeader>
+ <div className="flex items-start justify-between">
+ <div className="flex items-start gap-4">
+ <div className={cn(
+ "h-16 w-16 rounded-lg flex items-center justify-center",
+ "bg-gradient-to-br from-blue-50 to-blue-100"
+ )}>
+ <FileIcon className="h-8 w-8 text-blue-600" />
+ </div>
+ <div>
+ <CardTitle className="text-2xl">{file.name}</CardTitle>
+ <CardDescription className="mt-1">
+ {file.type === 'folder' ? '폴더' : formatFileSize(file.size)}
+ </CardDescription>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-2">
+ {accessLevel === 'view_only' && (
+ <Badge variant="secondary">
+ <Eye className="h-3 w-3 mr-1" />
+ 보기 전용
+ </Badge>
+ )}
+ {accessLevel === 'view_download' && (
+ <Badge variant="default">
+ <Download className="h-3 w-3 mr-1" />
+ 다운로드 가능
+ </Badge>
+ )}
+ </div>
+ </div>
+ </CardHeader>
+
+ <CardContent>
+ <div className="space-y-6">
+ {/* 파일 정보 */}
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-3">
+ <div>
+ <p className="text-sm text-muted-foreground mb-1">파일 유형</p>
+ <p className="font-medium">{file.mimeType || '알 수 없음'}</p>
+ </div>
+ <div>
+ <p className="text-sm text-muted-foreground mb-1">생성일</p>
+ <p className="font-medium flex items-center gap-1">
+ <Calendar className="h-4 w-4" />
+ {new Date(file.createdAt).toLocaleDateString()}
+ </p>
+ </div>
+ </div>
+
+ <div className="space-y-3">
+ <div>
+ <p className="text-sm text-muted-foreground mb-1">카테고리</p>
+ <Badge variant="outline">{file.category}</Badge>
+ </div>
+ <div>
+ <p className="text-sm text-muted-foreground mb-1">수정일</p>
+ <p className="font-medium flex items-center gap-1">
+ <Clock className="h-4 w-4" />
+ {new Date(file.updatedAt).toLocaleDateString()}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 미리보기 영역 (이미지인 경우) */}
+ {file.mimeType?.startsWith('image/') && accessLevel !== 'download_only' && (
+ <div className="bg-gray-50 rounded-lg p-4">
+ <p className="text-sm text-muted-foreground mb-3">미리보기</p>
+ <div className="bg-white rounded border p-4">
+ <img
+ src={`/api/shared/${token}/preview`}
+ alt={file.name}
+ className="max-w-full h-auto rounded"
+ />
+ </div>
+ </div>
+ )}
+
+ {/* 액션 버튼 */}
+ <div className="flex gap-3">
+ {accessLevel === 'view_download' && (
+ <Button
+ onClick={handleDownload}
+ disabled={downloading}
+ className="flex-1"
+ >
+ {downloading ? (
+ <>
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
+ 다운로드 중...
+ </>
+ ) : (
+ <>
+ <Download className="h-4 w-4 mr-2" />
+ 파일 다운로드
+ </>
+ )}
+ </Button>
+ )}
+
+ {accessLevel === 'view_only' && (
+ <Alert className="flex-1">
+ <Eye className="h-4 w-4" />
+ <AlertDescription>
+ 이 파일은 보기 전용입니다. 다운로드할 수 없습니다.
+ </AlertDescription>
+ </Alert>
+ )}
+ </div>
+
+ {/* 보안 안내 */}
+ <Alert>
+ <Lock className="h-4 w-4" />
+ <AlertDescription>
+ 이 링크는 보안을 위해 제한된 시간 동안만 유효합니다.
+ 필요한 경우 파일을 다운로드하여 보관하세요.
+ </AlertDescription>
+ </Alert>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 하단 정보 */}
+ <div className="mt-6 text-center text-sm text-muted-foreground">
+ <p>© 2024 File Manager. All rights reserved.</p>
+ <p className="mt-1">
+ 문제가 있으신가요?{' '}
+ <a href="/support" className="text-primary hover:underline">
+ 고객 지원
+ </a>
+ </p>
+ </div>
+ </div>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/components/project/ProjectDashboard.tsx b/components/project/ProjectDashboard.tsx
new file mode 100644
index 00000000..d9ec2e0c
--- /dev/null
+++ b/components/project/ProjectDashboard.tsx
@@ -0,0 +1,476 @@
+// components/project/ProjectDashboard.tsx
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import {
+ Crown,
+ Users,
+ Settings,
+ FolderOpen,
+ Shield,
+ UserPlus,
+ Trash2,
+ BarChart3,
+ Eye,
+ Download,
+ HardDrive,
+ UserCog,
+ Loader2
+} from 'lucide-react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { useToast } from '@/hooks/use-toast';
+import { useSession } from 'next-auth/react';
+
+interface ProjectDashboardProps {
+ projectId: string;
+}
+
+interface ProjectStats {
+ files: {
+ totalFiles: number;
+ totalSize: number;
+ publicFiles: number;
+ restrictedFiles: number;
+ confidentialFiles: number;
+ };
+ members: {
+ totalMembers: number;
+ admins: number;
+ editors: number;
+ viewers: number;
+ };
+ activity: {
+ views: number;
+ downloads: number;
+ uploads: number;
+ uniqueUsers: number;
+ };
+}
+
+export function ProjectDashboard({ projectId }: ProjectDashboardProps) {
+ const { data: session } = useSession();
+ const [isOwner, setIsOwner] = useState(false);
+ const [projectRole, setProjectRole] = useState<string>('viewer');
+ const [stats, setStats] = useState<ProjectStats | null>(null);
+ const [members, setMembers] = useState<any[]>([]);
+ const [loading, setLoading] = useState(true);
+
+ console.log(stats)
+
+ // 다이얼로그 상태
+ const [addMemberOpen, setAddMemberOpen] = useState(false);
+ const [transferOwnershipOpen, setTransferOwnershipOpen] = useState(false);
+ const [newMemberEmail, setNewMemberEmail] = useState('');
+ const [newMemberRole, setNewMemberRole] = useState('viewer');
+ const [newOwnerId, setNewOwnerId] = useState('');
+
+ const { toast } = useToast();
+
+ // 프로젝트 정보 및 권한 확인
+ useEffect(() => {
+ const fetchProjectData = async () => {
+ try {
+ // 권한 확인
+ const accessRes = await fetch(`/api/projects/${projectId}/access`);
+ const accessData = await accessRes.json();
+ setIsOwner(accessData.isOwner);
+ setProjectRole(accessData.role);
+
+ // Owner인 경우 통계 가져오기
+ if (accessData.isOwner) {
+ const statsRes = await fetch(`/api/projects/${projectId}/stats`);
+ const statsData = await statsRes.json();
+ setStats(statsData);
+ }
+
+ // 멤버 목록 가져오기
+ const membersRes = await fetch(`/api/projects/${projectId}/members`);
+ const membersData = await membersRes.json();
+ setMembers(membersData.member);
+
+ } catch (error) {
+ console.error('프로젝트 데이터 로드 실패:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchProjectData();
+ }, [projectId]);
+
+ // 멤버 추가
+ const handleAddMember = async () => {
+ try {
+ const response = await fetch(`/api/projects/${projectId}/members`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ email: newMemberEmail,
+ role: newMemberRole,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error('멤버 추가 실패');
+ }
+
+ toast({
+ title: '성공',
+ description: '새 멤버가 추가되었습니다.',
+ });
+
+ setAddMemberOpen(false);
+ // 멤버 목록 새로고침
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '멤버 추가에 실패했습니다.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ // 소유권 이전
+ const handleTransferOwnership = async () => {
+ try {
+ const response = await fetch(`/api/projects/${projectId}/transfer-ownership`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ newOwnerId: newOwnerId,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error('소유권 이전 실패');
+ }
+
+ toast({
+ title: '성공',
+ description: '프로젝트 소유권이 이전되었습니다.',
+ });
+
+ setTransferOwnershipOpen(false);
+ setIsOwner(false);
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '소유권 이전에 실패했습니다.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const formatBytes = (bytes: number) => {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ };
+
+ const roleConfig = {
+ owner: { label: 'Owner', icon: Crown, color: 'text-yellow-500' },
+ admin: { label: 'Admin', icon: Shield, color: 'text-blue-500' },
+ editor: { label: 'Editor', icon: FolderOpen, color: 'text-green-500' },
+ viewer: { label: 'Viewer', icon: Eye, color: 'text-gray-500' },
+ };
+
+ if (loading) {
+ return (
+ <div className="flex items-center justify-center min-h-[400px]">
+ <div className="text-center space-y-3">
+ <Loader2 className="h-8 w-8 animate-spin text-primary mx-auto" />
+ <p className="text-sm text-muted-foreground">프로젝트 정보를 불러오는 중...</p>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="p-6 space-y-6">
+ {/* 헤더 */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-3">
+ <h1 className="text-2xl font-bold">프로젝트 대시보드</h1>
+ <Badge variant="outline" className="flex items-center gap-1">
+ {roleConfig[projectRole as keyof typeof roleConfig].icon &&
+ React.createElement(roleConfig[projectRole as keyof typeof roleConfig].icon, {
+ className: `h-3 w-3 ${roleConfig[projectRole as keyof typeof roleConfig].color}`
+ })
+ }
+ {roleConfig[projectRole as keyof typeof roleConfig].label}
+ </Badge>
+ </div>
+
+ {isOwner && (
+ <div className="flex gap-2">
+ <Button onClick={() => setAddMemberOpen(true)}>
+ <UserPlus className="h-4 w-4 mr-2" />
+ 멤버 추가
+ </Button>
+ <Button variant="outline">
+ <Settings className="h-4 w-4 mr-2" />
+ 설정
+ </Button>
+ </div>
+ )}
+ </div>
+
+ {/* Owner 전용 통계 */}
+ {isOwner && stats && (
+ <div className="grid grid-cols-4 gap-4">
+ <Card>
+ <CardHeader className="pb-2">
+ <CardTitle className="text-sm font-medium">총 파일 수</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">{stats.storage.fileCount}</div>
+ <p className="text-xs text-muted-foreground mt-1">
+ {formatBytes(stats.storage.used)}
+ </p>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader className="pb-2">
+ <CardTitle className="text-sm font-medium">멤버</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">{stats.users.total}</div>
+ <div className="flex gap-2 mt-1">
+ <span className="text-xs text-muted-foreground">
+ 관리자 {stats.users.byRole.admins}
+ </span>
+ <span className="text-xs text-muted-foreground">
+ 편집자 {stats.users.byRole.editors}
+ </span>
+ </div>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader className="pb-2">
+ <CardTitle className="text-sm font-medium">조회수 (30일)</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">{stats.activity.views}</div>
+ <p className="text-xs text-muted-foreground mt-1">
+ 활성 사용자 {stats.users.active}명
+ </p>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader className="pb-2">
+ <CardTitle className="text-sm font-medium">다운로드 (30일)</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">{stats.activity.downloads}</div>
+ <p className="text-xs text-muted-foreground mt-1">
+ 업로드 {stats.activity.uploads}개
+ </p>
+ </CardContent>
+ </Card>
+ </div>
+ )}
+
+ {/* 탭 컨텐츠 */}
+ <Tabs defaultValue="members">
+ <TabsList>
+ <TabsTrigger value="members">멤버</TabsTrigger>
+ {isOwner && (
+ <>
+ <TabsTrigger value="permissions">권한 관리</TabsTrigger>
+ <TabsTrigger value="danger">위험 영역</TabsTrigger>
+ </>
+ )}
+ </TabsList>
+
+ <TabsContent value="members" className="mt-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>프로젝트 멤버</CardTitle>
+ <CardDescription>
+ 이 프로젝트에 접근할 수 있는 사용자 목록
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-3">
+ {members.map((member) => (
+ <div key={member.id} className="flex items-center justify-between p-3 border rounded-lg">
+ <div className="flex items-center gap-3">
+ <div className="h-10 w-10 bg-gray-100 rounded-full flex items-center justify-center">
+ {member.user.name?.charAt(0).toUpperCase()}
+ </div>
+ <div>
+ <p className="font-medium">{member.user.name}</p>
+ <p className="text-sm text-muted-foreground">{member.user.email}</p>
+ </div>
+ </div>
+ <Badge variant="secondary">
+ {roleConfig[member.role as keyof typeof roleConfig].icon &&
+ React.createElement(roleConfig[member.role as keyof typeof roleConfig].icon, {
+ className: `h-3 w-3 mr-1 ${roleConfig[member.role as keyof typeof roleConfig].color}`
+ })
+ }
+ {roleConfig[member.role as keyof typeof roleConfig].label}
+ </Badge>
+ </div>
+ ))}
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {isOwner && (
+ <TabsContent value="danger" className="mt-6">
+ <Card className="border-red-200">
+ <CardHeader>
+ <CardTitle className="text-red-600">위험 영역</CardTitle>
+ <CardDescription>
+ 이 작업들은 되돌릴 수 없습니다. 신중하게 진행하세요.
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="flex items-center justify-between p-4 border rounded-lg">
+ <div>
+ <h3 className="font-medium">소유권 이전</h3>
+ <p className="text-sm text-muted-foreground">
+ 프로젝트 소유권을 다른 멤버에게 이전합니다
+ </p>
+ </div>
+ <Button
+ variant="outline"
+ onClick={() => setTransferOwnershipOpen(true)}
+ >
+ <UserCog className="h-4 w-4 mr-2" />
+ 소유권 이전
+ </Button>
+ </div>
+
+ <div className="flex items-center justify-between p-4 border rounded-lg border-red-200">
+ <div>
+ <h3 className="font-medium text-red-600">프로젝트 삭제</h3>
+ <p className="text-sm text-muted-foreground">
+ 프로젝트와 모든 파일을 영구적으로 삭제합니다
+ </p>
+ </div>
+ <Button variant="destructive">
+ <Trash2 className="h-4 w-4 mr-2" />
+ 프로젝트 삭제
+ </Button>
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+ )}
+ </Tabs>
+
+ {/* 멤버 추가 다이얼로그 */}
+ <Dialog open={addMemberOpen} onOpenChange={setAddMemberOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>멤버 추가</DialogTitle>
+ <DialogDescription>
+ 프로젝트에 새 멤버를 추가합니다
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div>
+ <Label htmlFor="email">이메일</Label>
+ <Input
+ id="email"
+ type="email"
+ value={newMemberEmail}
+ onChange={(e) => setNewMemberEmail(e.target.value)}
+ placeholder="user@example.com"
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="role">역할</Label>
+ <Select value={newMemberRole} onValueChange={setNewMemberRole}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="viewer">Viewer - 읽기 전용</SelectItem>
+ <SelectItem value="editor">Editor - 파일 편집 가능</SelectItem>
+ <SelectItem value="admin">Admin - 프로젝트 관리</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setAddMemberOpen(false)}>
+ 취소
+ </Button>
+ <Button onClick={handleAddMember}>추가</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* 소유권 이전 다이얼로그 */}
+ <Dialog open={transferOwnershipOpen} onOpenChange={setTransferOwnershipOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>소유권 이전</DialogTitle>
+ <DialogDescription className="text-red-600">
+ 주의: 이 작업은 되돌릴 수 없습니다. 프로젝트의 모든 권한이 새 소유자에게 이전됩니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div>
+ <Label htmlFor="new-owner">새 소유자 선택</Label>
+ <Select value={newOwnerId} onValueChange={setNewOwnerId}>
+ <SelectTrigger>
+ <SelectValue placeholder="멤버 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {members
+ .filter(m => m.role !== 'owner')
+ .map(member => (
+ <SelectItem key={member.userId} value={member.userId.toString()}>
+ {member.user.name} ({member.user.email})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setTransferOwnershipOpen(false)}>
+ 취소
+ </Button>
+ <Button variant="destructive" onClick={handleTransferOwnership}>
+ 소유권 이전
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/components/project/ProjectHeader.tsx b/components/project/ProjectHeader.tsx
new file mode 100644
index 00000000..34a3f43e
--- /dev/null
+++ b/components/project/ProjectHeader.tsx
@@ -0,0 +1,84 @@
+// components/project/ProjectHeader.tsx
+'use client';
+
+import { useSession } from 'next-auth/react';
+import { Bell, Search, HelpCircle, User } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Badge } from '@/components/ui/badge';
+
+export function ProjectHeader() {
+ const { data: session } = useSession();
+
+ return (
+ <header className="border-b bg-white sticky top-0 z-50">
+ <div className="container mx-auto px-4">
+ <div className="flex items-center justify-between h-16">
+ {/* 로고 */}
+ <div className="flex items-center gap-6">
+ <div className="flex items-center gap-2">
+ <div className="h-8 w-8 bg-blue-600 rounded-lg flex items-center justify-center">
+ <span className="text-white font-bold">FM</span>
+ </div>
+ <span className="text-xl font-semibold">File Manager</span>
+ </div>
+ </div>
+
+ {/* 우측 메뉴 */}
+ <div className="flex items-center gap-3">
+ {/* 검색 */}
+ <Button variant="ghost" size="icon">
+ <Search className="h-5 w-5" />
+ </Button>
+
+ {/* 알림 */}
+ <Button variant="ghost" size="icon" className="relative">
+ <Bell className="h-5 w-5" />
+ <span className="absolute top-0 right-0 h-2 w-2 bg-red-500 rounded-full" />
+ </Button>
+
+ {/* 도움말 */}
+ <Button variant="ghost" size="icon">
+ <HelpCircle className="h-5 w-5" />
+ </Button>
+
+ {/* 사용자 메뉴 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" className="gap-2">
+ <div className="h-8 w-8 bg-gray-200 rounded-full flex items-center justify-center">
+ <User className="h-4 w-4" />
+ </div>
+ <span className="hidden md:inline">{session?.user?.name}</span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-56">
+ <DropdownMenuLabel>
+ <div>
+ <p className="font-medium">{session?.user?.name}</p>
+ <p className="text-sm text-muted-foreground">{session?.user?.email}</p>
+ </div>
+ </DropdownMenuLabel>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem>프로필</DropdownMenuItem>
+ <DropdownMenuItem>설정</DropdownMenuItem>
+ <DropdownMenuItem>팀 관리</DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem className="text-red-600">
+ 로그아웃
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ </div>
+ </div>
+ </header>
+ );
+} \ No newline at end of file
diff --git a/components/project/ProjectList.tsx b/components/project/ProjectList.tsx
new file mode 100644
index 00000000..4a4f7962
--- /dev/null
+++ b/components/project/ProjectList.tsx
@@ -0,0 +1,463 @@
+// components/project/ProjectList.tsx
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import { useForm } from 'react-hook-form';
+import {
+ Plus,
+ Folder,
+ Users,
+ Globe,
+ Lock,
+ Crown,
+ Calendar,
+ Search,
+ Filter,
+ Grid3x3,
+ List
+} from 'lucide-react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Input } from '@/components/ui/input';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Label } from '@/components/ui/label';
+import { Switch } from '@/components/ui/switch';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { useToast } from '@/hooks/use-toast';
+import { cn } from '@/lib/utils';
+
+interface Project {
+ id: string;
+ code: string;
+ name: string;
+ description?: string;
+ isPublic: boolean;
+ createdAt: string;
+ updatedAt: string;
+ role?: string;
+ memberCount?: number;
+ fileCount?: number;
+}
+
+interface ProjectFormData {
+ code: string;
+ name: string;
+ description?: string;
+ isPublic: boolean;
+}
+
+export function ProjectList() {
+ const [projects, setProjects] = useState<{
+ owned: Project[];
+ member: Project[];
+ public: Project[];
+ }>({ owned: [], member: [], public: [] });
+ const [searchQuery, setSearchQuery] = useState('');
+ const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
+ const [createDialogOpen, setCreateDialogOpen] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const router = useRouter();
+ const { toast } = useToast();
+
+ // React Hook Form 설정
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { errors, isValid },
+ watch,
+ setValue,
+ } = useForm<ProjectFormData>({
+ mode: 'onChange',
+ defaultValues: {
+ code: '',
+ name: '',
+ description: '',
+ isPublic: false,
+ },
+ });
+
+ const watchIsPublic = watch('isPublic');
+
+ useEffect(() => {
+ fetchProjects();
+ }, []);
+
+ const fetchProjects = async () => {
+ try {
+ const response = await fetch('/api/projects');
+ const data = await response.json();
+ setProjects(data);
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '프로젝트 목록을 불러올 수 없습니다.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const onSubmit = async (data: ProjectFormData) => {
+ setIsSubmitting(true);
+ try {
+ const response = await fetch('/api/projects', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data),
+ });
+
+ if (!response.ok) throw new Error('프로젝트 생성 실패');
+
+ const project = await response.json();
+
+ toast({
+ title: '성공',
+ description: '프로젝트가 생성되었습니다.',
+ });
+
+ setCreateDialogOpen(false);
+ reset();
+ fetchProjects();
+
+ // 생성된 프로젝트로 이동
+ router.push(`/evcp/data-room/${project.id}`);
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '프로젝트 생성에 실패했습니다.',
+ variant: 'destructive',
+ });
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleDialogClose = (open: boolean) => {
+ setCreateDialogOpen(open);
+ if (!open) {
+ reset();
+ }
+ };
+
+ const filteredProjects = {
+ owned: projects.owned?.filter(p =>
+ p.name.toLowerCase().includes(searchQuery.toLowerCase())
+ ),
+ member: projects.member?.filter(p =>
+ p.name.toLowerCase().includes(searchQuery.toLowerCase())
+ ),
+ public: projects.public?.filter(p =>
+ p.name.toLowerCase().includes(searchQuery.toLowerCase())
+ ),
+ };
+
+ const ProjectCard = ({ project, role }: { project: Project; role?: string }) => (
+ <Card
+ className="cursor-pointer hover:shadow-lg transition-shadow"
+ onClick={() => router.push(`/evcp/data-room/${project.id}/files`)}
+ >
+ <CardHeader>
+ <div className="flex items-start justify-between">
+ <div className="flex items-center gap-2">
+ <Folder className="h-5 w-5 text-blue-500" />
+ <CardTitle className="text-base">{project.code} {project.name}</CardTitle>
+ </div>
+ {role === 'owner' && (
+ <Crown className="h-4 w-4 text-yellow-500" />
+ )}
+ {project.isPublic ? (
+ <Globe className="h-4 w-4 text-green-500" />
+ ) : (
+ <Lock className="h-4 w-4 text-gray-500" />
+ )}
+ </div>
+ <CardDescription className="line-clamp-2">
+ {project.description || '설명이 없습니다'}
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div className="flex items-center justify-between text-sm text-muted-foreground">
+ <div className="flex items-center gap-3">
+ {project.memberCount && (
+ <span className="flex items-center gap-1">
+ <Users className="h-3 w-3" />
+ {project.memberCount}
+ </span>
+ )}
+ {project.fileCount !== undefined && (
+ <span className="flex items-center gap-1">
+ <Folder className="h-3 w-3" />
+ {project.fileCount}
+ </span>
+ )}
+ </div>
+ <span className="flex items-center gap-1">
+ <Calendar className="h-3 w-3" />
+ {new Date(project.updatedAt).toLocaleDateString()}
+ </span>
+ </div>
+ {role && (
+ <Badge variant="secondary" className="mt-2">
+ {role}
+ </Badge>
+ )}
+ </CardContent>
+ </Card>
+ );
+
+ return (
+ <>
+ {/* 헤더 */}
+ <div className="flex items-center justify-between mb-6">
+ <div>
+ <h1 className="text-3xl font-bold">프로젝트</h1>
+ <p className="text-muted-foreground mt-1">
+ 파일을 관리하고 팀과 협업하세요
+ </p>
+ </div>
+ {/* <Button onClick={() => setCreateDialogOpen(true)}>
+ <Plus className="h-4 w-4 mr-2" />
+ 새 프로젝트
+ </Button> */}
+ </div>
+
+ {/* 검색 및 필터 */}
+ <div className="flex items-center gap-3 mb-6">
+ <div className="relative flex-1 max-w-md">
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="프로젝트 검색..."
+ className="pl-9"
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ />
+ </div>
+ <Button
+ variant="outline"
+ size="icon"
+ onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
+ >
+ {viewMode === 'grid' ? <List className="h-4 w-4" /> : <Grid3x3 className="h-4 w-4" />}
+ </Button>
+ </div>
+
+ {/* 프로젝트 목록 */}
+ <Tabs defaultValue="owned" className="space-y-6">
+ <TabsList>
+ <TabsTrigger value="member">
+ 참여 프로젝트 ({filteredProjects.member?.length})
+ </TabsTrigger>
+ <TabsTrigger value="public">
+ 공개 프로젝트 ({filteredProjects.public?.length})
+ </TabsTrigger>
+ </TabsList>
+
+ <TabsContent value="member">
+ {filteredProjects.member?.length === 0 ? (
+ <div className="text-center py-12">
+ <Users className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
+ <p className="text-muted-foreground">참여 중인 프로젝트가 없습니다</p>
+ </div>
+ ) : viewMode === 'grid' ? (
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
+ {filteredProjects.member?.map(project => (
+ <ProjectCard key={project.id} project={project} role={project.role} />
+ ))}
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {filteredProjects.member?.map(project => (
+ <Card
+ key={project.id}
+ className="cursor-pointer hover:shadow transition-shadow"
+ onClick={() => router.push(`/evcp/data-room/${project.id}/files`)}
+ >
+ <CardContent className="flex items-center justify-between p-4">
+ <div className="flex items-center gap-3">
+ <Folder className="h-5 w-5 text-blue-500" />
+ <div>
+ <p className="font-medium">{project.name}</p>
+ <p className="text-sm text-muted-foreground">
+ {project.description || '설명이 없습니다'}
+ </p>
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ <Badge variant="secondary">{project.role}</Badge>
+ {project.isPublic ? (
+ <Globe className="h-4 w-4 text-green-500" />
+ ) : (
+ <Lock className="h-4 w-4 text-gray-500" />
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ )}
+ </TabsContent>
+
+ <TabsContent value="public">
+ {filteredProjects.public?.length === 0 ? (
+ <div className="text-center py-12">
+ <Globe className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
+ <p className="text-muted-foreground">공개 프로젝트가 없습니다</p>
+ </div>
+ ) : viewMode === 'grid' ? (
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
+ {filteredProjects.public?.map(project => (
+ <ProjectCard key={project.id} project={project} />
+ ))}
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {filteredProjects.public?.map(project => (
+ <Card
+ key={project.id}
+ className="cursor-pointer hover:shadow transition-shadow"
+ onClick={() => router.push(`/evcp/data-room/${project.id}/files`)}
+ >
+ <CardContent className="flex items-center justify-between p-4">
+ <div className="flex items-center gap-3">
+ <Globe className="h-5 w-5 text-green-500" />
+ <div>
+ <p className="font-medium">{project.name}</p>
+ <p className="text-sm text-muted-foreground">
+ {project.description || '설명이 없습니다'}
+ </p>
+ </div>
+ </div>
+ <Badge variant="outline">공개</Badge>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ )}
+ </TabsContent>
+ </Tabs>
+
+ {/* 프로젝트 생성 다이얼로그 */}
+ <Dialog open={createDialogOpen} onOpenChange={handleDialogClose}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>새 프로젝트 만들기</DialogTitle>
+ <DialogDescription>
+ 팀과 파일을 공유할 새 프로젝트를 생성합니다
+ </DialogDescription>
+ </DialogHeader>
+
+ <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
+ <div>
+ <Label htmlFor="code">
+ 프로젝트 코드 <span className="text-red-500">*</span>
+ </Label>
+ <Input
+ id="code"
+ {...register('code', {
+ required: '프로젝트 코드는 필수입니다',
+ minLength: {
+ value: 2,
+ message: '프로젝트 코드는 최소 2자 이상이어야 합니다',
+ },
+ pattern: {
+ value: /^[A-Z0-9]+$/,
+ message: '프로젝트 코드는 대문자와 숫자만 사용 가능합니다',
+ },
+ })}
+ placeholder="SN1001"
+ className={errors.code ? 'border-red-500' : ''}
+ />
+ {errors.code && (
+ <p className="text-sm text-red-500 mt-1">{errors.code.message}</p>
+ )}
+ </div>
+
+ <div>
+ <Label htmlFor="name">
+ 프로젝트 이름 <span className="text-red-500">*</span>
+ </Label>
+ <Input
+ id="name"
+ {...register('name', {
+ required: '프로젝트 이름은 필수입니다',
+ minLength: {
+ value: 2,
+ message: '프로젝트 이름은 최소 2자 이상이어야 합니다',
+ },
+ maxLength: {
+ value: 50,
+ message: '프로젝트 이름은 50자를 초과할 수 없습니다',
+ },
+ })}
+ placeholder="예: FNLG"
+ className={errors.name ? 'border-red-500' : ''}
+ />
+ {errors.name && (
+ <p className="text-sm text-red-500 mt-1">{errors.name.message}</p>
+ )}
+ </div>
+
+ <div>
+ <Label htmlFor="description">설명 (선택)</Label>
+ <Input
+ id="description"
+ {...register('description', {
+ maxLength: {
+ value: 200,
+ message: '설명은 200자를 초과할 수 없습니다',
+ },
+ })}
+ placeholder="프로젝트에 대한 간단한 설명"
+ className={errors.description ? 'border-red-500' : ''}
+ />
+ {errors.description && (
+ <p className="text-sm text-red-500 mt-1">{errors.description.message}</p>
+ )}
+ </div>
+
+ <div className="flex items-center justify-between">
+ <div>
+ <Label htmlFor="public">공개 프로젝트</Label>
+ <p className="text-sm text-muted-foreground">
+ 모든 사용자가 이 프로젝트를 볼 수 있습니다
+ </p>
+ </div>
+ <Switch
+ id="public"
+ checked={watchIsPublic}
+ onCheckedChange={(checked) => setValue('isPublic', checked)}
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => handleDialogClose(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={!isValid || isSubmitting}
+ >
+ {isSubmitting ? '생성 중...' : '프로젝트 생성'}
+ </Button>
+ </DialogFooter>
+ </form>
+ </DialogContent>
+ </Dialog>
+ </>
+ );
+} \ No newline at end of file
diff --git a/components/project/ProjectNav.tsx b/components/project/ProjectNav.tsx
new file mode 100644
index 00000000..acf9bfd8
--- /dev/null
+++ b/components/project/ProjectNav.tsx
@@ -0,0 +1,149 @@
+// components/project/ProjectNav.tsx
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useRouter, usePathname } from 'next/navigation';
+import {
+ Home,
+ FolderOpen,
+ Users,
+ Settings,
+ BarChart3,
+ Share2,
+ ChevronDown,
+ ExternalLink
+} from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbSeparator,BreadcrumbList
+} from '@/components/ui/breadcrumb';
+import { Badge } from '@/components/ui/badge';
+import { cn } from '@/lib/utils';
+
+interface ProjectNavProps {
+ projectId: string;
+}
+
+export function ProjectNav({ projectId }: ProjectNavProps) {
+ const [projectName, setProjectName] = useState('');
+ const [projectRole, setProjectRole] = useState('');
+ const router = useRouter();
+ const pathname = usePathname();
+
+ useEffect(() => {
+ // 프로젝트 정보 가져오기
+ fetchProjectInfo();
+ }, [projectId]);
+
+ const fetchProjectInfo = async () => {
+ try {
+ const response = await fetch(`/api/projects/${projectId}`);
+ const data = await response.json();
+ setProjectName(data.name);
+ setProjectRole(data.role);
+ } catch (error) {
+ console.error('프로젝트 정보 로드 실패:', error);
+ }
+ };
+
+ const navItems = [
+ {
+ label: '대시보드',
+ icon: Home,
+ href: `/evcp/data-room/${projectId}`,
+ active: pathname === `/evcp/data-room/${projectId}`,
+ },
+ {
+ label: '파일',
+ icon: FolderOpen,
+ href: `/evcp/data-room/${projectId}/files`,
+ active: pathname === `/evcp/data-room/${projectId}/files`,
+ },
+ {
+ label: '멤버',
+ icon: Users,
+ href: `/evcp/data-room/${projectId}/members`,
+ active: pathname === `/evcp/data-room/${projectId}/members`,
+ requireRole: ['owner', 'admin'],
+ },
+ {
+ label: '통계',
+ icon: BarChart3,
+ href: `/evcp/data-room/${projectId}/stats`,
+ active: pathname === `/evcp/data-room/${projectId}/stats`,
+ requireRole: ['owner'],
+ },
+ {
+ label: '설정',
+ icon: Settings,
+ href: `/evcp/data-room/${projectId}/settings`,
+ active: pathname === `/evcp/data-room/${projectId}/settings`,
+ requireRole: ['owner', 'admin'],
+ },
+ ];
+
+ const visibleNavItems = navItems.filter(item =>
+ !item.requireRole || item.requireRole.includes(projectRole)
+ );
+
+ return (
+ <div className="border-b bg-white">
+ <div className="px-6 py-3">
+ {/* Breadcrumb */}
+ <div className="flex items-center justify-between mb-3">
+ <Breadcrumb>
+ <BreadcrumbList>
+ <BreadcrumbItem>
+ <BreadcrumbLink href="/evcp/data-room">프로젝트</BreadcrumbLink>
+ </BreadcrumbItem>
+ <BreadcrumbSeparator />
+ <BreadcrumbItem>
+ {projectName || '로딩...'}
+ </BreadcrumbItem>
+ </BreadcrumbList>
+ </Breadcrumb>
+
+ <div className="flex items-center gap-2">
+ <Badge variant="outline">
+ {projectRole || 'viewer'}
+ </Badge>
+ <Button variant="outline" size="sm">
+ <Share2 className="h-4 w-4 mr-1" />
+ 공유
+ </Button>
+ </div>
+ </div>
+
+ {/* 네비게이션 탭 */}
+ <div className="flex items-center gap-1">
+ {visibleNavItems.map(item => (
+ <Button
+ key={item.label}
+ variant={item.active ? "secondary" : "ghost"}
+ size="sm"
+ onClick={() => router.push(item.href)}
+ className={cn(
+ "gap-2",
+ item.active && "bg-secondary"
+ )}
+ >
+ <item.icon className="h-4 w-4" />
+ {item.label}
+ </Button>
+ ))}
+ </div>
+ </div>
+ </div>
+ );
+}
+
diff --git a/components/project/ProjectSidebar.tsx b/components/project/ProjectSidebar.tsx
new file mode 100644
index 00000000..ce2007b1
--- /dev/null
+++ b/components/project/ProjectSidebar.tsx
@@ -0,0 +1,318 @@
+// components/project/ProjectSidebar.tsx
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useRouter, usePathname } from 'next/navigation';
+import {
+ Home,
+ FolderOpen,
+ Users,
+ Settings,
+ Plus,
+ ChevronLeft,
+ ChevronRight,
+ Search,
+ Crown,
+ Shield,
+ Eye,
+ Clock,
+ Star,
+ LogOut
+} from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Input } from '@/components/ui/input';
+import { Separator } from '@/components/ui/separator';
+import { Badge } from '@/components/ui/badge';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+import { cn } from '@/lib/utils';
+import { useSession, signOut } from 'next-auth/react';
+
+interface RecentProject {
+ id: string;
+ name: string;
+ role: string;
+ lastAccessed: string;
+}
+
+export function ProjectSidebar() {
+ const [collapsed, setCollapsed] = useState(false);
+ const [recentProjects, setRecentProjects] = useState<RecentProject[]>([]);
+ const [favoriteProjects, setFavoriteProjects] = useState<string[]>([]);
+
+ const router = useRouter();
+ const pathname = usePathname();
+ const { data: session } = useSession();
+
+ const isInternalUser = session?.user?.domain !== 'partners';
+
+ useEffect(() => {
+ // 최근 프로젝트 로드
+ const stored = localStorage.getItem('recentProjects');
+ if (stored) {
+ setRecentProjects(JSON.parse(stored));
+ }
+
+ // 즐겨찾기 프로젝트 로드
+ const favorites = localStorage.getItem('favoriteProjects');
+ if (favorites) {
+ setFavoriteProjects(JSON.parse(favorites));
+ }
+ }, [pathname]);
+
+ const menuItems = [
+ {
+ label: '홈',
+ icon: Home,
+ href: '/projects',
+ active: pathname === '/projects',
+ },
+ {
+ label: '모든 프로젝트',
+ icon: FolderOpen,
+ href: '/projects',
+ active: pathname === '/projects',
+ },
+ ...(isInternalUser ? [{
+ label: '팀 관리',
+ icon: Users,
+ href: '/projects/team',
+ active: pathname === '/projects/team',
+ }] : []),
+ {
+ label: '설정',
+ icon: Settings,
+ href: '/projects/settings',
+ active: pathname === '/projects/settings',
+ },
+ ];
+
+ const roleIcons = {
+ owner: { icon: Crown, color: 'text-yellow-500' },
+ admin: { icon: Shield, color: 'text-blue-500' },
+ viewer: { icon: Eye, color: 'text-gray-500' },
+ };
+
+ return (
+ <TooltipProvider>
+ <div className={cn(
+ "flex flex-col bg-white border-r transition-all duration-300",
+ collapsed ? "w-16" : "w-64"
+ )}>
+ {/* 헤더 */}
+ <div className="flex items-center justify-between p-4 border-b">
+ {!collapsed && (
+ <div>
+ <h2 className="text-lg font-semibold">파일 매니저</h2>
+ <p className="text-xs text-muted-foreground">
+ {session?.user?.name}
+ </p>
+ </div>
+ )}
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setCollapsed(!collapsed)}
+ className={cn(collapsed && "mx-auto")}
+ >
+ {collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
+ </Button>
+ </div>
+
+ {/* 검색 */}
+ {!collapsed && (
+ <div className="p-3 border-b">
+ <div className="relative">
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="프로젝트 검색..."
+ className="pl-8 h-8"
+ />
+ </div>
+ </div>
+ )}
+
+ {/* 메인 메뉴 */}
+ <ScrollArea className="flex-1">
+ <div className="p-2">
+ <div className={cn(!collapsed && "mb-3")}>
+ {!collapsed && (
+ <p className="text-xs text-muted-foreground px-2 mb-2">메뉴</p>
+ )}
+ {menuItems.map((item) => (
+ <Tooltip key={item.label} delayDuration={0}>
+ <TooltipTrigger asChild>
+ <Button
+ variant={item.active ? "secondary" : "ghost"}
+ className={cn(
+ "w-full justify-start mb-1",
+ collapsed && "justify-center"
+ )}
+ onClick={() => router.push(item.href)}
+ >
+ <item.icon className={cn("h-4 w-4", !collapsed && "mr-2")} />
+ {!collapsed && item.label}
+ </Button>
+ </TooltipTrigger>
+ {collapsed && (
+ <TooltipContent side="right">
+ {item.label}
+ </TooltipContent>
+ )}
+ </Tooltip>
+ ))}
+ </div>
+
+ <Separator className="my-3" />
+
+ {/* 빠른 액세스 */}
+ {!collapsed && (
+ <div className="mb-3">
+ <div className="flex items-center justify-between px-2 mb-2">
+ <p className="text-xs text-muted-foreground">빠른 액세스</p>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-6 w-6 p-0"
+ onClick={() => router.push('/projects/new')}
+ >
+ <Plus className="h-3 w-3" />
+ </Button>
+ </div>
+
+ {/* 즐겨찾기 프로젝트 */}
+ {favoriteProjects.length > 0 && (
+ <div className="space-y-1 mb-3">
+ {favoriteProjects.slice(0, 3).map((projectId) => (
+ <Button
+ key={projectId}
+ variant="ghost"
+ className="w-full justify-start h-8 px-2"
+ onClick={() => router.push(`/projects/${projectId}/files`)}
+ >
+ <Star className="h-3 w-3 mr-2 text-yellow-500" />
+ <span className="text-sm truncate">프로젝트 이름</span>
+ </Button>
+ ))}
+ </div>
+ )}
+
+ {/* 최근 프로젝트 */}
+ <div className="space-y-1">
+ <p className="text-xs text-muted-foreground px-2 mb-1">최근 프로젝트</p>
+ {recentProjects.slice(0, 5).map((project) => {
+ const RoleIcon = roleIcons[project.role as keyof typeof roleIcons];
+ return (
+ <Button
+ key={project.id}
+ variant="ghost"
+ className="w-full justify-start h-8 px-2 group"
+ onClick={() => router.push(`/projects/${project.id}/files`)}
+ >
+ {RoleIcon && (
+ <RoleIcon.icon className={cn("h-3 w-3 mr-2", RoleIcon.color)} />
+ )}
+ <span className="text-sm truncate flex-1 text-left">
+ {project.name}
+ </span>
+ <Clock className="h-3 w-3 text-muted-foreground opacity-0 group-hover:opacity-100" />
+ </Button>
+ );
+ })}
+ </div>
+ </div>
+ )}
+
+ {collapsed && (
+ <div className="space-y-1">
+ <Tooltip delayDuration={0}>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ className="w-full justify-center"
+ onClick={() => router.push('/projects/new')}
+ >
+ <Plus className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent side="right">
+ 새 프로젝트
+ </TooltipContent>
+ </Tooltip>
+
+ {recentProjects.slice(0, 3).map((project) => {
+ const RoleIcon = roleIcons[project.role as keyof typeof roleIcons];
+ return (
+ <Tooltip key={project.id} delayDuration={0}>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ className="w-full justify-center"
+ onClick={() => router.push(`/projects/${project.id}/files`)}
+ >
+ {RoleIcon && (
+ <RoleIcon.icon className={cn("h-4 w-4", RoleIcon.color)} />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent side="right">
+ {project.name}
+ </TooltipContent>
+ </Tooltip>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+
+ {/* 하단 사용자 정보 */}
+ <div className="border-t p-3">
+ {!collapsed ? (
+ <div className="flex items-center gap-2">
+ <div className="h-8 w-8 bg-gray-200 rounded-full flex items-center justify-center">
+ <span className="text-xs font-medium">
+ {session?.user?.name?.charAt(0).toUpperCase()}
+ </span>
+ </div>
+ <div className="flex-1">
+ <p className="text-sm font-medium truncate">{session?.user?.name}</p>
+ <Badge variant="outline" className="text-xs">
+ {isInternalUser ? '내부' : '외부'}
+ </Badge>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => signOut()}
+ >
+ <LogOut className="h-4 w-4" />
+ </Button>
+ </div>
+ ) : (
+ <Tooltip delayDuration={0}>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ className="w-full justify-center"
+ onClick={() => signOut()}
+ >
+ <LogOut className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent side="right">
+ 로그아웃
+ </TooltipContent>
+ </Tooltip>
+ )}
+ </div>
+ </div>
+ </TooltipProvider>
+ );
+}
+