summaryrefslogtreecommitdiff
path: root/components/file-manager
diff options
context:
space:
mode:
Diffstat (limited to 'components/file-manager')
-rw-r--r--components/file-manager/FileManager.tsx1447
-rw-r--r--components/file-manager/SharedFileViewer.tsx411
2 files changed, 1858 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