화면관리 ui개선 및 파일업로드 설정

This commit is contained in:
kjs
2025-10-15 13:30:11 +09:00
parent 5a8efa51af
commit 3d242c1c8e
14 changed files with 598 additions and 584 deletions

View File

@@ -11,37 +11,37 @@ import { API_BASE_URL } from "@/lib/api/client";
// Office 문서 렌더링을 위한 CDN 라이브러리 로드
const loadOfficeLibrariesFromCDN = async () => {
if (typeof window === 'undefined') return { XLSX: null, mammoth: null };
if (typeof window === "undefined") return { XLSX: null, mammoth: null };
try {
// XLSX 라이브러리가 이미 로드되어 있는지 확인
if (!(window as any).XLSX) {
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js';
const script = document.createElement("script");
script.src = "https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js";
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// mammoth 라이브러리가 이미 로드되어 있는지 확인
if (!(window as any).mammoth) {
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.4.2/mammoth.browser.min.js';
const script = document.createElement("script");
script.src = "https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.4.2/mammoth.browser.min.js";
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
return {
XLSX: (window as any).XLSX,
mammoth: (window as any).mammoth
mammoth: (window as any).mammoth,
};
} catch (error) {
console.error('Office 라이브러리 CDN 로드 실패:', error);
console.error("Office 라이브러리 CDN 로드 실패:", error);
return { XLSX: null, mammoth: null };
}
};
@@ -57,13 +57,7 @@ interface FileViewerModalProps {
/**
* 파일 뷰어 모달 컴포넌트
*/
export const FileViewerModal: React.FC<FileViewerModalProps> = ({
file,
isOpen,
onClose,
onDownload,
onDelete,
}) => {
export const FileViewerModal: React.FC<FileViewerModalProps> = ({ file, isOpen, onClose, onDownload, onDelete }) => {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
@@ -73,37 +67,37 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
const renderOfficeDocument = async (blob: Blob, fileExt: string, fileName: string) => {
try {
setIsLoading(true);
// CDN에서 라이브러리 로드
const { XLSX, mammoth } = await loadOfficeLibrariesFromCDN();
if (fileExt === "docx" && mammoth) {
// Word 문서 렌더링
const arrayBuffer = await blob.arrayBuffer();
const result = await mammoth.convertToHtml({ arrayBuffer });
const htmlContent = `
<div>
<h4 style="margin: 0 0 15px 0; color: #333; font-size: 16px;">📄 ${fileName}</h4>
<div class="word-content" style="max-height: 500px; overflow-y: auto; padding: 20px; background: white; border: 1px solid #ddd; border-radius: 5px; line-height: 1.6; font-family: 'Times New Roman', serif;">
${result.value || '내용을 읽을 수 없습니다.'}
${result.value || "내용을 읽을 수 없습니다."}
</div>
</div>
`;
setRenderedContent(htmlContent);
return true;
} else if (["xlsx", "xls"].includes(fileExt) && XLSX) {
// Excel 문서 렌더링
const arrayBuffer = await blob.arrayBuffer();
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
const workbook = XLSX.read(arrayBuffer, { type: "array" });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const html = XLSX.utils.sheet_to_html(worksheet, {
table: { className: 'excel-table' }
table: { className: "excel-table" },
});
const htmlContent = `
<div>
<h4 style="margin: 0 0 10px 0; color: #333; font-size: 16px;">📊 ${fileName}</h4>
@@ -118,7 +112,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
</div>
</div>
`;
setRenderedContent(htmlContent);
return true;
} else if (fileExt === "doc") {
@@ -130,7 +124,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
<p style="color: #666; font-size: 14px;">(.docx 파일만 미리보기 지원)</p>
</div>
`;
setRenderedContent(htmlContent);
return true;
} else if (["ppt", "pptx"].includes(fileExt)) {
@@ -142,22 +136,22 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
<p style="color: #666; font-size: 14px;">파일을 다운로드하여 확인해주세요.</p>
</div>
`;
setRenderedContent(htmlContent);
return true;
}
return false; // 지원하지 않는 형식
} catch (error) {
console.error("Office 문서 렌더링 오류:", error);
const htmlContent = `
<div style="color: red; text-align: center; padding: 20px;">
Office 문서를 읽을 수 없습니다.<br>
파일이 손상되었거나 지원하지 않는 형식일 수 있습니다.
</div>
`;
setRenderedContent(htmlContent);
return true; // 오류 메시지라도 표시
} finally {
@@ -182,7 +176,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
const url = URL.createObjectURL(file._file);
setPreviewUrl(url);
setIsLoading(false);
return () => URL.revokeObjectURL(url);
}
@@ -192,20 +186,35 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
const generatePreviewUrl = async () => {
try {
const fileExt = file.fileExt.toLowerCase();
// 미리보기 지원 파일 타입 정의
const imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "svg"];
const documentExtensions = ["pdf","doc", "docx", "xls", "xlsx", "ppt", "pptx", "rtf", "odt", "ods", "odp", "hwp", "hwpx", "hwpml", "hcdt", "hpt", "pages", "numbers", "keynote"];
const documentExtensions = [
"pdf",
"doc",
"docx",
"xls",
"xlsx",
"ppt",
"pptx",
"rtf",
"odt",
"ods",
"odp",
"hwp",
"hwpx",
"hwpml",
"hcdt",
"hpt",
"pages",
"numbers",
"keynote",
];
const textExtensions = ["txt", "md", "json", "xml", "csv"];
const mediaExtensions = ["mp4", "webm", "ogg", "mp3", "wav"];
const supportedExtensions = [
...imageExtensions,
...documentExtensions,
...textExtensions,
...mediaExtensions
];
const supportedExtensions = [...imageExtensions, ...documentExtensions, ...textExtensions, ...mediaExtensions];
if (supportedExtensions.includes(fileExt)) {
// 이미지나 PDF는 인증된 요청으로 Blob 생성
if (imageExtensions.includes(fileExt) || fileExt === "pdf") {
@@ -213,15 +222,15 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
// 인증된 요청으로 파일 데이터 가져오기
const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, {
headers: {
"Authorization": `Bearer ${localStorage.getItem("authToken")}`,
Authorization: `Bearer ${localStorage.getItem("authToken")}`,
},
});
if (response.ok) {
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
setPreviewUrl(blobUrl);
// 컴포넌트 언마운트 시 URL 정리를 위해 cleanup 함수 저장
cleanup = () => URL.revokeObjectURL(blobUrl);
} else {
@@ -236,20 +245,20 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
try {
const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
Authorization: `Bearer ${localStorage.getItem("authToken")}`,
},
});
if (response.ok) {
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
// Office 문서를 위한 특별한 처리 - CDN 라이브러리 사용
if (["doc", "docx", "xls", "xlsx", "ppt", "pptx"].includes(fileExt)) {
// CDN 라이브러리로 클라이언트 사이드 렌더링 시도
try {
const renderSuccess = await renderOfficeDocument(blob, fileExt, file.realFileName);
if (!renderSuccess) {
// 렌더링 실패 시 Blob URL 사용
setPreviewUrl(blobUrl);
@@ -263,7 +272,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
// 기타 문서는 직접 Blob URL 사용
setPreviewUrl(blobUrl);
}
return () => URL.revokeObjectURL(blobUrl); // Cleanup function
} else {
throw new Error(`HTTP ${response.status}`);
@@ -291,7 +300,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
};
generatePreviewUrl();
// cleanup 함수 반환
return () => {
if (cleanup) {
@@ -306,24 +315,20 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
const renderPreview = () => {
if (isLoading) {
return (
<div className="flex items-center justify-center h-96">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<div className="flex h-96 items-center justify-center">
<div className="h-12 w-12 animate-spin rounded-full border-b-2 border-blue-600"></div>
</div>
);
}
if (previewError) {
return (
<div className="flex flex-col items-center justify-center h-96">
<AlertTriangle className="w-16 h-16 mb-4 text-yellow-500" />
<p className="text-lg font-medium mb-2"> </p>
<p className="text-sm text-center">{previewError}</p>
<Button
variant="outline"
onClick={() => onDownload?.(file)}
className="mt-4"
>
<Download className="w-4 h-4 mr-2" />
<div className="flex h-96 flex-col items-center justify-center">
<AlertTriangle className="mb-4 h-16 w-16 text-yellow-500" />
<p className="mb-2 text-lg font-medium"> </p>
<p className="text-center text-sm">{previewError}</p>
<Button variant="outline" onClick={() => onDownload?.(file)} className="mt-4">
<Download className="mr-2 h-4 w-4" />
</Button>
</div>
@@ -335,11 +340,11 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
// 이미지 파일
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(fileExt)) {
return (
<div className="flex items-center justify-center max-h-96 overflow-hidden">
<div className="flex max-h-96 items-center justify-center overflow-hidden">
<img
src={previewUrl || ""}
alt={file.realFileName}
className="max-w-full max-h-full object-contain rounded-lg shadow-lg"
className="max-h-full max-w-full rounded-lg object-contain shadow-lg"
onError={(e) => {
console.error("이미지 로드 오류:", previewUrl, e);
setPreviewError("이미지를 불러올 수 없습니다. 파일이 손상되었거나 서버에서 접근할 수 없습니다.");
@@ -358,100 +363,83 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
<div className="h-96 overflow-auto">
<iframe
src={previewUrl || ""}
className="w-full h-full border rounded-lg"
className="h-full w-full rounded-lg border"
onError={() => setPreviewError("텍스트 파일을 불러올 수 없습니다.")}
/>
</div>
);
}
// PDF 파일
// PDF 파일 - 브라우저 기본 뷰어 사용
if (fileExt === "pdf") {
return (
<div className="h-96 overflow-auto">
<iframe
src={previewUrl || ""}
className="w-full h-full border rounded-lg"
onError={() => setPreviewError("PDF 파일을 불러올 수 없습니다.")}
/>
<div className="h-[600px] overflow-auto rounded-lg border bg-gray-50">
<object
data={previewUrl || ""}
type="application/pdf"
className="h-full w-full rounded-lg"
title="PDF Viewer"
>
<iframe src={previewUrl || ""} className="h-full w-full rounded-lg" title="PDF Viewer Fallback">
<div className="flex h-full flex-col items-center justify-center p-8">
<FileText className="mb-4 h-16 w-16 text-gray-400" />
<p className="mb-2 text-lg font-medium">PDF를 </p>
<p className="mb-4 text-center text-sm text-gray-600">
PDF . .
</p>
<Button variant="outline" onClick={() => onDownload?.(file)}>
<Download className="mr-2 h-4 w-4" />
PDF
</Button>
</div>
</iframe>
</object>
</div>
);
}
// Office 문서 (CDN 라이브러리 렌더링 또는 iframe)
// Office 문서 - 모든 Office 문서는 다운로드 권장
if (
["doc", "docx", "xls", "xlsx", "ppt", "pptx", "hwp", "hwpx", "hwpml", "hcdt", "hpt", "pages", "numbers", "keynote"].includes(fileExt)
[
"doc",
"docx",
"xls",
"xlsx",
"ppt",
"pptx",
"hwp",
"hwpx",
"hwpml",
"hcdt",
"hpt",
"pages",
"numbers",
"keynote",
].includes(fileExt)
) {
// CDN 라이브러리로 렌더링된 콘텐츠가 있는 경우
if (renderedContent) {
return (
<div className="relative h-96 overflow-auto">
<div
className="w-full h-full p-4 border rounded-lg bg-white"
dangerouslySetInnerHTML={{ __html: renderedContent }}
/>
</div>
);
}
// iframe 방식 (fallback)
// Office 문서 안내 메시지 표시
return (
<div className="relative h-96 overflow-auto">
<iframe
src={previewUrl || ""}
className="w-full h-full border rounded-lg"
onError={() => {
console.log("iframe 오류 발생, fallback 옵션 제공");
setPreviewError("이 Office 문서는 브라우저에서 직접 미리보기할 수 없습니다. 다운로드하여 확인해주세요.");
}}
title={`${file.realFileName} 미리보기`}
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
onLoad={() => setIsLoading(false)}
/>
{/* 로딩 상태 */}
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-90">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-gray-600">Office ...</p>
<p className="text-xs text-gray-400 mt-1"> </p>
</div>
</div>
)}
{/* 오류 발생 시 fallback 옵션 */}
{previewError && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-white">
<FileText className="w-16 h-16 mb-4 text-orange-500" />
<p className="text-lg font-medium mb-2"> </p>
<p className="text-sm text-center mb-4 text-gray-600">
{previewError}
</p>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => onDownload?.(file)}
>
<Download className="w-4 h-4 mr-2" />
</Button>
<Button
variant="ghost"
onClick={() => {
// 새 탭에서 파일 열기 시도
const link = document.createElement('a');
link.href = previewUrl || '';
link.target = '_blank';
link.click();
}}
>
<ExternalLink className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
)}
<div className="relative flex h-96 flex-col items-center justify-center overflow-auto rounded-lg border bg-gradient-to-br from-blue-50 to-indigo-50 p-8">
<FileText className="mb-6 h-20 w-20 text-blue-500" />
<h3 className="mb-2 text-xl font-semibold text-gray-800">Office </h3>
<p className="mb-6 max-w-md text-center text-sm text-gray-600">
{fileExt === "docx" || fileExt === "doc"
? "Word 문서"
: fileExt === "xlsx" || fileExt === "xls"
? "Excel 문서"
: fileExt === "pptx" || fileExt === "ppt"
? "PowerPoint 문서"
: "Office 문서"}
.
<br />
.
</p>
<div className="flex gap-3">
<Button onClick={() => onDownload?.(file)} size="lg" className="shadow-md">
<Download className="mr-2 h-5 w-5" />
</Button>
</div>
</div>
);
}
@@ -460,11 +448,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
if (["mp4", "webm", "ogg"].includes(fileExt)) {
return (
<div className="flex items-center justify-center">
<video
controls
className="w-full max-h-96"
onError={() => setPreviewError("비디오를 재생할 수 없습니다.")}
>
<video controls className="max-h-96 w-full" onError={() => setPreviewError("비디오를 재생할 수 없습니다.")}>
<source src={previewUrl || ""} type={`video/${fileExt}`} />
</video>
</div>
@@ -474,9 +458,9 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
// 오디오 파일
if (["mp3", "wav", "ogg"].includes(fileExt)) {
return (
<div className="flex flex-col items-center justify-center h-96">
<div className="w-32 h-32 bg-gray-100 rounded-full flex items-center justify-center mb-6">
<svg className="w-16 h-16 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<div className="flex h-96 flex-col items-center justify-center">
<div className="mb-6 flex h-32 w-32 items-center justify-center rounded-full bg-gray-100">
<svg className="h-16 w-16 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM15.657 6.343a1 1 0 011.414 0A9.972 9.972 0 0119 12a9.972 9.972 0 01-1.929 5.657 1 1 0 11-1.414-1.414A7.971 7.971 0 0017 12c0-1.594-.471-3.078-1.343-4.343a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 12a5.984 5.984 0 01-.757 2.829 1 1 0 01-1.415-1.414A3.987 3.987 0 0013 12a3.988 3.988 0 00-.172-1.171 1 1 0 010-1.414z"
@@ -484,11 +468,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
/>
</svg>
</div>
<audio
controls
className="w-full max-w-md"
onError={() => setPreviewError("오디오를 재생할 수 없습니다.")}
>
<audio controls className="w-full max-w-md" onError={() => setPreviewError("오디오를 재생할 수 없습니다.")}>
<source src={previewUrl || ""} type={`audio/${fileExt}`} />
</audio>
</div>
@@ -497,17 +477,12 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
// 기타 파일 타입
return (
<div className="flex flex-col items-center justify-center h-96">
<FileText className="w-16 h-16 mb-4 text-gray-400" />
<p className="text-lg font-medium mb-2"> </p>
<p className="text-sm text-center mb-4">
{file.fileExt.toUpperCase()} .
</p>
<Button
variant="outline"
onClick={() => onDownload?.(file)}
>
<Download className="w-4 h-4 mr-2" />
<div className="flex h-96 flex-col items-center justify-center">
<FileText className="mb-4 h-16 w-16 text-gray-400" />
<p className="mb-2 text-lg font-medium"> </p>
<p className="mb-4 text-center text-sm">{file.fileExt.toUpperCase()} .</p>
<Button variant="outline" onClick={() => onDownload?.(file)}>
<Download className="mr-2 h-4 w-4" />
</Button>
</div>
@@ -516,65 +491,53 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
return (
<Dialog open={isOpen} onOpenChange={() => {}}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto [&>button]:hidden">
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto [&>button]:hidden">
<DialogHeader>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<DialogTitle className="text-lg font-semibold truncate">
{file.realFileName}
</DialogTitle>
<DialogTitle className="truncate text-lg font-semibold">{file.realFileName}</DialogTitle>
<Badge variant="secondary" className="text-xs">
{file.fileExt.toUpperCase()}
</Badge>
</div>
</div>
<DialogDescription>
: {formatFileSize(file.size)} | : {file.fileExt.toUpperCase()}
: {formatFileSize(file.fileSize || file.size || 0)} | : {file.fileExt.toUpperCase()}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
{renderPreview()}
</div>
<div className="flex-1 overflow-y-auto">{renderPreview()}</div>
{/* 파일 정보 및 액션 버튼 */}
<div className="flex items-center space-x-4 text-sm text-gray-500 mt-2">
<span>: {formatFileSize(file.size)}</span>
{file.uploadedAt && (
<span>: {new Date(file.uploadedAt).toLocaleString()}</span>
<div className="mt-2 flex items-center space-x-4 text-sm text-gray-500">
<span>: {formatFileSize(file.fileSize || file.size || 0)}</span>
{(file.uploadedAt || file.regdate) && (
<span>: {new Date(file.uploadedAt || file.regdate || "").toLocaleString()}</span>
)}
</div>
<div className="flex justify-end space-x-2 pt-4 border-t">
<Button
variant="outline"
size="sm"
onClick={() => onDownload?.(file)}
>
<Download className="w-4 h-4 mr-2" />
<div className="flex justify-end space-x-2 border-t pt-4">
<Button variant="outline" size="sm" onClick={() => onDownload?.(file)}>
<Download className="mr-2 h-4 w-4" />
</Button>
{onDelete && (
<Button
variant="outline"
size="sm"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={() => onDelete(file)}
>
<Trash2 className="w-4 h-4 mr-2" />
<Trash2 className="mr-2 h-4 w-4" />
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={onClose}
>
<X className="w-4 h-4 mr-2" />
<Button variant="outline" size="sm" onClick={onClose}>
<X className="mr-2 h-4 w-4" />
</Button>
</div>
</DialogContent>
</Dialog>
);
};
};