diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-15 12:52:11 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-15 12:52:11 +0000 |
| commit | b54f6f03150dd78d86db62201b6386bf14b72394 (patch) | |
| tree | b3092bb34805fdc65eee5282e86a9fb90ba20d6e /components/file-manager | |
| parent | c1bd1a2f499ee2f0742170021b37dab410983ab7 (diff) | |
(대표님) 커버, 데이터룸, 파일매니저, 담당자할당 등
Diffstat (limited to 'components/file-manager')
| -rw-r--r-- | components/file-manager/FileManager copy.tsx | 1852 | ||||
| -rw-r--r-- | components/file-manager/FileManager.tsx | 729 | ||||
| -rw-r--r-- | components/file-manager/SecurePDFViewer.tsx | 9 | ||||
| -rw-r--r-- | components/file-manager/creaetWaterMarks.tsx | 71 |
4 files changed, 2173 insertions, 488 deletions
diff --git a/components/file-manager/FileManager copy.tsx b/components/file-manager/FileManager copy.tsx new file mode 100644 index 00000000..3f3d73a4 --- /dev/null +++ b/components/file-manager/FileManager copy.tsx @@ -0,0 +1,1852 @@ +'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'; +// Import the secure viewer component +import { SecurePDFViewer } from './SecurePDFViewer'; + +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; +} + +// Category configuration with icons and colors +const categoryConfig = { + public: { icon: Globe, color: 'text-green-500', label: 'Public' }, + restricted: { icon: Eye, color: 'text-yellow-500', label: 'Restricted' }, + confidential: { icon: Lock, color: 'text-red-500', label: 'Confidential' }, + internal: { icon: Shield, color: 'text-blue-500', label: 'Internal' }, +}; + +// 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; + onView: (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, + onView, + 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' && ( + <> + <DropdownMenuItem onClick={() => onView(item)}> + <Eye className="h-4 w-4 mr-2" /> + View + </DropdownMenuItem> + {item.permissions?.canDownload && ( + <DropdownMenuItem onClick={() => onDownload(item)}> + <Download className="h-4 w-4 mr-2" /> + Download + </DropdownMenuItem> + )} + </> + )} + + {item.type === 'folder' && ( + <DropdownMenuItem onClick={() => onDownloadFolder(item)}> + <Download className="h-4 w-4 mr-2" /> + Download Folder + </DropdownMenuItem> + )} + + {isInternalUser && ( + <> + <DropdownMenuItem onClick={() => onShare(item)}> + <Share2 className="h-4 w-4 mr-2" /> + Share + </DropdownMenuItem> + + {item.permissions?.canEdit && ( + <DropdownMenuItem onClick={() => onRename(item)}> + <Edit2 className="h-4 w-4 mr-2" /> + Rename + </DropdownMenuItem> + )} + </> + )} + + {item.permissions?.canDelete && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuItem + className="text-destructive" + onClick={() => onDelete([item.id])} + > + <Trash2 className="h-4 w-4 mr-2" /> + Delete + </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} + onView={onView} + 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); + + console.log(items, "items") + + // Upload states + const [uploadDialogOpen, setUploadDialogOpen] = useState(false); + const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([]); + const [uploadCategory, setUploadCategory] = useState<string>('confidential'); + + // Dialog states + const [folderDialogOpen, setFolderDialogOpen] = useState(false); + const [shareDialogOpen, setShareDialogOpen] = useState(false); + const [permissionDialogOpen, setPermissionDialogOpen] = useState(false); + const [renameDialogOpen, setRenameDialogOpen] = useState(false); + const [viewerDialogOpen, setViewerDialogOpen] = useState(false); + const [viewerFileUrl, setViewerFileUrl] = useState<string | null>(null); + + // Dialog data + 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(); + + // Check if user is internal + const isInternalUser = session?.user?.domain !== 'partners'; + + // Build tree structure function + const buildTree = (flatItems: FileItem[]): FileItem[] => { + const itemMap = new Map<string, FileItem>(); + const rootItems: FileItem[] = []; + + // Store all items in map (initialize children) + flatItems.forEach(item => { + itemMap.set(item.id, { ...item, children: [] }); + }); + + // Set parent-child relationships + flatItems.forEach(item => { + const mappedItem = itemMap.get(item.id)!; + + if (!item.parentId) { + // No parentId means root item + rootItems.push(mappedItem); + } else { + // Has parentId, add to parent's children + const parent = itemMap.get(item.parentId); + if (parent) { + if (!parent.children) parent.children = []; + parent.children.push(mappedItem); + } else { + // Can't find parent, treat as root + rootItems.push(mappedItem); + } + } + }); + + return rootItems; + }; + + // Fetch file list + const fetchItems = useCallback(async () => { + setLoading(true); + try { + const params = new URLSearchParams(); + + // For tree view, get entire list + if (viewMode === 'list') { + params.append('viewMode', 'tree'); + // Keep current path info for tree view (used for highlighting, etc.) + if (currentParentId) params.append('currentParentId', currentParentId); + } else { + // For grid view, only get current folder contents + 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); + + // Build tree structure + if (viewMode === 'list') { + const tree = buildTree(data); + setTreeItems(tree); + } + } catch (error) { + toast({ + title: 'Error', + description: 'Failed to load files.', + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }, [projectId, currentParentId, viewMode, toast]); + + useEffect(() => { + fetchItems(); + }, [fetchItems]); + + // Create folder + 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: 'Success', + description: 'Folder created successfully.', + }); + } catch (error: any) { + toast({ + title: 'Error', + description: error.message || 'Failed to create folder.', + variant: 'destructive', + }); + } + }; + + // Handle file upload + const handleFileUpload = async (files: FileList | File[]) => { + const fileArray = Array.from(files); + + // Initialize uploading file list + const newUploadingFiles: UploadingFile[] = fileArray.map(file => ({ + file, + progress: 0, + status: 'pending' as const + })); + + setUploadingFiles(newUploadingFiles); + + // Process each file upload + for (let i = 0; i < fileArray.length; i++) { + const file = fileArray[i]; + + try { + // Update status: uploading + setUploadingFiles(prev => prev.map((f, idx) => + idx === i ? { ...f, status: 'uploading', progress: 20 } : f + )); + + // DRM decryption + setUploadingFiles(prev => prev.map((f, idx) => + idx === i ? { ...f, status: 'processing', progress: 40 } : f + )); + + const decryptedData = await decryptWithServerAction(file); + + // Create FormData + const formData = new FormData(); + const blob = new Blob([decryptedData], { type: file.type }); + formData.append('file', blob, file.name); + formData.append('category', uploadCategory); + formData.append('fileSize', file.size.toString()); // Pass file size + if (currentParentId) { + formData.append('parentId', currentParentId); + } + + // Update upload progress + setUploadingFiles(prev => prev.map((f, idx) => + idx === i ? { ...f, progress: 60 } : f + )); + + // API call + const response = await fetch(`/api/data-room/${projectId}/upload`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Upload failed'); + } + + // Success + setUploadingFiles(prev => prev.map((f, idx) => + idx === i ? { ...f, status: 'completed', progress: 100 } : f + )); + + } catch (error: any) { + // Failure + setUploadingFiles(prev => prev.map((f, idx) => + idx === i ? { + ...f, + status: 'error', + error: error.message || 'Upload failed' + } : f + )); + } + } + + // Refresh list after all uploads complete + await fetchItems(); + + // Show toast if any files succeeded + const successCount = newUploadingFiles.filter(f => f.status === 'completed').length; + if (successCount > 0) { + toast({ + title: 'Upload Complete', + description: `${successCount} file(s) uploaded successfully.`, + }); + } + }; + + // Download folder + const downloadFolder = async (folder: FileItem) => { + if (folder.type !== 'folder') return; + + try { + toast({ + title: 'Checking Permissions', + description: 'Verifying download permissions for folder contents...', + }); + + // Call folder download API + const response = await fetch(`/api/data-room/${projectId}/download-folder/${folder.id}`, { + method: 'GET', + }); + + if (!response.ok) { + const error = await response.json(); + + // If there are files without permission, provide details + if (error.unauthorizedFiles) { + toast({ + title: 'Insufficient Permissions', + description: `No permission for ${error.unauthorizedFiles.length} file(s): ${error.unauthorizedFiles.join(', ')}`, + variant: 'destructive', + }); + return; + } + + throw new Error(error.error || 'Folder download failed'); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + + // Include folder name in filename + 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: 'Download Complete', + description: `${folder.name} folder downloaded successfully.`, + }); + + } catch (error: any) { + toast({ + title: 'Error', + description: error.message || 'Failed to download folder.', + variant: 'destructive', + }); + } + }; + + // Share file + 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(); + + // Copy share link to clipboard + await navigator.clipboard.writeText(data.shareUrl); + + toast({ + title: 'Share Link Created', + description: 'Link copied to clipboard.', + }); + + setShareDialogOpen(false); + setSelectedFile(null); + } catch (error) { + toast({ + title: 'Error', + description: 'Failed to create share link.', + variant: 'destructive', + }); + } + }; + + // Download multiple files + const downloadMultipleFiles = async (itemIds: string[]) => { + // Filter only actual files (exclude folders) that can be downloaded + const filesToDownload = items.filter(item => + itemIds.includes(item.id) && + item.type === 'file' && + item.permissions?.canDownload === 'true' + ); + + if (filesToDownload.length === 0) { + toast({ + title: 'Notice', + description: 'No downloadable files selected.', + variant: 'default', + }); + return; + } + + // Use regular download for single file + if (filesToDownload.length === 1) { + await downloadFile(filesToDownload[0]); + return; + } + + try { + toast({ + title: 'Preparing Download', + description: `Compressing ${filesToDownload.length} files...`, + }); + + // Call multiple files download 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('Download failed'); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + + // Include timestamp in filename + 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: 'Download Complete', + description: `${filesToDownload.length} files downloaded successfully.`, + }); + + } catch (error) { + console.error('Multiple download error:', error); + + // Offer individual downloads on failure + toast({ + title: 'Batch Download Failed', + description: 'Would you like to try individual downloads?', + action: ( + <Button + size="sm" + variant="outline" + onClick={() => { + // Execute individual downloads + filesToDownload.forEach(async (file, index) => { + // Add delay between downloads to reduce browser load + setTimeout(() => downloadFile(file), index * 500); + }); + }} + > + Download Individually + </Button> + ), + }); + } + }; + + // View file with PDFTron + const viewFile = async (file: FileItem) => { + try { + + + + setViewerFileUrl(file.filePath); + setSelectedFile(file); + setViewerDialogOpen(true); + + + + } catch (error) { + toast({ + title: 'Error', + description: 'Failed to open file for viewing.', + variant: 'destructive', + }); + } + }; + + // Download file + 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: 'Error', + description: 'Download failed.', + variant: 'destructive', + }); + } + }; + + // Delete files + 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: 'Success', + description: 'Selected items deleted successfully.', + }); + } catch (error) { + toast({ + title: 'Error', + description: 'Failed to delete items.', + variant: 'destructive', + }); + } + }; + + // Rename item + const renameItem = async () => { + if (!selectedFile) return; + + try { + const response = await fetch( + `/api/data-room/${projectId}/${selectedFile.id}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: dialogValue }), + } + ); + + if (!response.ok) { + throw new Error('Failed to rename'); + } + + await fetchItems(); + setRenameDialogOpen(false); + setSelectedFile(null); + setDialogValue(''); + + toast({ + title: 'Success', + description: 'Item renamed successfully.', + }); + } catch (error) { + toast({ + title: 'Error', + description: 'Failed to rename item.', + variant: 'destructive', + }); + } + }; + + // Change category + const changeCategory = async ( + itemId: string, + newCategory: string, + applyToChildren: boolean = false + ) => { + try { + const response = await fetch( + `/api/data-room/${projectId}/${itemId}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + category: newCategory, + applyToChildren + }), + } + ); + + if (!response.ok) { + throw new Error('Failed to change category'); + } + + await fetchItems(); + + toast({ + title: 'Success', + description: 'Category updated successfully.', + }); + } catch (error) { + toast({ + title: 'Error', + description: 'Failed to change category.', + variant: 'destructive', + }); + } + }; + + // Category change dialog states + const [categoryDialogOpen, setCategoryDialogOpen] = useState(false); + const [applyToChildren, setApplyToChildren] = useState(false); + const [newCategory, setNewCategory] = useState('confidential'); + + // Handle folder double click + const handleFolderOpen = (folder: FileItem) => { + if (viewMode === 'grid') { + setCurrentPath([...currentPath, folder.name]); + setCurrentParentId(folder.id); + } else { + // In tree view, 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()); + }; + + // Toggle folder expansion + const toggleFolderExpand = (folderId: string) => { + const newExpanded = new Set(expandedFolders); + if (newExpanded.has(folderId)) { + newExpanded.delete(folderId); + } else { + newExpanded.add(folderId); + } + setExpandedFolders(newExpanded); + }; + + // Toggle item selection + const toggleItemSelection = (itemId: string) => { + const newSelected = new Set(selectedItems); + if (newSelected.has(itemId)) { + newSelected.delete(itemId); + } else { + newSelected.add(itemId); + } + setSelectedItems(newSelected); + }; + + // Navigate to path + const navigateToPath = (index: number) => { + if (index === -1) { + setCurrentPath([]); + setCurrentParentId(null); + } else { + setCurrentPath(currentPath.slice(0, index + 1)); + // Need to update parentId logic + } + }; + + // Filtered items + const filteredItems = items.filter(item => + item.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const filteredTreeItems = treeItems.filter(item => + item.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + // Format file size + 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"> + {/* Toolbar */} + <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" /> + New Folder + </Button> + <Button + size="sm" + variant="outline" + onClick={() => { + // 현재 폴더의 카테고리를 기본값으로 설정 + if (currentParentId) { + const currentFolder = items.find(item => item.parentId === currentParentId); + if (currentFolder) { + setUploadCategory(currentFolder.category); + } + } + setUploadDialogOpen(true); + }} + > + <Upload className="h-4 w-4 mr-1" /> + Upload + </Button> + </> + )} + + {selectedItems.size > 0 && ( + <> + {/* Multiple download button */} + {items.filter(item => + selectedItems.has(item.id) && + item.type === 'file' && + item.permissions?.canDownload === 'true' + ).length > 0 && ( + <Button + size="sm" + variant="outline" + onClick={() => downloadMultipleFiles(Array.from(selectedItems))} + > + <Download className="h-4 w-4 mr-1" /> + Download ({items.filter(item => + selectedItems.has(item.id) && item.type === 'file' + ).length}) + </Button> + )} + + {/* Delete 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" /> + Delete ({selectedItems.size}) + </Button> + )} + </> + )} + + {!isInternalUser && ( + <Badge variant="secondary" className="ml-2"> + <Shield className="h-3 w-3 mr-1" /> + External User + </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="Search..." + 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> + + {/* File List */} + <ScrollArea className="flex-1 p-4"> + {loading ? ( + <div className="flex justify-center items-center h-64"> + <div className="text-muted-foreground">Loading...</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">Empty</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)}> + Open + </ContextMenuItem> + <ContextMenuItem onClick={() => downloadFolder(item)}> + <Download className="h-4 w-4 mr-2" /> + Download Folder + </ContextMenuItem> + </> + )} + + {item.type === 'file' && ( + <> + <ContextMenuItem onClick={() => viewFile(item)}> + <Eye className="h-4 w-4 mr-2" /> + View + </ContextMenuItem> + {item.permissions?.canDownload === 'true' && ( + <ContextMenuItem onClick={() => downloadFile(item)}> + <Download className="h-4 w-4 mr-2" /> + Download + </ContextMenuItem> + )} + </> + )} + + {isInternalUser && ( + <> + <ContextMenuSeparator /> + <ContextMenuSub> + <ContextMenuSubTrigger> + <Shield className="h-4 w-4 mr-2" /> + Change Category + </ContextMenuSubTrigger> + <ContextMenuSubContent> + {Object.entries(categoryConfig).map(([key, config]) => ( + <ContextMenuItem + key={key} + onClick={() => { + if (item.type === 'folder') { + // Show dialog for folders + setSelectedFile(item); + setNewCategory(key); + setCategoryDialogOpen(true); + } else { + // Change immediately for files + changeCategory(item.id, key, false); + } + }} + > + <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" /> + Share + </ContextMenuItem> + + {item.permissions?.canEdit && ( + <ContextMenuItem onClick={() => { + setSelectedFile(item); + setDialogValue(item.name); + setRenameDialogOpen(true); + }}> + <Edit2 className="h-4 w-4 mr-2" /> + Rename + </ContextMenuItem> + )} + </> + )} + + {item.permissions?.canDelete && ( + <> + <ContextMenuSeparator /> + <ContextMenuItem + className="text-destructive" + onClick={() => deleteItems([item.id])} + > + <Trash2 className="h-4 w-4 mr-2" /> + Delete + </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} + onView={viewFile} + 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> + +{/* Upload Dialog */} +<Dialog open={uploadDialogOpen} onOpenChange={setUploadDialogOpen}> + <DialogContent className="max-w-2xl max-h-[90vh] flex flex-col"> + <DialogHeader> + <DialogTitle>Upload Files</DialogTitle> + <DialogDescription> + Drag and drop files or click to select. + </DialogDescription> + </DialogHeader> + + <ScrollArea className="flex-1 pr-4"> + <div className="space-y-4"> + {/* Category Selection */} + <div> + <Label htmlFor="upload-category">Category</Label> + <Select value={uploadCategory} onValueChange={setUploadCategory}> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {Object.entries(categoryConfig) + .filter(([key]) => { + // 현재 폴더가 있는 경우 + if (currentParentId) { + const currentFolder = items.find(item => item.parentId === currentParentId); + // 현재 폴더가 public이 아니면 public 옵션 제외 + if (currentFolder && currentFolder.category !== 'public') { + return key !== 'public'; + } + } + // 루트 폴더이거나 현재 폴더가 public인 경우 모든 옵션 표시 + return true; + }) + .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> + {/* 현재 폴더 정보 표시 (선택사항) */} + {currentParentId && (() => { + const currentFolder = items.find(item => item.parentId === currentParentId); + if (currentFolder && currentFolder.category !== 'public') { + return ( + <p className="text-xs text-muted-foreground mt-1 flex items-center"> + <AlertCircle className="h-3 w-3 mr-1" /> + Current folder is {categoryConfig[currentFolder.category].label}. + Public uploads are not allowed. + </p> + ); + } + })()} + </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>Drag files or click to upload</DropzoneTitle> + <DropzoneDescription>Multiple files can be uploaded simultaneously</DropzoneDescription> + </div> + </DropzoneZone> + </Dropzone> + + {/* Uploading File List */} + {uploadingFiles.length > 0 && ( + <div className="border rounded-lg p-4 bg-muted/50"> + <div className="flex items-center justify-between mb-3"> + <h4 className="font-medium text-sm"> + Uploading Files ({uploadingFiles.filter(f => f.status === 'completed').length}/{uploadingFiles.length}) + </h4> + {uploadingFiles.every(f => f.status === 'completed' || f.status === 'error') && ( + <Button + size="sm" + variant="ghost" + onClick={() => setUploadingFiles([])} + > + <X className="h-4 w-4" /> + </Button> + )} + </div> + <div className="space-y-2 max-h-[300px] overflow-y-auto"> + {uploadingFiles.map((uploadFile, index) => ( + <div key={index} className="flex items-start gap-3 p-3 bg-background rounded-md"> + <File className="h-5 w-5 mt-0.5 flex-shrink-0 text-muted-foreground" /> + <div className="flex-1 min-w-0"> + <p className="text-sm font-medium truncate">{uploadFile.file.name}</p> + <div className="flex items-center gap-2 mt-1"> + <span className="text-xs text-muted-foreground"> + {formatFileSize(uploadFile.file.size)} + </span> + <span className="text-xs"> + {uploadFile.status === 'pending' && 'Waiting...'} + {uploadFile.status === 'uploading' && 'Uploading...'} + {uploadFile.status === 'processing' && 'Processing...'} + {uploadFile.status === 'completed' && ( + <span className="text-green-600 font-medium">✓ Complete</span> + )} + {uploadFile.status === 'error' && ( + <span className="text-red-600 font-medium">✗ {uploadFile.error}</span> + )} + </span> + </div> + {(uploadFile.status === 'uploading' || uploadFile.status === 'processing') && ( + <Progress value={uploadFile.progress} className="h-1.5 mt-2" /> + )} + </div> + {uploadFile.status === 'error' && ( + <Button + size="sm" + variant="ghost" + onClick={() => { + setUploadingFiles(prev => + prev.filter((_, i) => i !== index) + ); + }} + > + <X className="h-4 w-4" /> + </Button> + )} + </div> + ))} + </div> + </div> + )} + </div> + </ScrollArea> + + <DialogFooter className="mt-4"> + <Button + variant="outline" + onClick={() => { + setUploadDialogOpen(false); + setUploadingFiles([]); + }} + > + Close + </Button> + </DialogFooter> + </DialogContent> +</Dialog> + + {/* Create Folder Dialog */} + <Dialog open={folderDialogOpen} onOpenChange={setFolderDialogOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>Create New Folder</DialogTitle> + <DialogDescription> + Set the folder name and access permission category. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <div> + <Label htmlFor="folder-name">Folder Name</Label> + <Input + id="folder-name" + value={dialogValue} + onChange={(e) => setDialogValue(e.target.value)} + placeholder="Enter folder name" + /> + </div> + + <div> + <Label htmlFor="folder-category">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)}> + Cancel + </Button> + <Button onClick={createFolder}>Create</Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* File Share Dialog */} + <Dialog open={shareDialogOpen} onOpenChange={setShareDialogOpen}> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>Share File</DialogTitle> + <DialogDescription> + Sharing {selectedFile?.name}. + </DialogDescription> + </DialogHeader> + + <Tabs defaultValue="link" className="w-full"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="link">Link Sharing</TabsTrigger> + <TabsTrigger value="permission">Permission Settings</TabsTrigger> + </TabsList> + + <TabsContent value="link" className="space-y-4"> + <div> + <Label htmlFor="access-level">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" /> + View Only + </div> + </SelectItem> + <SelectItem value="view_download"> + <div className="flex items-center"> + <Download className="h-4 w-4 mr-2" /> + View + Download + </div> + </SelectItem> + </SelectContent> + </Select> + </div> + + <div> + <Label htmlFor="password">Password (Optional)</Label> + <Input + id="password" + type="password" + value={shareSettings.password} + onChange={(e) => setShareSettings({ ...shareSettings, password: e.target.value })} + placeholder="Enter password" + /> + </div> + + <div> + <Label htmlFor="expires">Expiry Date (Optional)</Label> + <Input + id="expires" + type="datetime-local" + value={shareSettings.expiresAt} + onChange={(e) => setShareSettings({ ...shareSettings, expiresAt: e.target.value })} + /> + </div> + + <div> + <Label htmlFor="max-downloads">Max Downloads (Optional)</Label> + <Input + id="max-downloads" + type="number" + value={shareSettings.maxDownloads} + onChange={(e) => setShareSettings({ ...shareSettings, maxDownloads: e.target.value })} + placeholder="Unlimited" + /> + </div> + </TabsContent> + + <TabsContent value="permission" className="space-y-4"> + <div> + <Label htmlFor="target-domain">Target Domain</Label> + <Select> + <SelectTrigger> + <SelectValue placeholder="Select domain" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="partners">Partners</SelectItem> + <SelectItem value="internal">Internal</SelectItem> + </SelectContent> + </Select> + </div> + + <div className="space-y-2"> + <Label>Permissions</Label> + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <Label htmlFor="can-view" className="text-sm font-normal">View</Label> + <Switch id="can-view" defaultChecked /> + </div> + <div className="flex items-center justify-between"> + <Label htmlFor="can-download" className="text-sm font-normal">Download</Label> + <Switch id="can-download" /> + </div> + <div className="flex items-center justify-between"> + <Label htmlFor="can-edit" className="text-sm font-normal">Edit</Label> + <Switch id="can-edit" /> + </div> + <div className="flex items-center justify-between"> + <Label htmlFor="can-share" className="text-sm font-normal">Share</Label> + <Switch id="can-share" /> + </div> + </div> + </div> + </TabsContent> + </Tabs> + + <DialogFooter> + <Button variant="outline" onClick={() => setShareDialogOpen(false)}> + Cancel + </Button> + <Button onClick={shareFile}> + <Share2 className="h-4 w-4 mr-2" /> + Create Share Link + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* Rename Dialog */} + <Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>Rename</DialogTitle> + <DialogDescription> + {selectedFile?.type === 'file' + ? 'Enter the file name. (Extension will be preserved automatically)' + : 'Enter the folder name.' + } + </DialogDescription> + </DialogHeader> + + <div> + <Label htmlFor="item-name">New Name</Label> + <Input + id="item-name" + value={dialogValue} + onChange={(e) => setDialogValue(e.target.value)} + placeholder={ + selectedFile?.type === 'file' + ? selectedFile.name.substring(0, selectedFile.name.lastIndexOf('.')) + : selectedFile?.name + } + onKeyDown={(e) => { + if (e.key === 'Enter') { + renameItem(); + } + }} + /> + {selectedFile?.type === 'file' && ( + <p className="text-sm text-muted-foreground mt-1"> + Extension: {selectedFile.name.substring(selectedFile.name.lastIndexOf('.'))} + </p> + )} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setRenameDialogOpen(false); + setSelectedFile(null); + setDialogValue(''); + }} + > + Cancel + </Button> + <Button onClick={renameItem}>Rename</Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* Category Change Dialog (for folders) */} + <Dialog open={categoryDialogOpen} onOpenChange={setCategoryDialogOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>Change Category</DialogTitle> + <DialogDescription> + Changing category for {selectedFile?.name} folder. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <div> + <Label>New Category</Label> + <div className="mt-2 space-y-2"> + {Object.entries(categoryConfig).map(([key, config]) => ( + <div + key={key} + className={cn( + "flex items-center p-3 rounded-lg border cursor-pointer transition-colors", + newCategory === key && "bg-accent border-primary" + )} + onClick={() => setNewCategory(key)} + > + <config.icon className={cn("h-5 w-5 mr-3", config.color)} /> + <div className="flex-1"> + <div className="font-medium">{config.label}</div> + <div className="text-sm text-muted-foreground"> + {key === 'public' && 'External users can access freely'} + {key === 'restricted' && 'External users can only view'} + {key === 'confidential' && 'External users cannot access'} + {key === 'internal' && 'Internal use only'} + </div> + </div> + </div> + ))} + </div> + </div> + {selectedFile?.type === 'folder' && ( + <div className="flex items-center space-x-2"> + <Switch + id="apply-to-children" + checked={newCategory !== 'public' ? true : applyToChildren} + onCheckedChange={(checked) => { + if (newCategory === 'public') { + setApplyToChildren(checked); + } + }} + disabled={newCategory !== 'public'} + /> + <Label htmlFor="apply-to-children" className={cn( + newCategory !== 'public' && "text-muted-foreground" + )}> + Apply to all files and subfolders + {newCategory !== 'public' && ( + <span className="text-xs block mt-1"> + (Required for security categories) + </span> + )} + </Label> + </div> + )} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setCategoryDialogOpen(false); + setSelectedFile(null); + setApplyToChildren(false); + }} + > + Cancel + </Button> + <Button + onClick={() => { + if (selectedFile) { + changeCategory(selectedFile.id, newCategory, applyToChildren); + setCategoryDialogOpen(false); + setSelectedFile(null); + setApplyToChildren(false); + } + }} + > + Change + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* Secure Document Viewer Dialog */} + <Dialog + open={viewerDialogOpen} + onOpenChange={(open) => { + if (!open) { + setViewerDialogOpen(false); + setViewerFileUrl(null); + setSelectedFile(null); + } + }} + > + <DialogContent className="max-w-[90vw] max-h-[90vh] w-full h-full p-0"> + <DialogHeader className="px-6 py-4 border-b"> + <DialogTitle className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Eye className="h-5 w-5" /> + Secure Document Viewer + </div> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Lock className="h-4 w-4" /> + View Only Mode + </div> + </DialogTitle> + <DialogDescription> + <div className="flex items-center justify-between mt-2"> + <span>Viewing: {selectedFile?.name}</span> + <Badge variant="destructive" className="text-xs"> + <AlertCircle className="h-3 w-3 mr-1" /> + Protected Document - No Download/Copy/Print + </Badge> + </div> + </DialogDescription> + </DialogHeader> + + <div className="relative flex-1 h-[calc(90vh-120px)]"> + {viewerFileUrl && selectedFile && ( + <SecurePDFViewer + documentUrl={viewerFileUrl} + fileName={selectedFile.name} + onClose={() => { + setViewerDialogOpen(false); + setViewerFileUrl(null); + setSelectedFile(null); + }} + /> + )} + </div> + + <div className="px-6 py-3 border-t bg-muted/50"> + <div className="flex items-center justify-between text-xs text-muted-foreground"> + <div className="flex items-center gap-4"> + <span>Viewer: {session?.user?.email}</span> + <span>Time: {new Date().toLocaleString()}</span> + <span>IP logged for security</span> + </div> + <Button + size="sm" + variant="outline" + onClick={() => { + setViewerDialogOpen(false); + setViewerFileUrl(null); + setSelectedFile(null); + }} + > + <X className="h-4 w-4 mr-1" /> + Close Viewer + </Button> + </div> + </div> + </DialogContent> + </Dialog> + </div> + ); +}
\ No newline at end of file diff --git a/components/file-manager/FileManager.tsx b/components/file-manager/FileManager.tsx index fa2d8c38..f92f6b04 100644 --- a/components/file-manager/FileManager.tsx +++ b/components/file-manager/FileManager.tsx @@ -266,10 +266,6 @@ const TreeItem: React.FC<{ {isInternalUser && ( <> - <DropdownMenuItem onClick={() => onShare(item)}> - <Share2 className="h-4 w-4 mr-2" /> - Share - </DropdownMenuItem> {item.permissions?.canEdit && ( <DropdownMenuItem onClick={() => onRename(item)}> @@ -624,44 +620,6 @@ export function FileManager({ projectId }: FileManagerProps) { } }; - // Share file - 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(); - - // Copy share link to clipboard - await navigator.clipboard.writeText(data.shareUrl); - - toast({ - title: 'Share Link Created', - description: 'Link copied to clipboard.', - }); - - setShareDialogOpen(false); - setSelectedFile(null); - } catch (error) { - toast({ - title: 'Error', - description: 'Failed to create share link.', - variant: 'destructive', - }); - } - }; // Download multiple files const downloadMultipleFiles = async (itemIds: string[]) => { @@ -974,9 +932,9 @@ export function FileManager({ projectId }: FileManagerProps) { }; return ( - <div className="flex flex-col h-full"> - {/* Toolbar */} - <div className="border-b p-4"> + <div className="h-full flex flex-col min-h-0"> + {/* Toolbar - 고정 */} + <div className="border-b p-4 bg-background shrink-0"> <div className="flex items-center justify-between mb-3"> <div className="flex items-center gap-2"> {isInternalUser && ( @@ -1091,204 +1049,114 @@ export function FileManager({ projectId }: FileManagerProps) { </Breadcrumb> </div> - {/* File List */} - <ScrollArea className="flex-1 p-4"> - {loading ? ( - <div className="flex justify-center items-center h-64"> - <div className="text-muted-foreground">Loading...</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">Empty</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> + {/* File List - 스크롤 가능 영역 */} + <div className="flex-1 min-h-0"> + <ScrollArea className="h-full"> + <div className="p-4"> + {loading ? ( + <div className="flex justify-center items-center h-64"> + <div className="text-muted-foreground">Loading...</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">Empty</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; - {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> + 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" )} - </div> - )} - </div> - </ContextMenuTrigger> - - <ContextMenuContent> - {item.type === 'folder' && ( - <> - <ContextMenuItem onClick={() => handleFolderOpen(item)}> - Open - </ContextMenuItem> - <ContextMenuItem onClick={() => downloadFolder(item)}> - <Download className="h-4 w-4 mr-2" /> - Download Folder - </ContextMenuItem> - </> - )} - - {item.type === 'file' && ( - <> - <ContextMenuItem onClick={() => viewFile(item)}> - <Eye className="h-4 w-4 mr-2" /> - View - </ContextMenuItem> - {item.permissions?.canDownload === 'true' && ( - <ContextMenuItem onClick={() => downloadFile(item)}> - <Download className="h-4 w-4 mr-2" /> - Download - </ContextMenuItem> - )} - </> - )} - - {isInternalUser && ( - <> - <ContextMenuSeparator /> - <ContextMenuSub> - <ContextMenuSubTrigger> - <Shield className="h-4 w-4 mr-2" /> - Change Category - </ContextMenuSubTrigger> - <ContextMenuSubContent> - {Object.entries(categoryConfig).map(([key, config]) => ( - <ContextMenuItem - key={key} - onClick={() => { - if (item.type === 'folder') { - // Show dialog for folders - setSelectedFile(item); - setNewCategory(key); - setCategoryDialogOpen(true); - } else { - // Change immediately for files - changeCategory(item.id, key, false); - } - }} - > - <config.icon className={cn("h-4 w-4 mr-2", config.color)} /> - {config.label} - </ContextMenuItem> - ))} - </ContextMenuSubContent> - </ContextMenuSub> - - <ContextMenuItem - onClick={() => { - setSelectedFile(item); - setShareDialogOpen(true); + onClick={() => toggleItemSelection(item.id)} + onDoubleClick={() => { + if (item.type === 'folder') { + handleFolderOpen(item); + } }} > - <Share2 className="h-4 w-4 mr-2" /> - Share - </ContextMenuItem> - - {item.permissions?.canEdit && ( - <ContextMenuItem onClick={() => { - setSelectedFile(item); - setDialogValue(item.name); - setRenameDialogOpen(true); - }}> - <Edit2 className="h-4 w-4 mr-2" /> - Rename - </ContextMenuItem> - )} - </> - )} + <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.permissions?.canDelete && ( - <> - <ContextMenuSeparator /> - <ContextMenuItem - className="text-destructive" - onClick={() => deleteItems([item.id])} - > - <Trash2 className="h-4 w-4 mr-2" /> - Delete - </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} - onView={viewFile} - onDownload={downloadFile} - onDownloadFolder={downloadFolder} - onDelete={deleteItems} - onShare={(item) => { - setSelectedFile(item); - setShareDialogOpen(true); - }} - onRename={(item) => { - setSelectedFile(item); - setDialogValue(item.name); - setRenameDialogOpen(true); - }} - isInternalUser={isInternalUser} - /> - ))} + {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는 동일 ... */} + </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} + onView={viewFile} + onDownload={downloadFile} + onDownloadFolder={downloadFolder} + onDelete={deleteItems} + onShare={(item) => { + setSelectedFile(item); + setShareDialogOpen(true); + }} + onRename={(item) => { + setSelectedFile(item); + setDialogValue(item.name); + setRenameDialogOpen(true); + }} + isInternalUser={isInternalUser} + /> + ))} + </div> + )} </div> - )} - </ScrollArea> + </ScrollArea> + </div> {/* Upload Dialog */} <Dialog open={uploadDialogOpen} onOpenChange={setUploadDialogOpen}> - <DialogContent className="max-w-2xl"> + <DialogContent className="max-w-2xl max-h-[90vh] flex flex-col"> <DialogHeader> <DialogTitle>Upload Files</DialogTitle> <DialogDescription> @@ -1296,138 +1164,154 @@ export function FileManager({ projectId }: FileManagerProps) { </DialogDescription> </DialogHeader> - <div className="space-y-4"> - {/* Category Selection */} - <div> - <Label htmlFor="upload-category">Category</Label> - <Select value={uploadCategory} onValueChange={setUploadCategory}> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {Object.entries(categoryConfig) - .filter(([key]) => { - // 현재 폴더가 있는 경우 - if (currentParentId) { - const currentFolder = items.find(item => item.parentId === currentParentId); - // 현재 폴더가 public이 아니면 public 옵션 제외 - if (currentFolder && currentFolder.category !== 'public') { - return key !== 'public'; + <ScrollArea className="flex-1 pr-4"> + <div className="space-y-4"> + {/* Category Selection */} + <div> + <Label htmlFor="upload-category">Category</Label> + <Select value={uploadCategory} onValueChange={setUploadCategory}> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {Object.entries(categoryConfig) + .filter(([key]) => { + // 현재 폴더가 있는 경우 + if (currentParentId) { + const currentFolder = items.find(item => item.parentId === currentParentId); + // 현재 폴더가 public이 아니면 public 옵션 제외 + if (currentFolder && currentFolder.category !== 'public') { + return key !== 'public'; + } } - } - // 루트 폴더이거나 현재 폴더가 public인 경우 모든 옵션 표시 - return true; - }) - .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> - {/* 현재 폴더 정보 표시 (선택사항) */} - {currentParentId && (() => { - const currentFolder = items.find(item => item.parentId === currentParentId); - if (currentFolder && currentFolder.category !== 'public') { - return ( - <p className="text-xs text-muted-foreground mt-1 flex items-center"> - <AlertCircle className="h-3 w-3 mr-1" /> - Current folder is {categoryConfig[currentFolder.category].label}. - Public uploads are not allowed. - </p> - ); - } - })()} - </div> + // 루트 폴더이거나 현재 폴더가 public인 경우 모든 옵션 표시 + return true; + }) + .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> + {/* 현재 폴더 정보 표시 (선택사항) */} + {currentParentId && (() => { + const currentFolder = items.find(item => item.parentId === currentParentId); + if (currentFolder && currentFolder.category !== 'public') { + return ( + <p className="text-xs text-muted-foreground mt-1 flex items-center"> + <AlertCircle className="h-3 w-3 mr-1" /> + Current folder is {categoryConfig[currentFolder.category].label}. + Public uploads are not allowed. + </p> + ); + } + })()} + </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>Drag files or click to upload</DropzoneTitle> - <DropzoneDescription>Multiple files can be uploaded simultaneously</DropzoneDescription> - </div> - </DropzoneZone> - </Dropzone> - - {/* Uploading File List */} - {uploadingFiles.length > 0 && ( - <FileList> - <FileListHeader>Uploading Files</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>Uploading...</span>} - {uploadFile.status === 'processing' && <span>Processing...</span>} - {uploadFile.status === 'completed' && ( - <span className="text-green-600">Complete</span> - )} - {uploadFile.status === 'error' && ( - <span className="text-red-600">{uploadFile.error}</span> + {/* 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>Drag files or click to upload</DropzoneTitle> + <DropzoneDescription>Multiple files can be uploaded simultaneously</DropzoneDescription> + </div> + </DropzoneZone> + </Dropzone> + + {/* Uploading File List */} + {uploadingFiles.length > 0 && ( + <div className="border rounded-lg p-4 bg-muted/50"> + <div className="flex items-center justify-between mb-3"> + <h4 className="font-medium text-sm"> + Uploading Files ({uploadingFiles.filter(f => f.status === 'completed').length}/{uploadingFiles.length}) + </h4> + {uploadingFiles.every(f => f.status === 'completed' || f.status === 'error') && ( + <Button + size="sm" + variant="ghost" + onClick={() => setUploadingFiles([])} + > + <X className="h-4 w-4" /> + </Button> + )} + </div> + <div className="space-y-2 max-h-[300px] overflow-y-auto"> + {uploadingFiles.map((uploadFile, index) => ( + <div key={index} className="flex items-start gap-3 p-3 bg-background rounded-md"> + <File className="h-5 w-5 mt-0.5 flex-shrink-0 text-muted-foreground" /> + <div className="flex-1 min-w-0"> + <p className="text-sm font-medium truncate">{uploadFile.file.name}</p> + <div className="flex items-center gap-2 mt-1"> + <span className="text-xs text-muted-foreground"> + {formatFileSize(uploadFile.file.size)} + </span> + <span className="text-xs"> + {uploadFile.status === 'pending' && 'Waiting...'} + {uploadFile.status === 'uploading' && 'Uploading...'} + {uploadFile.status === 'processing' && 'Processing...'} + {uploadFile.status === 'completed' && ( + <span className="text-green-600 font-medium">✓ Complete</span> + )} + {uploadFile.status === 'error' && ( + <span className="text-red-600 font-medium">✗ {uploadFile.error}</span> + )} + </span> + </div> + {(uploadFile.status === 'uploading' || uploadFile.status === 'processing') && ( + <Progress value={uploadFile.progress} className="h-1.5 mt-2" /> )} </div> - {(uploadFile.status === 'uploading' || uploadFile.status === 'processing') && ( - <Progress value={uploadFile.progress} className="h-1 mt-1" /> + {uploadFile.status === 'error' && ( + <Button + size="sm" + variant="ghost" + onClick={() => { + setUploadingFiles(prev => + prev.filter((_, i) => i !== index) + ); + }} + > + <X className="h-4 w-4" /> + </Button> )} - </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> + </div> + ))} + </div> + </div> + )} + </div> + </ScrollArea> - <DialogFooter> + <DialogFooter className="mt-4"> <Button variant="outline" onClick={() => { @@ -1491,131 +1375,6 @@ export function FileManager({ projectId }: FileManagerProps) { </DialogContent> </Dialog> - {/* File Share Dialog */} - <Dialog open={shareDialogOpen} onOpenChange={setShareDialogOpen}> - <DialogContent className="max-w-md"> - <DialogHeader> - <DialogTitle>Share File</DialogTitle> - <DialogDescription> - Sharing {selectedFile?.name}. - </DialogDescription> - </DialogHeader> - - <Tabs defaultValue="link" className="w-full"> - <TabsList className="grid w-full grid-cols-2"> - <TabsTrigger value="link">Link Sharing</TabsTrigger> - <TabsTrigger value="permission">Permission Settings</TabsTrigger> - </TabsList> - - <TabsContent value="link" className="space-y-4"> - <div> - <Label htmlFor="access-level">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" /> - View Only - </div> - </SelectItem> - <SelectItem value="view_download"> - <div className="flex items-center"> - <Download className="h-4 w-4 mr-2" /> - View + Download - </div> - </SelectItem> - </SelectContent> - </Select> - </div> - - <div> - <Label htmlFor="password">Password (Optional)</Label> - <Input - id="password" - type="password" - value={shareSettings.password} - onChange={(e) => setShareSettings({ ...shareSettings, password: e.target.value })} - placeholder="Enter password" - /> - </div> - - <div> - <Label htmlFor="expires">Expiry Date (Optional)</Label> - <Input - id="expires" - type="datetime-local" - value={shareSettings.expiresAt} - onChange={(e) => setShareSettings({ ...shareSettings, expiresAt: e.target.value })} - /> - </div> - - <div> - <Label htmlFor="max-downloads">Max Downloads (Optional)</Label> - <Input - id="max-downloads" - type="number" - value={shareSettings.maxDownloads} - onChange={(e) => setShareSettings({ ...shareSettings, maxDownloads: e.target.value })} - placeholder="Unlimited" - /> - </div> - </TabsContent> - - <TabsContent value="permission" className="space-y-4"> - <div> - <Label htmlFor="target-domain">Target Domain</Label> - <Select> - <SelectTrigger> - <SelectValue placeholder="Select domain" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="partners">Partners</SelectItem> - <SelectItem value="internal">Internal</SelectItem> - </SelectContent> - </Select> - </div> - - <div className="space-y-2"> - <Label>Permissions</Label> - <div className="space-y-2"> - <div className="flex items-center justify-between"> - <Label htmlFor="can-view" className="text-sm font-normal">View</Label> - <Switch id="can-view" defaultChecked /> - </div> - <div className="flex items-center justify-between"> - <Label htmlFor="can-download" className="text-sm font-normal">Download</Label> - <Switch id="can-download" /> - </div> - <div className="flex items-center justify-between"> - <Label htmlFor="can-edit" className="text-sm font-normal">Edit</Label> - <Switch id="can-edit" /> - </div> - <div className="flex items-center justify-between"> - <Label htmlFor="can-share" className="text-sm font-normal">Share</Label> - <Switch id="can-share" /> - </div> - </div> - </div> - </TabsContent> - </Tabs> - - <DialogFooter> - <Button variant="outline" onClick={() => setShareDialogOpen(false)}> - Cancel - </Button> - <Button onClick={shareFile}> - <Share2 className="h-4 w-4 mr-2" /> - Create Share Link - </Button> - </DialogFooter> - </DialogContent> - </Dialog> {/* Rename Dialog */} <Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}> diff --git a/components/file-manager/SecurePDFViewer.tsx b/components/file-manager/SecurePDFViewer.tsx index cd7c081a..707d95dc 100644 --- a/components/file-manager/SecurePDFViewer.tsx +++ b/components/file-manager/SecurePDFViewer.tsx @@ -5,6 +5,7 @@ import { useSession } from 'next-auth/react'; import { WebViewerInstance } from '@pdftron/webviewer'; import { Loader2 } from 'lucide-react'; import { toast } from 'sonner'; +import { createCustomWatermark } from './creaetWaterMarks'; interface SecurePDFViewerProps { documentUrl: string; @@ -194,14 +195,16 @@ export function SecurePDFViewer({ documentUrl, fileName, onClose }: SecurePDFVie const watermarkText = `${session?.user?.email || 'CONFIDENTIAL'}\n${new Date().toLocaleString()}`; // 대각선 워터마크 - documentViewer.setWatermark({ + documentViewer.setWatermark( + {custom:createCustomWatermark({ text: watermarkText, fontSize: 30, fontFamily: 'Arial', color: 'rgba(255, 0, 0, 0.3)', opacity: 30, - diagonal: true, - }); + // diagonal: true, + })} + ); // 각 페이지에 커스텀 워터마크 추가 const pageCount = documentViewer.getPageCount(); diff --git a/components/file-manager/creaetWaterMarks.tsx b/components/file-manager/creaetWaterMarks.tsx new file mode 100644 index 00000000..524b18ee --- /dev/null +++ b/components/file-manager/creaetWaterMarks.tsx @@ -0,0 +1,71 @@ +export const createCustomWatermark: CreateCustomWatermark = ({ + text, + fontSize, + color, + opacity, + rotation = -45, + fontFamily = "Helvetica", + }) => { + return (ctx, pageNumber, pageWidth, pageHeight) => { + if (!text) return; + + const lines = text.split("\n"); // 줄바꿈 기준 멀치 처리 + + ctx.save(); + ctx.translate(pageWidth / 2, pageHeight / 2); + ctx.rotate((rotation * Math.PI) / 180); + ctx.fillStyle = color; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + const lineHeights = lines.map((s) => { + return fontSize; + }); + + const totalHeight = + lineHeights.reduce((sum, h) => sum + h, 0) - lineHeights[0]; // 첫 줄은 기준선 0 + + let yOffset = -totalHeight / 2; + + lines.forEach((line, i) => { + ctx.font = `900 ${fontSize}px ${fontFamily}`; + ctx.fillText(line, 0, yOffset); + yOffset += lineHeights[i]; + }); + + ctx.restore(); + }; + }; + + + import { Core, WebViewerInstance } from "@pdftron/webviewer"; + +export interface WaterMarkOption { + fontSize: number; + color: string; + opacity: number; + rotation: number; + fontFamily: string; + split: boolean; + shipNameCheck: boolean; + shipName: string; + ownerNameCheck: boolean; + ownerName: string; + classNameCheck: boolean; + className: string; + classList: string[]; + customCheck: boolean; + text: string; +} + +type CreateCustomWatermark = ({ + text, + fontSize, + color, + opacity, + rotation, + fontFamily, +}: Pick< + WaterMarkOption, + "text" | "fontSize" | "color" | "opacity" | "rotation" | "fontFamily" +>) => Core.DocumentViewer.CustomWatermarkCallback;
\ No newline at end of file |
