feat: 파일 정보 조회 API 추가 및 파일 업로드 컴포넌트 개선
- 파일 정보 조회를 위한 getFileInfo 함수를 추가하여, 파일의 메타데이터를 공개 접근으로 조회할 수 있도록 하였습니다. - 파일 업로드 컴포넌트에서 파일 아이콘 매핑 및 파일 미리보기 기능을 개선하여 사용자 경험을 향상시켰습니다. - V2 파일 업로드 컴포넌트의 설정 패널을 추가하여, 파일 업로드 관련 설정을 보다 쉽게 관리할 수 있도록 하였습니다. - 파일 뷰어 모달을 추가하여 다양한 파일 형식의 미리보기를 지원합니다.
This commit is contained in:
parent
21a663a99b
commit
ad7c5923a6
|
|
@ -1261,5 +1261,56 @@ export const setRepresentativeFile = async (
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 파일 정보 조회 (메타데이터만, 파일 내용 없음)
|
||||
* 공개 접근 허용
|
||||
*/
|
||||
export const getFileInfo = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { objid } = req.params;
|
||||
|
||||
if (!objid) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "파일 ID가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 파일 정보 조회
|
||||
const fileRecord = await queryOne<any>(
|
||||
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate, is_representative
|
||||
FROM attach_file_info
|
||||
WHERE objid = $1 AND status = 'ACTIVE'`,
|
||||
[parseInt(objid)]
|
||||
);
|
||||
|
||||
if (!fileRecord) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "파일을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
objid: fileRecord.objid.toString(),
|
||||
realFileName: fileRecord.real_file_name,
|
||||
fileSize: fileRecord.file_size,
|
||||
fileExt: fileRecord.file_ext,
|
||||
filePath: fileRecord.file_path,
|
||||
regdate: fileRecord.regdate,
|
||||
isRepresentative: fileRecord.is_representative,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("파일 정보 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "파일 정보 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Multer 미들웨어 export
|
||||
export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
generateTempToken,
|
||||
getFileByToken,
|
||||
setRepresentativeFile,
|
||||
getFileInfo,
|
||||
} from "../controllers/fileController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
|
|
@ -31,6 +32,13 @@ router.get("/public/:token", getFileByToken);
|
|||
*/
|
||||
router.get("/preview/:objid", previewFile);
|
||||
|
||||
/**
|
||||
* @route GET /api/files/info/:objid
|
||||
* @desc 파일 정보 조회 (메타데이터만, 파일 내용 없음) - 공개 접근 허용
|
||||
* @access Public
|
||||
*/
|
||||
router.get("/info/:objid", getFileInfo);
|
||||
|
||||
// 모든 파일 API는 인증 필요
|
||||
router.use(authenticateToken);
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||
import { apiClient, getCurrentUser, UserInfo } from "@/lib/api/client";
|
||||
import { apiClient, getCurrentUser, UserInfo, getFullImageUrl } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -2224,6 +2224,37 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
// 파일 타입 컬럼 처리 (가상 파일 컬럼 포함)
|
||||
const isFileColumn = actualWebType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn;
|
||||
|
||||
// 🖼️ 이미지 타입 컬럼: 썸네일로 표시
|
||||
const isImageColumn = actualWebType === "image" || actualWebType === "img";
|
||||
if (isImageColumn && value) {
|
||||
// value가 objid (숫자 또는 숫자 문자열)인 경우 파일 API URL 사용
|
||||
// 🔑 download 대신 preview 사용 (공개 접근 허용)
|
||||
const isObjid = /^\d+$/.test(String(value));
|
||||
const imageUrl = isObjid
|
||||
? `/api/files/preview/${value}`
|
||||
: getFullImageUrl(String(value));
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="이미지"
|
||||
className="h-10 w-10 rounded object-cover cursor-pointer hover:opacity-80 transition-opacity"
|
||||
style={{ maxWidth: "40px", maxHeight: "40px" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 이미지 클릭 시 크게 보기 (새 탭에서 열기)
|
||||
window.open(imageUrl, "_blank");
|
||||
}}
|
||||
onError={(e) => {
|
||||
// 이미지 로드 실패 시 기본 아이콘 표시
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 파일 타입 컬럼은 파일 아이콘으로 표시 (컬럼별 파일 관리)
|
||||
if (isFileColumn && rowData) {
|
||||
// 현재 행의 기본키 값 가져오기
|
||||
|
|
|
|||
|
|
@ -124,7 +124,8 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
|||
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
|
||||
const recordTableName = formData?.tableName || tableName;
|
||||
const recordId = formData?.id;
|
||||
const effectiveColumnName = isRecordMode ? 'attachments' : (columnName || id || 'attachments');
|
||||
// 🔑 columnName 우선 사용 (실제 DB 컬럼명), 없으면 id, 최후에 attachments
|
||||
const effectiveColumnName = columnName || id || 'attachments';
|
||||
|
||||
// 레코드용 targetObjid 생성
|
||||
const getRecordTargetObjid = useCallback(() => {
|
||||
|
|
@ -471,13 +472,21 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
|||
|
||||
// 폼 데이터 업데이트 - 부모 컴포넌트 시그니처에 맞게 (fieldName, value) 형식
|
||||
if (onFormDataChange && targetColumn) {
|
||||
// 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값)
|
||||
// 복수 파일: 콤마 구분 문자열로 전달
|
||||
const formValue = config.multiple
|
||||
? fileIds.join(',')
|
||||
: (fileIds[0] || '');
|
||||
|
||||
console.log("📝 [V2Media] formData 업데이트:", {
|
||||
columnName: targetColumn,
|
||||
fileIds,
|
||||
formValue,
|
||||
isMultiple: config.multiple,
|
||||
isRecordMode: effectiveIsRecordMode,
|
||||
});
|
||||
// (fieldName: string, value: any) 형식으로 호출
|
||||
onFormDataChange(targetColumn, fileIds);
|
||||
onFormDataChange(targetColumn, formValue);
|
||||
}
|
||||
|
||||
// 그리드 파일 상태 새로고침 이벤트 발생
|
||||
|
|
@ -601,12 +610,19 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
|||
|
||||
// 폼 데이터 업데이트 - 부모 컴포넌트 시그니처에 맞게 (fieldName, value) 형식
|
||||
if (onFormDataChange && targetColumn) {
|
||||
// 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값)
|
||||
// 복수 파일: 콤마 구분 문자열로 전달
|
||||
const formValue = config.multiple
|
||||
? fileIds.join(',')
|
||||
: (fileIds[0] || '');
|
||||
|
||||
console.log("🗑️ [V2Media] 삭제 후 formData 업데이트:", {
|
||||
columnName: targetColumn,
|
||||
fileIds,
|
||||
formValue,
|
||||
});
|
||||
// (fieldName: string, value: any) 형식으로 호출
|
||||
onFormDataChange(targetColumn, fileIds);
|
||||
onFormDataChange(targetColumn, formValue);
|
||||
}
|
||||
|
||||
toast.success(`${fileName} 삭제 완료`);
|
||||
|
|
|
|||
|
|
@ -298,3 +298,31 @@ export const setRepresentativeFile = async (objid: string): Promise<{
|
|||
throw new Error("대표 파일 설정에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 파일 정보 조회 (메타데이터만, objid로 조회)
|
||||
*/
|
||||
export const getFileInfoByObjid = async (objid: string): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
objid: string;
|
||||
realFileName: string;
|
||||
fileSize: number;
|
||||
fileExt: string;
|
||||
filePath: string;
|
||||
regdate: string;
|
||||
isRepresentative: boolean;
|
||||
};
|
||||
message?: string;
|
||||
}> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/files/info/${objid}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("파일 정보 조회 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "파일 정보 조회에 실패했습니다.",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,22 +14,23 @@ import { FileUploadConfig } from "./types";
|
|||
*/
|
||||
export const FileUploadDefinition = createComponentDefinition({
|
||||
id: "file-upload",
|
||||
name: "파일 업로드",
|
||||
nameEng: "FileUpload Component",
|
||||
description: "파일 업로드를 위한 파일 선택 컴포넌트",
|
||||
name: "파일 업로드 (레거시)",
|
||||
nameEng: "FileUpload Component (Legacy)",
|
||||
description: "파일 업로드를 위한 파일 선택 컴포넌트 (레거시)",
|
||||
category: ComponentCategory.INPUT,
|
||||
webType: "file",
|
||||
component: FileUploadComponent,
|
||||
defaultConfig: {
|
||||
placeholder: "입력하세요",
|
||||
},
|
||||
defaultSize: { width: 350, height: 240 }, // 40 * 6 (파일 선택 + 목록 표시)
|
||||
defaultSize: { width: 350, height: 240 },
|
||||
configPanel: FileUploadConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "https://docs.example.com/components/file-upload",
|
||||
hidden: true, // v2-file-upload 사용으로 패널에서 숨김
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ import { ImageWidgetConfigPanel } from "./ImageWidgetConfigPanel";
|
|||
*/
|
||||
export const ImageWidgetDefinition = createComponentDefinition({
|
||||
id: "image-widget",
|
||||
name: "이미지 위젯",
|
||||
nameEng: "Image Widget",
|
||||
description: "이미지 표시 및 업로드",
|
||||
name: "이미지 위젯 (레거시)",
|
||||
nameEng: "Image Widget (Legacy)",
|
||||
description: "이미지 표시 및 업로드 (레거시)",
|
||||
category: ComponentCategory.INPUT,
|
||||
webType: "image",
|
||||
component: ImageWidget,
|
||||
|
|
@ -32,6 +32,7 @@ export const ImageWidgetDefinition = createComponentDefinition({
|
|||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "https://docs.example.com/components/image-widget",
|
||||
hidden: true, // v2-file-upload 사용으로 패널에서 숨김
|
||||
});
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ import "./v2-timeline-scheduler/TimelineSchedulerRenderer"; // 타임라인 스
|
|||
import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트
|
||||
import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
|
||||
import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
|
||||
import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
|
|
|
|||
|
|
@ -0,0 +1,421 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useRef } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FileInfo, FileUploadConfig } from "./types";
|
||||
import {
|
||||
Upload,
|
||||
Download,
|
||||
Trash2,
|
||||
Eye,
|
||||
File,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Video,
|
||||
Music,
|
||||
Archive,
|
||||
Presentation,
|
||||
X,
|
||||
Star
|
||||
} from "lucide-react";
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { FileViewerModal } from "./FileViewerModal";
|
||||
|
||||
interface FileManagerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
uploadedFiles: FileInfo[];
|
||||
onFileUpload: (files: File[]) => Promise<void>;
|
||||
onFileDownload: (file: FileInfo) => void;
|
||||
onFileDelete: (file: FileInfo) => void;
|
||||
onFileView: (file: FileInfo) => void;
|
||||
onSetRepresentative?: (file: FileInfo) => void; // 대표 이미지 설정 콜백
|
||||
config: FileUploadConfig;
|
||||
isDesignMode?: boolean;
|
||||
}
|
||||
|
||||
export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
uploadedFiles,
|
||||
onFileUpload,
|
||||
onFileDownload,
|
||||
onFileDelete,
|
||||
onFileView,
|
||||
onSetRepresentative,
|
||||
config,
|
||||
isDesignMode = false,
|
||||
}) => {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [viewerFile, setViewerFile] = useState<FileInfo | null>(null);
|
||||
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<FileInfo | null>(null); // 선택된 파일 (좌측 미리보기용)
|
||||
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null); // 이미지 미리보기 URL
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 파일 아이콘 가져오기
|
||||
const getFileIcon = (fileExt: string) => {
|
||||
const ext = fileExt.toLowerCase();
|
||||
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) {
|
||||
return <ImageIcon className="w-5 h-5 text-blue-500" />;
|
||||
} else if (['pdf', 'doc', 'docx', 'txt', 'rtf'].includes(ext)) {
|
||||
return <FileText className="w-5 h-5 text-red-500" />;
|
||||
} else if (['xls', 'xlsx', 'csv'].includes(ext)) {
|
||||
return <FileText className="w-5 h-5 text-green-500" />;
|
||||
} else if (['ppt', 'pptx'].includes(ext)) {
|
||||
return <Presentation className="w-5 h-5 text-orange-500" />;
|
||||
} else if (['mp4', 'avi', 'mov', 'webm'].includes(ext)) {
|
||||
return <Video className="w-5 h-5 text-purple-500" />;
|
||||
} else if (['mp3', 'wav', 'ogg'].includes(ext)) {
|
||||
return <Music className="w-5 h-5 text-pink-500" />;
|
||||
} else if (['zip', 'rar', '7z'].includes(ext)) {
|
||||
return <Archive className="w-5 h-5 text-yellow-500" />;
|
||||
} else {
|
||||
return <File className="w-5 h-5 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
// 파일 업로드 핸들러
|
||||
const handleFileUpload = async (files: FileList | File[]) => {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const fileArray = Array.from(files);
|
||||
await onFileUpload(fileArray);
|
||||
console.log('✅ FileManagerModal: 파일 업로드 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ FileManagerModal: 파일 업로드 오류:', error);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
console.log('🔄 FileManagerModal: 업로드 상태 초기화');
|
||||
}
|
||||
};
|
||||
|
||||
// 드래그 앤 드롭 핸들러
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
|
||||
if (config.disabled || isDesignMode) return;
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
handleFileUpload(files);
|
||||
};
|
||||
|
||||
// 파일 선택 핸들러
|
||||
const handleFileSelect = () => {
|
||||
if (config.disabled || isDesignMode) return;
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files) {
|
||||
handleFileUpload(files);
|
||||
}
|
||||
// 입력값 초기화
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
// 파일 뷰어 핸들러
|
||||
const handleFileViewInternal = (file: FileInfo) => {
|
||||
setViewerFile(file);
|
||||
setIsViewerOpen(true);
|
||||
};
|
||||
|
||||
const handleViewerClose = () => {
|
||||
setIsViewerOpen(false);
|
||||
setViewerFile(null);
|
||||
};
|
||||
|
||||
// 파일 클릭 시 미리보기 로드
|
||||
const handleFileClick = async (file: FileInfo) => {
|
||||
setSelectedFile(file);
|
||||
|
||||
// 이미지 파일인 경우 미리보기 로드
|
||||
// 🔑 점(.)을 제거하고 확장자만 비교
|
||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
|
||||
const ext = file.fileExt.toLowerCase().replace('.', '');
|
||||
if (imageExtensions.includes(ext) || file.isImage) {
|
||||
try {
|
||||
// 🔑 이미 previewUrl이 있으면 바로 사용
|
||||
if (file.previewUrl) {
|
||||
setPreviewImageUrl(file.previewUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// 이전 Blob URL 해제
|
||||
if (previewImageUrl) {
|
||||
URL.revokeObjectURL(previewImageUrl);
|
||||
}
|
||||
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.get(`/files/preview/${file.objid}`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const blob = new Blob([response.data]);
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
setPreviewImageUrl(blobUrl);
|
||||
} catch (error) {
|
||||
console.error("이미지 로드 실패:", error);
|
||||
// 🔑 에러 발생 시에도 previewUrl이 있으면 사용
|
||||
if (file.previewUrl) {
|
||||
setPreviewImageUrl(file.previewUrl);
|
||||
} else {
|
||||
setPreviewImageUrl(null);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setPreviewImageUrl(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 컴포넌트 언마운트 시 Blob URL 해제
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (previewImageUrl) {
|
||||
URL.revokeObjectURL(previewImageUrl);
|
||||
}
|
||||
};
|
||||
}, [previewImageUrl]);
|
||||
|
||||
// 🔑 모달이 열릴 때 첫 번째 파일을 자동으로 선택
|
||||
React.useEffect(() => {
|
||||
if (isOpen && uploadedFiles.length > 0 && !selectedFile) {
|
||||
const firstFile = uploadedFiles[0];
|
||||
handleFileClick(firstFile);
|
||||
}
|
||||
}, [isOpen, uploadedFiles, selectedFile]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={() => {}}>
|
||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden [&>button]:hidden">
|
||||
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<DialogTitle className="text-lg font-semibold">
|
||||
파일 관리 ({uploadedFiles.length}개)
|
||||
</DialogTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 hover:bg-gray-100"
|
||||
onClick={onClose}
|
||||
title="닫기"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col space-y-3 h-[75vh]">
|
||||
{/* 파일 업로드 영역 - 높이 축소 */}
|
||||
{!isDesignMode && (
|
||||
<div
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors
|
||||
${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300'}
|
||||
${config.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-400'}
|
||||
${uploading ? 'opacity-75' : ''}
|
||||
`}
|
||||
onClick={() => {
|
||||
if (!config.disabled && !isDesignMode) {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple={config.multiple}
|
||||
accept={config.accept}
|
||||
onChange={handleFileInputChange}
|
||||
className="hidden"
|
||||
disabled={config.disabled}
|
||||
/>
|
||||
|
||||
{uploading ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
|
||||
<span className="text-sm text-blue-600 font-medium">업로드 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Upload className="h-6 w-6 text-gray-400" />
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
파일을 드래그하거나 클릭하여 업로드하세요
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 좌우 분할 레이아웃 */}
|
||||
<div className="flex-1 flex gap-4 min-h-0">
|
||||
{/* 좌측: 이미지 미리보기 */}
|
||||
<div className="w-1/2 border border-gray-200 rounded-lg bg-gray-50 flex items-center justify-center overflow-hidden">
|
||||
{selectedFile && previewImageUrl ? (
|
||||
<img
|
||||
src={previewImageUrl}
|
||||
alt={selectedFile.realFileName}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
) : selectedFile ? (
|
||||
<div className="flex flex-col items-center text-gray-400">
|
||||
{getFileIcon(selectedFile.fileExt)}
|
||||
<p className="mt-2 text-sm">미리보기 불가능</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center text-gray-400">
|
||||
<ImageIcon className="w-16 h-16 mb-2" />
|
||||
<p className="text-sm">파일을 선택하면 미리보기가 표시됩니다</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측: 파일 목록 */}
|
||||
<div className="w-1/2 border border-gray-200 rounded-lg overflow-hidden flex flex-col">
|
||||
<div className="p-3 border-b border-gray-200 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-700">
|
||||
업로드된 파일
|
||||
</h3>
|
||||
{uploadedFiles.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
총 {formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
{uploadedFiles.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{uploadedFiles.map((file) => (
|
||||
<div
|
||||
key={file.objid}
|
||||
className={`
|
||||
flex items-center space-x-3 p-2 rounded-lg transition-colors cursor-pointer
|
||||
${selectedFile?.objid === file.objid ? 'bg-blue-50 border border-blue-200' : 'bg-gray-50 hover:bg-gray-100'}
|
||||
`}
|
||||
onClick={() => handleFileClick(file)}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{getFileIcon(file.fileExt)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 truncate">
|
||||
{file.realFileName}
|
||||
</span>
|
||||
{file.isRepresentative && (
|
||||
<Badge variant="default" className="h-5 px-1.5 text-xs">
|
||||
대표
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatFileSize(file.fileSize)} • {file.fileExt.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
{onSetRepresentative && (
|
||||
<Button
|
||||
variant={file.isRepresentative ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSetRepresentative(file);
|
||||
}}
|
||||
title={file.isRepresentative ? "현재 대표 파일" : "대표 파일로 설정"}
|
||||
>
|
||||
<Star className={`w-3 h-3 ${file.isRepresentative ? "fill-white" : ""}`} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleFileViewInternal(file);
|
||||
}}
|
||||
title="미리보기"
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFileDownload(file);
|
||||
}}
|
||||
title="다운로드"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
</Button>
|
||||
{!isDesignMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-red-500 hover:text-red-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFileDelete(file);
|
||||
}}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||
<File className="w-12 h-12 mb-3 text-gray-300" />
|
||||
<p className="text-sm font-medium text-gray-600">업로드된 파일이 없습니다</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{isDesignMode ? '디자인 모드에서는 파일을 업로드할 수 없습니다' : '위의 영역에 파일을 업로드하세요'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 파일 뷰어 모달 */}
|
||||
<FileViewerModal
|
||||
file={viewerFile}
|
||||
isOpen={isViewerOpen}
|
||||
onClose={handleViewerClose}
|
||||
onDownload={onFileDownload}
|
||||
onDelete={!isDesignMode ? onFileDelete : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,287 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo, useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { FileUploadConfig } from "./types";
|
||||
import { V2FileUploadDefaultConfig } from "./config";
|
||||
|
||||
export interface FileUploadConfigPanelProps {
|
||||
config: FileUploadConfig;
|
||||
onChange: (config: Partial<FileUploadConfig>) => void;
|
||||
screenTableName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 FileUpload 설정 패널
|
||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||
*/
|
||||
export const FileUploadConfigPanel: React.FC<FileUploadConfigPanelProps> = ({
|
||||
config: propConfig,
|
||||
onChange,
|
||||
screenTableName,
|
||||
}) => {
|
||||
// config 안전하게 초기화 (useMemo)
|
||||
const config = useMemo(() => ({
|
||||
...V2FileUploadDefaultConfig,
|
||||
...propConfig,
|
||||
}), [propConfig]);
|
||||
|
||||
// 핸들러
|
||||
const handleChange = useCallback(<K extends keyof FileUploadConfig>(
|
||||
key: K,
|
||||
value: FileUploadConfig[K]
|
||||
) => {
|
||||
onChange({ [key]: value });
|
||||
}, [onChange]);
|
||||
|
||||
// 파일 크기를 MB 단위로 변환
|
||||
const maxSizeMB = useMemo(() => {
|
||||
return (config.maxSize || 10 * 1024 * 1024) / (1024 * 1024);
|
||||
}, [config.maxSize]);
|
||||
|
||||
const handleMaxSizeChange = useCallback((value: string) => {
|
||||
const mb = parseFloat(value) || 10;
|
||||
handleChange("maxSize", mb * 1024 * 1024);
|
||||
}, [handleChange]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
V2 파일 업로드 설정
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 기본 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
기본 설정
|
||||
</Label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder" className="text-xs">플레이스홀더</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
value={config.placeholder || ""}
|
||||
onChange={(e) => handleChange("placeholder", e.target.value)}
|
||||
placeholder="파일을 선택하세요"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="accept" className="text-xs">허용 파일 형식</Label>
|
||||
<Select
|
||||
value={config.accept || "*/*"}
|
||||
onValueChange={(value) => handleChange("accept", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="파일 형식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="*/*">모든 파일</SelectItem>
|
||||
<SelectItem value="image/*">이미지만</SelectItem>
|
||||
<SelectItem value=".pdf,.doc,.docx,.xls,.xlsx">문서만</SelectItem>
|
||||
<SelectItem value="image/*,.pdf">이미지 + PDF</SelectItem>
|
||||
<SelectItem value=".zip,.rar,.7z">압축 파일만</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxSize" className="text-xs">최대 크기 (MB)</Label>
|
||||
<Input
|
||||
id="maxSize"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={maxSizeMB}
|
||||
onChange={(e) => handleMaxSizeChange(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxFiles" className="text-xs">최대 파일 수</Label>
|
||||
<Input
|
||||
id="maxFiles"
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={config.maxFiles || 10}
|
||||
onChange={(e) => handleChange("maxFiles", parseInt(e.target.value) || 10)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 동작 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
동작 설정
|
||||
</Label>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="multiple"
|
||||
checked={config.multiple !== false}
|
||||
onCheckedChange={(checked) => handleChange("multiple", checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="multiple" className="text-xs">다중 파일 선택 허용</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="allowDelete"
|
||||
checked={config.allowDelete !== false}
|
||||
onCheckedChange={(checked) => handleChange("allowDelete", checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="allowDelete" className="text-xs">파일 삭제 허용</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="allowDownload"
|
||||
checked={config.allowDownload !== false}
|
||||
onCheckedChange={(checked) => handleChange("allowDownload", checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="allowDownload" className="text-xs">파일 다운로드 허용</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 표시 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
표시 설정
|
||||
</Label>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showPreview"
|
||||
checked={config.showPreview !== false}
|
||||
onCheckedChange={(checked) => handleChange("showPreview", checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="showPreview" className="text-xs">미리보기 표시</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showFileList"
|
||||
checked={config.showFileList !== false}
|
||||
onCheckedChange={(checked) => handleChange("showFileList", checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="showFileList" className="text-xs">파일 목록 표시</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showFileSize"
|
||||
checked={config.showFileSize !== false}
|
||||
onCheckedChange={(checked) => handleChange("showFileSize", checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="showFileSize" className="text-xs">파일 크기 표시</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 상태 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
상태 설정
|
||||
</Label>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="required"
|
||||
checked={config.required || false}
|
||||
onCheckedChange={(checked) => handleChange("required", checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="required" className="text-xs">필수 입력</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="readonly"
|
||||
checked={config.readonly || false}
|
||||
onCheckedChange={(checked) => handleChange("readonly", checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="readonly" className="text-xs">읽기 전용</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="disabled"
|
||||
checked={config.disabled || false}
|
||||
onCheckedChange={(checked) => handleChange("disabled", checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="disabled" className="text-xs">비활성화</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 스타일 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
스타일 설정
|
||||
</Label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="variant" className="text-xs">스타일 변형</Label>
|
||||
<Select
|
||||
value={config.variant || "default"}
|
||||
onValueChange={(value) => handleChange("variant", value as "default" | "outlined" | "filled")}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="스타일 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">기본</SelectItem>
|
||||
<SelectItem value="outlined">테두리</SelectItem>
|
||||
<SelectItem value="filled">채움</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="size" className="text-xs">크기</Label>
|
||||
<Select
|
||||
value={config.size || "md"}
|
||||
onValueChange={(value) => handleChange("size", value as "sm" | "md" | "lg")}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="크기 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sm">작게</SelectItem>
|
||||
<SelectItem value="md">보통</SelectItem>
|
||||
<SelectItem value="lg">크게</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 도움말 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="helperText" className="text-xs">도움말</Label>
|
||||
<Input
|
||||
id="helperText"
|
||||
value={config.helperText || ""}
|
||||
onChange={(e) => handleChange("helperText", e.target.value)}
|
||||
placeholder="파일 업로드에 대한 안내 문구"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,543 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FileInfo } from "./types";
|
||||
import { Download, X, AlertTriangle, FileText, Trash2, ExternalLink } from "lucide-react";
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { API_BASE_URL } from "@/lib/api/client";
|
||||
|
||||
// Office 문서 렌더링을 위한 CDN 라이브러리 로드
|
||||
const loadOfficeLibrariesFromCDN = async () => {
|
||||
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";
|
||||
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";
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
XLSX: (window as any).XLSX,
|
||||
mammoth: (window as any).mammoth,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Office 라이브러리 CDN 로드 실패:", error);
|
||||
return { XLSX: null, mammoth: null };
|
||||
}
|
||||
};
|
||||
|
||||
interface FileViewerModalProps {
|
||||
file: FileInfo | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onDownload?: (file: FileInfo) => void;
|
||||
onDelete?: (file: FileInfo) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 뷰어 모달 컴포넌트
|
||||
*/
|
||||
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);
|
||||
const [renderedContent, setRenderedContent] = useState<string | null>(null);
|
||||
|
||||
// Office 문서를 CDN 라이브러리로 렌더링하는 함수
|
||||
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 || "내용을 읽을 수 없습니다."}
|
||||
</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 sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
|
||||
const html = XLSX.utils.sheet_to_html(worksheet, {
|
||||
table: { className: "excel-table" },
|
||||
});
|
||||
|
||||
const htmlContent = `
|
||||
<div>
|
||||
<h4 style="margin: 0 0 10px 0; color: #333; font-size: 16px;">📊 ${fileName}</h4>
|
||||
<p style="margin: 0 0 15px 0; color: #666; font-size: 14px;">시트: ${sheetName}</p>
|
||||
<div style="max-height: 400px; overflow: auto; border: 1px solid #ddd; border-radius: 5px;">
|
||||
<style>
|
||||
.excel-table { border-collapse: collapse; width: 100%; }
|
||||
.excel-table td, .excel-table th { border: 1px solid #ddd; padding: 8px; text-align: left; font-size: 12px; }
|
||||
.excel-table th { background-color: #f5f5f5; font-weight: bold; }
|
||||
</style>
|
||||
${html}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
setRenderedContent(htmlContent);
|
||||
return true;
|
||||
} else if (fileExt === "doc") {
|
||||
// .doc 파일은 .docx로 변환 안내
|
||||
const htmlContent = `
|
||||
<div style="text-align: center; padding: 40px;">
|
||||
<h3 style="color: #333; margin-bottom: 15px;">📄 ${fileName}</h3>
|
||||
<p style="color: #666; margin-bottom: 10px;">.doc 파일은 .docx로 변환 후 업로드해주세요.</p>
|
||||
<p style="color: #666; font-size: 14px;">(.docx 파일만 미리보기 지원)</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
setRenderedContent(htmlContent);
|
||||
return true;
|
||||
} else if (["ppt", "pptx"].includes(fileExt)) {
|
||||
// PowerPoint는 미리보기 불가 안내
|
||||
const htmlContent = `
|
||||
<div style="text-align: center; padding: 40px;">
|
||||
<h3 style="color: #333; margin-bottom: 15px;">📑 ${fileName}</h3>
|
||||
<p style="color: #666; margin-bottom: 10px;">PowerPoint 파일은 브라우저에서 미리보기할 수 없습니다.</p>
|
||||
<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 {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 파일이 변경될 때마다 미리보기 URL 생성
|
||||
useEffect(() => {
|
||||
if (!file || !isOpen) {
|
||||
setPreviewUrl(null);
|
||||
setPreviewError(null);
|
||||
setRenderedContent(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setPreviewError(null);
|
||||
|
||||
// 로컬 파일인 경우
|
||||
if (file._file) {
|
||||
const url = URL.createObjectURL(file._file);
|
||||
setPreviewUrl(url);
|
||||
setIsLoading(false);
|
||||
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
let cleanup: (() => void) | undefined;
|
||||
|
||||
// 서버 파일인 경우 - 미리보기 API 호출
|
||||
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 textExtensions = ["txt", "md", "json", "xml", "csv"];
|
||||
const mediaExtensions = ["mp4", "webm", "ogg", "mp3", "wav"];
|
||||
|
||||
const supportedExtensions = [...imageExtensions, ...documentExtensions, ...textExtensions, ...mediaExtensions];
|
||||
|
||||
if (supportedExtensions.includes(fileExt)) {
|
||||
// 이미지나 PDF는 인증된 요청으로 Blob 생성
|
||||
if (imageExtensions.includes(fileExt) || fileExt === "pdf") {
|
||||
try {
|
||||
// 인증된 요청으로 파일 데이터 가져오기
|
||||
const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, {
|
||||
headers: {
|
||||
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 {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("파일 미리보기 로드 실패:", error);
|
||||
setPreviewError("파일을 불러올 수 없습니다. 권한을 확인해주세요.");
|
||||
}
|
||||
} else if (documentExtensions.includes(fileExt)) {
|
||||
// Office 문서는 OnlyOffice 또는 안정적인 뷰어 사용
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, {
|
||||
headers: {
|
||||
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);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Office 문서 렌더링 중 오류:", error);
|
||||
// 오류 발생 시 Blob URL 사용
|
||||
setPreviewUrl(blobUrl);
|
||||
}
|
||||
} else {
|
||||
// 기타 문서는 직접 Blob URL 사용
|
||||
setPreviewUrl(blobUrl);
|
||||
}
|
||||
|
||||
return () => URL.revokeObjectURL(blobUrl); // Cleanup function
|
||||
} else {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Office 문서 로드 실패:", error);
|
||||
// 오류 발생 시 다운로드 옵션 제공
|
||||
setPreviewError(`${fileExt.toUpperCase()} 문서를 미리보기할 수 없습니다. 다운로드하여 확인해주세요.`);
|
||||
}
|
||||
} else {
|
||||
// 기타 파일은 다운로드 URL 사용
|
||||
const url = `${API_BASE_URL.replace("/api", "")}/api/files/download/${file.objid}`;
|
||||
setPreviewUrl(url);
|
||||
}
|
||||
} else {
|
||||
// 지원하지 않는 파일 타입
|
||||
setPreviewError(`${file.fileExt.toUpperCase()} 파일은 미리보기를 지원하지 않습니다.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("미리보기 URL 생성 오류:", error);
|
||||
setPreviewError("미리보기를 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
generatePreviewUrl();
|
||||
|
||||
// cleanup 함수 반환
|
||||
return () => {
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
}, [file, isOpen]);
|
||||
|
||||
if (!file) return null;
|
||||
|
||||
// 파일 타입별 미리보기 컴포넌트
|
||||
const renderPreview = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
|
||||
const fileExt = file.fileExt.toLowerCase();
|
||||
|
||||
// 이미지 파일
|
||||
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(fileExt)) {
|
||||
return (
|
||||
<div className="flex max-h-96 items-center justify-center overflow-hidden">
|
||||
<img
|
||||
src={previewUrl || ""}
|
||||
alt={file.realFileName}
|
||||
className="max-h-full max-w-full rounded-lg object-contain shadow-lg"
|
||||
onError={(e) => {
|
||||
console.error("이미지 로드 오류:", previewUrl, e);
|
||||
setPreviewError("이미지를 불러올 수 없습니다. 파일이 손상되었거나 서버에서 접근할 수 없습니다.");
|
||||
}}
|
||||
onLoad={() => {
|
||||
console.log("이미지 로드 성공:", previewUrl);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 텍스트 파일
|
||||
if (["txt", "md", "json", "xml", "csv"].includes(fileExt)) {
|
||||
return (
|
||||
<div className="h-96 overflow-auto">
|
||||
<iframe
|
||||
src={previewUrl || ""}
|
||||
className="h-full w-full rounded-lg border"
|
||||
onError={() => setPreviewError("텍스트 파일을 불러올 수 없습니다.")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// PDF 파일 - 브라우저 기본 뷰어 사용
|
||||
if (fileExt === "pdf") {
|
||||
return (
|
||||
<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 문서 - 모든 Office 문서는 다운로드 권장
|
||||
if (
|
||||
[
|
||||
"doc",
|
||||
"docx",
|
||||
"xls",
|
||||
"xlsx",
|
||||
"ppt",
|
||||
"pptx",
|
||||
"hwp",
|
||||
"hwpx",
|
||||
"hwpml",
|
||||
"hcdt",
|
||||
"hpt",
|
||||
"pages",
|
||||
"numbers",
|
||||
"keynote",
|
||||
].includes(fileExt)
|
||||
) {
|
||||
// Office 문서 안내 메시지 표시
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// 비디오 파일
|
||||
if (["mp4", "webm", "ogg"].includes(fileExt)) {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<video controls className="max-h-96 w-full" onError={() => setPreviewError("비디오를 재생할 수 없습니다.")}>
|
||||
<source src={previewUrl || ""} type={`video/${fileExt}`} />
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 오디오 파일
|
||||
if (["mp3", "wav", "ogg"].includes(fileExt)) {
|
||||
return (
|
||||
<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"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<audio controls className="w-full max-w-md" onError={() => setPreviewError("오디오를 재생할 수 없습니다.")}>
|
||||
<source src={previewUrl || ""} type={`audio/${fileExt}`} />
|
||||
</audio>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 기타 파일 타입
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={() => {}}>
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-hidden [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<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.fileSize || file.size || 0)} | 파일 형식: {file.fileExt.toUpperCase()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden">{renderPreview()}</div>
|
||||
|
||||
{/* 파일 정보 및 액션 버튼 */}
|
||||
<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 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:bg-red-50 hover:text-red-700"
|
||||
onClick={() => onDelete(file)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { V2FileUploadDefinition } from "./index";
|
||||
import { FileUploadComponent } from "./FileUploadComponent";
|
||||
|
||||
/**
|
||||
* V2 FileUpload 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class V2FileUploadRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2FileUploadDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <FileUploadComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 특화 메서드들
|
||||
*/
|
||||
|
||||
// file 타입 특화 속성 처리
|
||||
protected getFileUploadProps() {
|
||||
const baseProps = this.getWebTypeProps();
|
||||
|
||||
// file 타입에 특화된 추가 속성들
|
||||
return {
|
||||
...baseProps,
|
||||
// 여기에 file 타입 특화 속성들 추가
|
||||
};
|
||||
}
|
||||
|
||||
// 값 변경 처리
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
|
||||
// 포커스 처리
|
||||
protected handleFocus = () => {
|
||||
// 포커스 로직
|
||||
};
|
||||
|
||||
// 블러 처리
|
||||
protected handleBlur = () => {
|
||||
// 블러 로직
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
V2FileUploadRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
V2FileUploadRenderer.enableHotReload();
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
"use client";
|
||||
|
||||
import { FileUploadConfig } from "./types";
|
||||
|
||||
/**
|
||||
* V2 FileUpload 컴포넌트 기본 설정
|
||||
*/
|
||||
export const V2FileUploadDefaultConfig: FileUploadConfig = {
|
||||
placeholder: "파일을 선택하세요",
|
||||
multiple: true,
|
||||
accept: "*/*",
|
||||
maxSize: 10 * 1024 * 1024, // 10MB
|
||||
maxFiles: 10,
|
||||
|
||||
// 공통 기본값
|
||||
disabled: false,
|
||||
required: false,
|
||||
readonly: false,
|
||||
variant: "default",
|
||||
size: "md",
|
||||
|
||||
// V2 추가 설정 기본값
|
||||
showPreview: true,
|
||||
showFileList: true,
|
||||
showFileSize: true,
|
||||
allowDelete: true,
|
||||
allowDownload: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* V2 FileUpload 컴포넌트 설정 스키마
|
||||
* 유효성 검사 및 타입 체크에 사용
|
||||
*/
|
||||
export const V2FileUploadConfigSchema = {
|
||||
placeholder: { type: "string", default: "파일을 선택하세요" },
|
||||
multiple: { type: "boolean", default: true },
|
||||
accept: { type: "string", default: "*/*" },
|
||||
maxSize: { type: "number", default: 10 * 1024 * 1024 },
|
||||
maxFiles: { type: "number", default: 10 },
|
||||
|
||||
// 공통 스키마
|
||||
disabled: { type: "boolean", default: false },
|
||||
required: { type: "boolean", default: false },
|
||||
readonly: { type: "boolean", default: false },
|
||||
variant: {
|
||||
type: "enum",
|
||||
values: ["default", "outlined", "filled"],
|
||||
default: "default"
|
||||
},
|
||||
size: {
|
||||
type: "enum",
|
||||
values: ["sm", "md", "lg"],
|
||||
default: "md"
|
||||
},
|
||||
|
||||
// V2 추가 설정 스키마
|
||||
showPreview: { type: "boolean", default: true },
|
||||
showFileList: { type: "boolean", default: true },
|
||||
showFileSize: { type: "boolean", default: true },
|
||||
allowDelete: { type: "boolean", default: true },
|
||||
allowDownload: { type: "boolean", default: true },
|
||||
};
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { FileUploadComponent } from "./FileUploadComponent";
|
||||
import { FileUploadConfigPanel } from "./FileUploadConfigPanel";
|
||||
import { FileUploadConfig } from "./types";
|
||||
|
||||
/**
|
||||
* V2 FileUpload 컴포넌트 정의
|
||||
* 화면관리 전용 V2 파일 업로드 컴포넌트입니다
|
||||
*/
|
||||
export const V2FileUploadDefinition = createComponentDefinition({
|
||||
id: "v2-file-upload",
|
||||
name: "파일 업로드",
|
||||
nameEng: "V2 FileUpload Component",
|
||||
description: "V2 파일 업로드를 위한 파일 선택 컴포넌트",
|
||||
category: ComponentCategory.INPUT,
|
||||
webType: "file",
|
||||
component: FileUploadComponent,
|
||||
defaultConfig: {
|
||||
placeholder: "파일을 선택하세요",
|
||||
multiple: true,
|
||||
accept: "*/*",
|
||||
maxSize: 10 * 1024 * 1024, // 10MB
|
||||
},
|
||||
defaultSize: { width: 350, height: 240 },
|
||||
configPanel: FileUploadConfigPanel,
|
||||
icon: "Upload",
|
||||
tags: ["file", "upload", "attachment", "v2"],
|
||||
version: "2.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "https://docs.example.com/components/v2-file-upload",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type { FileUploadConfig, FileInfo, FileUploadProps, FileUploadStatus, FileUploadResponse } from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { FileUploadComponent } from "./FileUploadComponent";
|
||||
export { V2FileUploadRenderer } from "./V2FileUploadRenderer";
|
||||
|
||||
// 기본 export
|
||||
export default V2FileUploadDefinition;
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
|
||||
/**
|
||||
* 파일 정보 인터페이스 (AttachedFileInfo와 호환)
|
||||
*/
|
||||
export interface FileInfo {
|
||||
// AttachedFileInfo 기본 속성들
|
||||
objid: string;
|
||||
savedFileName: string;
|
||||
realFileName: string;
|
||||
fileSize: number;
|
||||
fileExt: string;
|
||||
filePath: string;
|
||||
docType?: string;
|
||||
docTypeName?: string;
|
||||
targetObjid: string;
|
||||
parentTargetObjid?: string;
|
||||
companyCode?: string;
|
||||
writer?: string;
|
||||
regdate?: string;
|
||||
status?: string;
|
||||
|
||||
// 추가 호환성 속성들
|
||||
path?: string; // filePath와 동일
|
||||
name?: string; // realFileName과 동일
|
||||
id?: string; // objid와 동일
|
||||
size?: number; // fileSize와 동일
|
||||
type?: string; // docType과 동일
|
||||
uploadedAt?: string; // regdate와 동일
|
||||
_file?: File; // 로컬 파일 객체 (업로드 전)
|
||||
|
||||
// 대표 이미지 설정
|
||||
isRepresentative?: boolean; // 대표 이미지로 설정 여부
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 FileUpload 컴포넌트 설정 타입
|
||||
*/
|
||||
export interface FileUploadConfig extends ComponentConfig {
|
||||
// file 관련 설정
|
||||
placeholder?: string;
|
||||
multiple?: boolean;
|
||||
accept?: string;
|
||||
maxSize?: number; // bytes
|
||||
maxFiles?: number; // 최대 파일 수
|
||||
|
||||
// 공통 설정
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
helperText?: string;
|
||||
|
||||
// 스타일 관련
|
||||
variant?: "default" | "outlined" | "filled";
|
||||
size?: "sm" | "md" | "lg";
|
||||
|
||||
// V2 추가 설정
|
||||
showPreview?: boolean; // 미리보기 표시 여부
|
||||
showFileList?: boolean; // 파일 목록 표시 여부
|
||||
showFileSize?: boolean; // 파일 크기 표시 여부
|
||||
allowDelete?: boolean; // 삭제 허용 여부
|
||||
allowDownload?: boolean; // 다운로드 허용 여부
|
||||
|
||||
// 이벤트 관련
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
onFileUpload?: (files: FileInfo[]) => void;
|
||||
onFileDelete?: (fileId: string) => void;
|
||||
onFileDownload?: (file: FileInfo) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 FileUpload 컴포넌트 Props 타입
|
||||
*/
|
||||
export interface FileUploadProps {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: any;
|
||||
config?: FileUploadConfig;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
// 파일 관련
|
||||
uploadedFiles?: FileInfo[];
|
||||
|
||||
// 이벤트 핸들러
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
onFileUpload?: (files: FileInfo[]) => void;
|
||||
onFileDelete?: (fileId: string) => void;
|
||||
onFileDownload?: (file: FileInfo) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 업로드 상태 타입
|
||||
*/
|
||||
export type FileUploadStatus = 'idle' | 'uploading' | 'success' | 'error';
|
||||
|
||||
/**
|
||||
* 파일 업로드 응답 타입
|
||||
*/
|
||||
export interface FileUploadResponse {
|
||||
success: boolean;
|
||||
data?: FileInfo[];
|
||||
files?: FileInfo[];
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
|
@ -30,6 +30,15 @@ export class V2MediaRenderer extends AutoRegisteringComponentRenderer {
|
|||
const columnName = component.columnName;
|
||||
const tableName = component.tableName || this.props.tableName;
|
||||
|
||||
// 🔍 디버깅: 컴포넌트 정보 로깅
|
||||
console.log("📸 [V2MediaRenderer] 컴포넌트 정보:", {
|
||||
componentId: component.id,
|
||||
columnName: columnName,
|
||||
tableName: tableName,
|
||||
formDataId: formData?.id,
|
||||
formDataTableName: formData?.tableName,
|
||||
});
|
||||
|
||||
// V1 file-upload에서 사용하는 형태로 설정 매핑
|
||||
const mediaType = config.mediaType || config.type || this.getMediaTypeFromWebType(component.webType);
|
||||
|
||||
|
|
@ -58,17 +67,14 @@ export class V2MediaRenderer extends AutoRegisteringComponentRenderer {
|
|||
componentConfig: legacyComponentConfig,
|
||||
};
|
||||
|
||||
// onFormDataChange 래퍼: 레거시 컴포넌트는 객체를 전달하므로 변환 필요
|
||||
const handleFormDataChange = (data: any) => {
|
||||
// onFormDataChange 래퍼: FileUploadComponent는 (fieldName, value) 형태로 호출함
|
||||
const handleFormDataChange = (fieldName: string, value: any) => {
|
||||
if (onFormDataChange) {
|
||||
// 레거시 컴포넌트는 { [columnName]: value } 형태로 전달
|
||||
// 부모는 (fieldName, value) 형태를 기대
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
// __attachmentsUpdate 같은 메타 데이터는 건너뛰기
|
||||
if (!key.startsWith("__")) {
|
||||
onFormDataChange(key, value);
|
||||
}
|
||||
});
|
||||
// 메타 데이터(__로 시작하는 키)는 건너뛰기
|
||||
if (!fieldName.startsWith("__")) {
|
||||
console.log("📸 [V2MediaRenderer] formData 업데이트:", { fieldName, value });
|
||||
onFormDataChange(fieldName, value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ interface GroupedData {
|
|||
// 캐시 및 유틸리티
|
||||
// ========================================
|
||||
|
||||
const tableColumnCache = new Map<string, { columns: any[]; timestamp: number }>();
|
||||
const tableColumnCache = new Map<string, { columns: any[]; inputTypes?: any[]; timestamp: number }>();
|
||||
const tableInfoCache = new Map<string, { tables: any[]; timestamp: number }>();
|
||||
const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분
|
||||
|
||||
|
|
@ -1139,6 +1139,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
});
|
||||
}
|
||||
|
||||
// 🔍 디버깅: 캐시 사용 시 로그
|
||||
console.log("📊 [TableListComponent] 캐시에서 inputTypes 로드:", {
|
||||
tableName: tableConfig.selectedTable,
|
||||
cacheKey: cacheKey,
|
||||
hasInputTypes: !!cached.inputTypes,
|
||||
inputTypesLength: cached.inputTypes?.length || 0,
|
||||
imageInputType: inputTypeMap["image"],
|
||||
cacheAge: Date.now() - cached.timestamp,
|
||||
});
|
||||
|
||||
cached.columns.forEach((col: any) => {
|
||||
labels[col.columnName] = col.displayName || col.comment || col.columnName;
|
||||
meta[col.columnName] = {
|
||||
|
|
@ -1162,6 +1172,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
inputTypeMap[col.columnName] = col.inputType;
|
||||
});
|
||||
|
||||
// 🔍 디버깅: inputTypes 확인
|
||||
console.log("📊 [TableListComponent] inputTypes 조회 결과:", {
|
||||
tableName: tableConfig.selectedTable,
|
||||
inputTypes: inputTypes,
|
||||
inputTypeMap: inputTypeMap,
|
||||
imageColumn: inputTypes.find((col: any) => col.columnName === "image"),
|
||||
});
|
||||
|
||||
tableColumnCache.set(cacheKey, {
|
||||
columns,
|
||||
inputTypes,
|
||||
|
|
@ -4061,19 +4079,44 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
|
||||
const inputType = meta?.inputType || column.inputType;
|
||||
|
||||
// 🔍 디버깅: image 컬럼인 경우 로그 출력
|
||||
if (column.columnName === "image") {
|
||||
console.log("🖼️ [formatCellValue] image 컬럼 처리:", {
|
||||
columnName: column.columnName,
|
||||
value: value,
|
||||
meta: meta,
|
||||
inputType: inputType,
|
||||
columnInputType: column.inputType,
|
||||
});
|
||||
}
|
||||
|
||||
// 🖼️ 이미지 타입: 작은 썸네일 표시
|
||||
if (inputType === "image" && value && typeof value === "string") {
|
||||
const imageUrl = getFullImageUrl(value);
|
||||
if (inputType === "image" && value) {
|
||||
// value가 objid (숫자 또는 숫자 문자열)인 경우 파일 API URL 사용
|
||||
// 🔑 download 대신 preview 사용 (공개 접근 허용)
|
||||
const strValue = String(value);
|
||||
const isObjid = /^\d+$/.test(strValue);
|
||||
const imageUrl = isObjid
|
||||
? `/api/files/preview/${strValue}`
|
||||
: getFullImageUrl(strValue);
|
||||
return (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="이미지"
|
||||
className="h-10 w-10 rounded object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Crect width='40' height='40' fill='%23f3f4f6'/%3E%3C/svg%3E";
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="이미지"
|
||||
className="h-10 w-10 rounded object-cover cursor-pointer hover:opacity-80 transition-opacity"
|
||||
style={{ maxWidth: "40px", maxHeight: "40px" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 이미지 클릭 시 새 탭에서 크게 보기
|
||||
window.open(imageUrl, "_blank");
|
||||
}}
|
||||
onError={(e) => {
|
||||
// 이미지 로드 실패 시 기본 아이콘 표시
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -107,18 +107,18 @@ export const WEB_TYPE_V2_MAPPING: Record<string, V2ComponentMapping> = {
|
|||
config: { mode: "dropdown", source: "category" },
|
||||
},
|
||||
|
||||
// 파일/이미지 → 레거시 file-upload (안정적인 파일 업로드)
|
||||
// 파일/이미지 → V2 파일 업로드
|
||||
file: {
|
||||
componentType: "file-upload",
|
||||
config: { maxFileCount: 10, accept: "*/*" },
|
||||
componentType: "v2-file-upload",
|
||||
config: { multiple: true, accept: "*/*", maxFiles: 10 },
|
||||
},
|
||||
image: {
|
||||
componentType: "file-upload",
|
||||
config: { maxFileCount: 1, accept: "image/*" },
|
||||
componentType: "v2-file-upload",
|
||||
config: { multiple: false, accept: "image/*", maxFiles: 1, showPreview: true },
|
||||
},
|
||||
img: {
|
||||
componentType: "file-upload",
|
||||
config: { maxFileCount: 1, accept: "image/*" },
|
||||
componentType: "v2-file-upload",
|
||||
config: { multiple: false, accept: "image/*", maxFiles: 1, showPreview: true },
|
||||
},
|
||||
|
||||
// 버튼은 V2 컴포넌트에서 제외 (기존 버튼 시스템 사용)
|
||||
|
|
@ -157,9 +157,9 @@ export const WEB_TYPE_COMPONENT_MAPPING: Record<string, string> = {
|
|||
code: "v2-select",
|
||||
entity: "v2-select",
|
||||
category: "v2-select",
|
||||
file: "file-upload",
|
||||
image: "file-upload",
|
||||
img: "file-upload",
|
||||
file: "v2-file-upload",
|
||||
image: "v2-file-upload",
|
||||
img: "v2-file-upload",
|
||||
button: "button-primary",
|
||||
label: "v2-input",
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue