ERP-node/frontend/components/GlobalFileViewer.tsx

304 lines
11 KiB
TypeScript
Raw Permalink Normal View History

2025-09-29 13:29:03 +09:00
"use client";
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { GlobalFileManager, GlobalFileInfo } from "@/lib/api/globalFile";
import { downloadFile } from "@/lib/api/file";
import { FileViewerModal } from "@/lib/registry/components/file-upload/FileViewerModal";
import { formatFileSize } from "@/lib/utils";
import { toast } from "sonner";
import {
File,
FileText,
Image,
Video,
Music,
Archive,
Download,
Eye,
Search,
Trash2,
Clock,
MapPin,
Monitor,
RefreshCw,
Info,
} from "lucide-react";
interface GlobalFileViewerProps {
showControls?: boolean;
maxHeight?: string;
className?: string;
}
export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
showControls = true,
maxHeight = "600px",
className = "",
}) => {
const [allFiles, setAllFiles] = useState<GlobalFileInfo[]>([]);
const [filteredFiles, setFilteredFiles] = useState<GlobalFileInfo[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [selectedTab, setSelectedTab] = useState("all");
const [viewerFile, setViewerFile] = useState<GlobalFileInfo | null>(null);
const [isViewerOpen, setIsViewerOpen] = useState(false);
const [registryInfo, setRegistryInfo] = useState({
totalFiles: 0,
accessibleFiles: 0,
pages: [] as string[],
screens: [] as number[],
});
// 파일 아이콘 가져오기
const getFileIcon = (fileName: string, size: number = 16) => {
const extension = fileName.split('.').pop()?.toLowerCase() || '';
const iconProps = { size, className: "text-muted-foreground" };
2025-09-29 13:29:03 +09:00
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(extension)) {
return <Image {...iconProps} className="text-primary" />;
2025-09-29 13:29:03 +09:00
}
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'].includes(extension)) {
return <Video {...iconProps} className="text-purple-600" />;
}
if (['mp3', 'wav', 'flac', 'aac', 'ogg'].includes(extension)) {
return <Music {...iconProps} className="text-green-600" />;
}
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extension)) {
return <Archive {...iconProps} className="text-yellow-600" />;
}
if (['txt', 'md', 'doc', 'docx', 'pdf', 'rtf'].includes(extension)) {
return <FileText {...iconProps} className="text-destructive" />;
2025-09-29 13:29:03 +09:00
}
return <File {...iconProps} />;
};
// 파일 목록 새로고침
const refreshFiles = () => {
const files = GlobalFileManager.getAllAccessibleFiles();
const info = GlobalFileManager.getRegistryInfo();
setAllFiles(files);
setRegistryInfo(info);
// 탭에 따른 필터링
filterFilesByTab(files, selectedTab, searchQuery);
console.log("🔄 전역 파일 목록 새로고침:", files.length + "개");
};
// 탭별 파일 필터링
const filterFilesByTab = (files: GlobalFileInfo[], tab: string, query: string) => {
let filtered = files;
// 탭별 필터링
if (tab === "images") {
filtered = files.filter(file => {
const ext = file.realFileName?.split('.').pop()?.toLowerCase() || '';
return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext);
});
} else if (tab === "documents") {
filtered = files.filter(file => {
const ext = file.realFileName?.split('.').pop()?.toLowerCase() || '';
return ['txt', 'md', 'doc', 'docx', 'pdf', 'rtf', 'hwp', 'hwpx'].includes(ext);
});
} else if (tab === "recent") {
filtered = files
.sort((a, b) => new Date(b.uploadTime).getTime() - new Date(a.uploadTime).getTime())
.slice(0, 20);
}
// 검색 필터링
if (query.trim()) {
const lowerQuery = query.toLowerCase();
filtered = filtered.filter(file =>
file.realFileName?.toLowerCase().includes(lowerQuery) ||
file.savedFileName?.toLowerCase().includes(lowerQuery) ||
file.uploadPage?.toLowerCase().includes(lowerQuery)
);
}
setFilteredFiles(filtered);
};
// 파일 다운로드
const handleDownload = async (file: GlobalFileInfo) => {
try {
await downloadFile({
fileId: file.objid,
originalName: file.realFileName || file.savedFileName || "download",
});
toast.success(`파일 다운로드 시작: ${file.realFileName}`);
} catch (error) {
console.error("파일 다운로드 오류:", error);
toast.error("파일 다운로드에 실패했습니다.");
}
};
// 파일 뷰어 열기
const handleView = (file: GlobalFileInfo) => {
setViewerFile(file);
setIsViewerOpen(true);
};
// 파일 접근 불가능하게 설정 (삭제 대신)
const handleRemove = (file: GlobalFileInfo) => {
GlobalFileManager.setFileAccessible(file.objid, false);
refreshFiles();
toast.success(`파일이 목록에서 제거되었습니다: ${file.realFileName}`);
};
// 초기 로드 및 검색/탭 변경 시 필터링
useEffect(() => {
refreshFiles();
}, []);
useEffect(() => {
filterFilesByTab(allFiles, selectedTab, searchQuery);
}, [allFiles, selectedTab, searchQuery]);
return (
<div className={`w-full ${className}`}>
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle className="flex items-center gap-2">
<File className="w-5 h-5" />
</CardTitle>
{showControls && (
<div className="flex items-center gap-2">
<Badge variant="secondary" className="flex items-center gap-1">
<Info className="w-3 h-3" />
{registryInfo.accessibleFiles}
</Badge>
<Button
variant="outline"
size="sm"
onClick={refreshFiles}
className="flex items-center gap-1"
>
<RefreshCw className="w-3 h-3" />
</Button>
</div>
)}
</div>
{showControls && (
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
placeholder="파일명으로 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
</div>
)}
</CardHeader>
<CardContent>
<Tabs value={selectedTab} onValueChange={setSelectedTab}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="all"></TabsTrigger>
<TabsTrigger value="recent"></TabsTrigger>
<TabsTrigger value="images"></TabsTrigger>
<TabsTrigger value="documents"></TabsTrigger>
</TabsList>
<TabsContent value={selectedTab} className="mt-4">
<div
className="space-y-2 overflow-y-auto"
style={{ maxHeight }}
>
{filteredFiles.length === 0 ? (
<div className="text-center py-8 text-gray-500">
{searchQuery ? "검색 결과가 없습니다." : "저장된 파일이 없습니다."}
</div>
) : (
filteredFiles.map((file) => (
<Card key={file.objid} className="p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
{getFileIcon(file.realFileName || file.savedFileName || "", 20)}
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{file.realFileName || file.savedFileName}
</div>
<div className="text-sm text-gray-500 flex items-center gap-2">
<span>{formatFileSize(file.fileSize)}</span>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{new Date(file.uploadTime).toLocaleDateString()}
</div>
<div className="flex items-center gap-1">
<MapPin className="w-3 h-3" />
{file.uploadPage.split('/').pop() || 'Unknown'}
</div>
{file.screenId && (
<div className="flex items-center gap-1">
<Monitor className="w-3 h-3" />
Screen {file.screenId}
</div>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleView(file)}
className="flex items-center gap-1"
>
<Eye className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDownload(file)}
className="flex items-center gap-1"
>
<Download className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemove(file)}
className="flex items-center gap-1 text-destructive hover:text-red-700"
2025-09-29 13:29:03 +09:00
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
</Card>
))
)}
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
{/* 파일 뷰어 모달 */}
{viewerFile && (
<FileViewerModal
file={viewerFile}
isOpen={isViewerOpen}
onClose={() => {
setIsViewerOpen(false);
setViewerFile(null);
}}
/>
)}
</div>
);
};