Files
vexplor/frontend/components/v2/V2Media.tsx
kjs 43523a0bba feat: Implement NOT NULL validation for form fields based on table metadata
- Added a new function `isColumnRequired` to determine if a column is required based on its NOT NULL status from the table schema.
- Updated the `SaveModal` and `InteractiveScreenViewer` components to incorporate this validation, ensuring that required fields are accurately assessed during form submission.
- Enhanced the `DynamicComponentRenderer` to reflect the NOT NULL requirement in the component's required state.
- Improved user feedback by marking required fields with an asterisk based on both manual settings and database constraints.

These changes enhance the form validation process, ensuring that users are prompted for all necessary information based on the underlying data structure.
2026-03-10 14:16:02 +09:00

977 lines
32 KiB
TypeScript

"use client";
/**
* V2Media
*
* 통합 미디어 컴포넌트 (레거시 FileUploadComponent 기능 통합)
* - file: 파일 업로드
* - image: 이미지 업로드/표시
* - video: 비디오
* - audio: 오디오
*
* 핵심 기능:
* - FileViewerModal / FileManagerModal (자세히보기)
* - 대표 이미지 설정
* - 레코드 모드 (테이블/레코드 연결)
* - 전역 파일 상태 관리
* - 파일 다운로드/삭제
* - DB에서 기존 파일 로드
*/
import React, { forwardRef, useCallback, useRef, useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { V2MediaProps } from "@/types/v2-components";
import {
Upload,
X,
File,
Image as ImageIcon,
Video,
Music,
Eye,
Download,
Trash2,
Plus,
FileText,
Archive,
Presentation,
FileImage,
FileVideo,
FileAudio,
} from "lucide-react";
import { apiClient } from "@/lib/api/client";
import { toast } from "sonner";
import { uploadFiles, downloadFile, deleteFile, getComponentFiles } from "@/lib/api/file";
import { GlobalFileManager } from "@/lib/api/globalFile";
import { formatFileSize } from "@/lib/utils";
import { useAuth } from "@/hooks/useAuth";
// 레거시 모달 컴포넌트 import
import { FileViewerModal } from "@/lib/registry/components/file-upload/FileViewerModal";
import { FileManagerModal } from "@/lib/registry/components/file-upload/FileManagerModal";
import type { FileInfo, FileUploadConfig } from "@/lib/registry/components/file-upload/types";
/**
* 파일 아이콘 매핑
*/
const getFileIcon = (extension: string) => {
const ext = extension.toLowerCase().replace(".", "");
if (["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(ext)) {
return <FileImage className="h-6 w-6 text-blue-500" />;
}
if (["mp4", "avi", "mov", "wmv", "flv", "webm"].includes(ext)) {
return <FileVideo className="h-6 w-6 text-purple-500" />;
}
if (["mp3", "wav", "flac", "aac", "ogg"].includes(ext)) {
return <FileAudio className="h-6 w-6 text-green-500" />;
}
if (["pdf"].includes(ext)) {
return <FileText className="h-6 w-6 text-red-500" />;
}
if (["doc", "docx", "hwp", "hwpx", "pages"].includes(ext)) {
return <FileText className="h-6 w-6 text-blue-600" />;
}
if (["xls", "xlsx", "hcell", "numbers"].includes(ext)) {
return <FileText className="h-6 w-6 text-green-600" />;
}
if (["ppt", "pptx", "hanshow", "keynote"].includes(ext)) {
return <Presentation className="h-6 w-6 text-orange-500" />;
}
if (["zip", "rar", "7z", "tar", "gz"].includes(ext)) {
return <Archive className="h-6 w-6 text-gray-500" />;
}
return <File className="h-6 w-6 text-gray-400" />;
};
/**
* V2 미디어 컴포넌트 (레거시 기능 통합)
*/
export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>((props, ref) => {
const {
id,
label,
required,
readonly,
disabled,
style,
size,
config: configProp,
value,
onChange,
formData,
columnName,
tableName,
onFormDataChange,
isDesignMode = false,
isInteractive = true,
onUpdate,
...restProps
} = props;
// 인증 정보
const { user } = useAuth();
// config 기본값
const config = configProp || { type: "file" as const };
const mediaType = config.type || "file";
// 파일 상태
const [uploadedFiles, setUploadedFiles] = useState<FileInfo[]>([]);
const [uploadStatus, setUploadStatus] = useState<"idle" | "uploading" | "success" | "error">("idle");
const [dragOver, setDragOver] = useState(false);
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
// 모달 상태
const [viewerFile, setViewerFile] = useState<FileInfo | null>(null);
const [isViewerOpen, setIsViewerOpen] = useState(false);
const [isFileManagerOpen, setIsFileManagerOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// 레코드 모드 판단
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith("temp_"));
const recordTableName = formData?.tableName || tableName;
const recordId = formData?.id;
// 🔑 columnName 우선 사용 (실제 DB 컬럼명), 없으면 id, 최후에 attachments
const effectiveColumnName = columnName || id || "attachments";
// 레코드용 targetObjid 생성
const getRecordTargetObjid = useCallback(() => {
if (isRecordMode && recordTableName && recordId) {
return `${recordTableName}:${recordId}:${effectiveColumnName}`;
}
return null;
}, [isRecordMode, recordTableName, recordId, effectiveColumnName]);
// 레코드별 고유 키 생성
const getUniqueKey = useCallback(() => {
if (isRecordMode && recordTableName && recordId) {
return `v2media_${recordTableName}_${recordId}_${id}`;
}
return `v2media_${id}`;
}, [isRecordMode, recordTableName, recordId, id]);
// 레코드 ID 변경 시 파일 목록 초기화
const prevRecordIdRef = useRef<any>(null);
useEffect(() => {
if (prevRecordIdRef.current !== recordId) {
prevRecordIdRef.current = recordId;
if (isRecordMode) {
setUploadedFiles([]);
}
}
}, [recordId, isRecordMode]);
// 컴포넌트 마운트 시 localStorage에서 파일 복원
useEffect(() => {
if (!id) return;
try {
const backupKey = getUniqueKey();
const backupFiles = localStorage.getItem(backupKey);
if (backupFiles) {
const parsedFiles = JSON.parse(backupFiles);
if (parsedFiles.length > 0) {
setUploadedFiles(parsedFiles);
if (typeof window !== "undefined") {
(window as any).globalFileState = {
...(window as any).globalFileState,
[backupKey]: parsedFiles,
};
}
}
}
} catch (e) {
console.warn("파일 복원 실패:", e);
}
}, [id, getUniqueKey, recordId]);
// DB에서 파일 목록 로드
const loadComponentFiles = useCallback(async () => {
if (!id) return false;
try {
let screenId = formData?.screenId;
if (!screenId && typeof window !== "undefined") {
const pathname = window.location.pathname;
const screenMatch = pathname.match(/\/screens\/(\d+)/);
if (screenMatch) {
screenId = parseInt(screenMatch[1]);
}
}
if (!screenId && isDesignMode) {
screenId = 999999;
}
if (!screenId) {
screenId = 0;
}
const params = {
screenId,
componentId: id,
tableName: recordTableName || formData?.tableName || tableName,
recordId: recordId || formData?.id,
columnName: effectiveColumnName,
};
const response = await getComponentFiles(params);
if (response.success) {
const formattedFiles = response.totalFiles.map((file: any) => ({
objid: file.objid || file.id,
savedFileName: file.savedFileName || file.saved_file_name,
realFileName: file.realFileName || file.real_file_name,
fileSize: file.fileSize || file.file_size,
fileExt: file.fileExt || file.file_ext,
regdate: file.regdate,
status: file.status || "ACTIVE",
uploadedAt: file.uploadedAt || new Date().toISOString(),
targetObjid: file.targetObjid || file.target_objid,
filePath: file.filePath || file.file_path,
...file,
}));
// localStorage와 병합
let finalFiles = formattedFiles;
const uniqueKey = getUniqueKey();
try {
const backupFiles = localStorage.getItem(uniqueKey);
if (backupFiles) {
const parsedBackupFiles = JSON.parse(backupFiles);
const serverObjIds = new Set(formattedFiles.map((f: any) => f.objid));
const additionalFiles = parsedBackupFiles.filter((f: any) => !serverObjIds.has(f.objid));
finalFiles = [...formattedFiles, ...additionalFiles];
}
} catch (e) {
console.warn("파일 병합 오류:", e);
}
setUploadedFiles(finalFiles);
if (typeof window !== "undefined") {
(window as any).globalFileState = {
...(window as any).globalFileState,
[uniqueKey]: finalFiles,
};
GlobalFileManager.registerFiles(finalFiles, {
uploadPage: window.location.pathname,
componentId: id,
screenId: formData?.screenId,
recordId: recordId,
});
try {
localStorage.setItem(uniqueKey, JSON.stringify(finalFiles));
} catch (e) {
console.warn("localStorage 백업 실패:", e);
}
}
return true;
}
} catch (error) {
console.error("파일 조회 오류:", error);
}
return false;
}, [
id,
tableName,
columnName,
formData?.screenId,
formData?.tableName,
formData?.id,
getUniqueKey,
recordId,
isRecordMode,
recordTableName,
effectiveColumnName,
isDesignMode,
]);
// 파일 동기화
useEffect(() => {
loadComponentFiles();
}, [loadComponentFiles]);
// 전역 상태 변경 감지
useEffect(() => {
const handleGlobalFileStateChange = (event: CustomEvent) => {
const { componentId, files, isRestore } = event.detail;
if (componentId === id) {
setUploadedFiles(files);
try {
const backupKey = getUniqueKey();
localStorage.setItem(backupKey, JSON.stringify(files));
} catch (e) {
console.warn("localStorage 백업 실패:", e);
}
}
};
if (typeof window !== "undefined") {
window.addEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
return () => {
window.removeEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
};
}
}, [id, getUniqueKey]);
// 파일 업로드 처리
const handleFileUpload = useCallback(
async (files: File[]) => {
if (!files.length) return;
// 중복 체크
const existingFileNames = uploadedFiles.map((f) => f.realFileName.toLowerCase());
const duplicates: string[] = [];
const uniqueFiles: File[] = [];
files.forEach((file) => {
const fileName = file.name.toLowerCase();
if (existingFileNames.includes(fileName)) {
duplicates.push(file.name);
} else {
uniqueFiles.push(file);
}
});
if (duplicates.length > 0) {
toast.error(`중복된 파일: ${duplicates.join(", ")}`);
if (uniqueFiles.length === 0) return;
toast.info(`${uniqueFiles.length}개의 새로운 파일만 업로드합니다.`);
}
const filesToUpload = uniqueFiles.length > 0 ? uniqueFiles : files;
setUploadStatus("uploading");
toast.loading("파일 업로드 중...", { id: "file-upload" });
try {
const effectiveTableName = recordTableName || formData?.tableName || tableName || "default_table";
const effectiveRecordId = recordId || formData?.id;
let screenId = formData?.screenId;
if (!screenId && typeof window !== "undefined") {
const pathname = window.location.pathname;
const screenMatch = pathname.match(/\/screens\/(\d+)/);
if (screenMatch) {
screenId = parseInt(screenMatch[1]);
}
}
let targetObjid;
const effectiveIsRecordMode =
isRecordMode || (effectiveRecordId && !String(effectiveRecordId).startsWith("temp_"));
if (effectiveIsRecordMode && effectiveTableName && effectiveRecordId) {
targetObjid = `${effectiveTableName}:${effectiveRecordId}:${effectiveColumnName}`;
} else if (screenId) {
targetObjid = `screen_files:${screenId}:${id}:${effectiveColumnName}`;
} else {
targetObjid = `temp_${id}`;
}
const userCompanyCode = user?.companyCode || (window as any).__user__?.companyCode;
const finalLinkedTable = effectiveIsRecordMode
? effectiveTableName
: formData?.linkedTable || effectiveTableName;
const uploadData = {
autoLink: formData?.autoLink || true,
linkedTable: finalLinkedTable,
recordId: effectiveRecordId || `temp_${id}`,
columnName: effectiveColumnName,
isVirtualFileColumn: formData?.isVirtualFileColumn || true,
docType: config?.docType || "DOCUMENT",
docTypeName: config?.docTypeName || "일반 문서",
companyCode: userCompanyCode,
tableName: effectiveTableName,
fieldName: effectiveColumnName,
targetObjid: targetObjid,
isRecordMode: effectiveIsRecordMode,
};
const response = await uploadFiles({
files: filesToUpload,
...uploadData,
});
if (response.success) {
const fileData = response.files || (response as any).data || [];
if (fileData.length === 0) {
throw new Error("업로드된 파일 데이터를 받지 못했습니다.");
}
const newFiles = fileData.map((file: any) => ({
objid: file.objid || file.id,
savedFileName: file.saved_file_name || file.savedFileName,
realFileName: file.real_file_name || file.realFileName || file.name,
fileSize: file.file_size || file.fileSize || file.size,
fileExt: file.file_ext || file.fileExt || file.extension,
filePath: file.file_path || file.filePath || file.path,
docType: file.doc_type || file.docType,
docTypeName: file.doc_type_name || file.docTypeName,
targetObjid: file.target_objid || file.targetObjid,
parentTargetObjid: file.parent_target_objid || file.parentTargetObjid,
companyCode: file.company_code || file.companyCode,
writer: file.writer,
regdate: file.regdate,
status: file.status || "ACTIVE",
uploadedAt: new Date().toISOString(),
...file,
}));
const updatedFiles = [...uploadedFiles, ...newFiles];
setUploadedFiles(updatedFiles);
setUploadStatus("success");
// localStorage 백업
try {
const backupKey = getUniqueKey();
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
} catch (e) {
console.warn("localStorage 백업 실패:", e);
}
// 전역 상태 업데이트
if (typeof window !== "undefined") {
const globalFileState = (window as any).globalFileState || {};
const uniqueKey = getUniqueKey();
globalFileState[uniqueKey] = updatedFiles;
(window as any).globalFileState = globalFileState;
GlobalFileManager.registerFiles(newFiles, {
uploadPage: window.location.pathname,
componentId: id,
screenId: formData?.screenId,
recordId: recordId,
});
const syncEvent = new CustomEvent("globalFileStateChanged", {
detail: {
componentId: id,
uniqueKey: uniqueKey,
recordId: recordId,
files: updatedFiles,
fileCount: updatedFiles.length,
timestamp: Date.now(),
},
});
window.dispatchEvent(syncEvent);
}
// 부모 컴포넌트 업데이트
if (onUpdate) {
onUpdate({
uploadedFiles: updatedFiles,
lastFileUpdate: Date.now(),
});
}
// onChange 콜백 (objid 배열 또는 단일 값)
const fileIds = updatedFiles.map((f) => f.objid);
const finalValue = config.multiple ? fileIds : fileIds[0] || "";
const targetColumn = columnName || effectiveColumnName;
console.log("📤 [V2Media] 파일 업로드 완료 - 값 전달:", {
columnName: targetColumn,
fileIds,
finalValue,
hasOnChange: !!onChange,
hasOnFormDataChange: !!onFormDataChange,
});
if (onChange) {
onChange(finalValue);
}
// 폼 데이터 업데이트 - 부모 컴포넌트 시그니처에 맞게 (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, formValue);
}
// 그리드 파일 상태 새로고침 이벤트 발생
if (typeof window !== "undefined") {
const refreshEvent = new CustomEvent("refreshFileStatus", {
detail: {
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: targetColumn,
targetObjid: targetObjid,
fileCount: updatedFiles.length,
},
});
window.dispatchEvent(refreshEvent);
}
toast.dismiss("file-upload");
toast.success(`${newFiles.length}개 파일 업로드 완료`);
} else {
throw new Error(response.message || (response as any).error || "파일 업로드 실패");
}
} catch (error) {
console.error("파일 업로드 오류:", error);
setUploadStatus("error");
toast.dismiss("file-upload");
toast.error(`업로드 오류: ${error instanceof Error ? error.message : "알 수 없는 오류"}`);
}
},
[
config,
uploadedFiles,
onChange,
id,
getUniqueKey,
recordId,
isRecordMode,
recordTableName,
effectiveColumnName,
tableName,
onUpdate,
onFormDataChange,
user,
columnName,
],
);
// 파일 뷰어 열기/닫기
const handleFileView = useCallback((file: FileInfo) => {
setViewerFile(file);
setIsViewerOpen(true);
}, []);
const handleViewerClose = useCallback(() => {
setIsViewerOpen(false);
setViewerFile(null);
}, []);
// 파일 다운로드
const handleFileDownload = useCallback(async (file: FileInfo) => {
try {
await downloadFile({
fileId: file.objid,
serverFilename: file.savedFileName,
originalName: file.realFileName,
});
toast.success(`${file.realFileName} 다운로드 완료`);
} catch (error) {
console.error("파일 다운로드 오류:", error);
toast.error("파일 다운로드 실패");
}
}, []);
// 파일 삭제
const handleFileDelete = useCallback(
async (file: FileInfo | string) => {
try {
const fileId = typeof file === "string" ? file : file.objid;
const fileName = typeof file === "string" ? "파일" : file.realFileName;
const serverFilename = typeof file === "string" ? "temp_file" : file.savedFileName;
await deleteFile(fileId, serverFilename);
const updatedFiles = uploadedFiles.filter((f) => f.objid !== fileId);
setUploadedFiles(updatedFiles);
// localStorage 백업
try {
const backupKey = getUniqueKey();
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
} catch (e) {
console.warn("localStorage 백업 실패:", e);
}
// 전역 상태 업데이트
if (typeof window !== "undefined") {
const globalFileState = (window as any).globalFileState || {};
const uniqueKey = getUniqueKey();
globalFileState[uniqueKey] = updatedFiles;
(window as any).globalFileState = globalFileState;
const syncEvent = new CustomEvent("globalFileStateChanged", {
detail: {
componentId: id,
uniqueKey: uniqueKey,
recordId: recordId,
files: updatedFiles,
fileCount: updatedFiles.length,
timestamp: Date.now(),
action: "delete",
},
});
window.dispatchEvent(syncEvent);
}
if (onUpdate) {
onUpdate({
uploadedFiles: updatedFiles,
lastFileUpdate: Date.now(),
});
}
// onChange 콜백
const fileIds = updatedFiles.map((f) => f.objid);
const finalValue = config.multiple ? fileIds : fileIds[0] || "";
const targetColumn = columnName || effectiveColumnName;
console.log("🗑️ [V2Media] 파일 삭제 완료 - 값 전달:", {
columnName: targetColumn,
fileIds,
finalValue,
});
if (onChange) {
onChange(finalValue);
}
// 폼 데이터 업데이트 - 부모 컴포넌트 시그니처에 맞게 (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, formValue);
}
toast.success(`${fileName} 삭제 완료`);
} catch (error) {
console.error("파일 삭제 오류:", error);
toast.error("파일 삭제 실패");
}
},
[
uploadedFiles,
onUpdate,
id,
isRecordMode,
onFormDataChange,
recordTableName,
recordId,
effectiveColumnName,
getUniqueKey,
onChange,
config.multiple,
columnName,
],
);
// 대표 이미지 로드
const loadRepresentativeImage = useCallback(
async (file: FileInfo) => {
try {
const isImage = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(
file.fileExt.toLowerCase().replace(".", ""),
);
if (!isImage) {
setRepresentativeImageUrl(null);
return;
}
if (!file.objid || file.objid === "0" || file.objid === "") {
setRepresentativeImageUrl(null);
return;
}
const response = await apiClient.get(`/files/download/${file.objid}`, {
params: { serverFilename: file.savedFileName },
responseType: "blob",
});
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
if (representativeImageUrl) {
window.URL.revokeObjectURL(representativeImageUrl);
}
setRepresentativeImageUrl(url);
} catch (error) {
console.error("대표 이미지 로드 실패:", error);
setRepresentativeImageUrl(null);
}
},
[representativeImageUrl],
);
// 대표 이미지 설정
const handleSetRepresentative = useCallback(
async (file: FileInfo) => {
try {
const { setRepresentativeFile } = await import("@/lib/api/file");
await setRepresentativeFile(file.objid);
const updatedFiles = uploadedFiles.map((f) => ({
...f,
isRepresentative: f.objid === file.objid,
}));
setUploadedFiles(updatedFiles);
loadRepresentativeImage(file);
} catch (e) {
console.error("대표 파일 설정 실패:", e);
}
},
[uploadedFiles, loadRepresentativeImage],
);
// uploadedFiles 변경 시 대표 이미지 로드
useEffect(() => {
const representativeFile = uploadedFiles.find((f) => f.isRepresentative) || uploadedFiles[0];
if (representativeFile) {
loadRepresentativeImage(representativeFile);
} else {
setRepresentativeImageUrl(null);
}
return () => {
if (representativeImageUrl) {
window.URL.revokeObjectURL(representativeImageUrl);
}
};
}, [uploadedFiles]);
// 드래그 앤 드롭 핸들러
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!readonly && !disabled) {
setDragOver(true);
}
},
[readonly, disabled],
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
if (!readonly && !disabled) {
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
handleFileUpload(files);
}
}
},
[readonly, disabled, handleFileUpload],
);
// 파일 선택
const handleFileSelect = useCallback(() => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
}, []);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
handleFileUpload(files);
}
e.target.value = "";
},
[handleFileUpload],
);
// 파일 설정
const fileConfig: FileUploadConfig = {
accept: config.accept || "*/*",
multiple: config.multiple || false,
maxSize: config.maxSize || 10 * 1024 * 1024,
disabled: disabled,
readonly: readonly,
};
const showLabel = label && style?.labelDisplay !== false;
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
return (
<div ref={ref} id={id} className="flex w-full flex-col" style={{ width: componentWidth }}>
{/* 라벨 */}
{showLabel && (
<Label
htmlFor={id}
style={{
fontSize: style?.labelFontSize,
color: style?.labelColor,
fontWeight: style?.labelFontWeight,
marginBottom: style?.labelMarginBottom,
}}
className="shrink-0 text-sm font-medium"
>
{label}{required && <span className="text-orange-500">*</span>}
</Label>
)}
{/* 메인 컨테이너 */}
<div className="min-h-0" style={{ height: componentHeight }}>
<div
className="border-border bg-card relative flex h-full w-full flex-col overflow-hidden rounded-lg border"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* 숨겨진 파일 입력 */}
<input
ref={fileInputRef}
type="file"
multiple={config.multiple}
accept={config.accept}
onChange={handleInputChange}
className="hidden"
disabled={disabled || readonly}
/>
{/* 파일이 있는 경우: 대표 이미지/파일 표시 */}
{uploadedFiles.length > 0 ? (
(() => {
const representativeFile = uploadedFiles.find((f) => f.isRepresentative) || uploadedFiles[0];
const isImage =
representativeFile &&
["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(
representativeFile.fileExt.toLowerCase().replace(".", ""),
);
return (
<>
{isImage && representativeImageUrl ? (
<div className="bg-muted/10 relative flex h-full w-full items-center justify-center">
<img
src={representativeImageUrl}
alt={representativeFile.realFileName}
className="h-full w-full object-contain"
/>
</div>
) : isImage && !representativeImageUrl ? (
<div className="flex h-full w-full flex-col items-center justify-center">
<div className="border-primary mb-2 h-8 w-8 animate-spin rounded-full border-b-2"></div>
<p className="text-muted-foreground text-sm"> ...</p>
</div>
) : (
<div className="flex h-full w-full flex-col items-center justify-center">
{getFileIcon(representativeFile.fileExt)}
<p className="mt-3 px-4 text-center text-sm font-medium">{representativeFile.realFileName}</p>
<Badge variant="secondary" className="mt-2">
</Badge>
</div>
)}
{/* 우측 하단 자세히보기 버튼 */}
<div className="absolute right-3 bottom-3">
<Button
variant="secondary"
size="sm"
className="h-8 px-3 text-xs shadow-md"
onClick={() => setIsFileManagerOpen(true)}
>
({uploadedFiles.length})
</Button>
</div>
</>
);
})()
) : (
// 파일이 없는 경우: 업로드 안내
<div
className={cn(
"text-muted-foreground flex h-full w-full cursor-pointer flex-col items-center justify-center",
dragOver && "border-primary bg-primary/5",
(disabled || readonly) && "cursor-not-allowed opacity-50",
)}
onClick={() => !disabled && !readonly && handleFileSelect()}
>
<Upload className="mb-3 h-12 w-12" />
<p className="text-sm font-medium"> </p>
<p className="text-muted-foreground mt-1 text-xs">
{formatFileSize(config.maxSize || 10 * 1024 * 1024)}
{config.accept && config.accept !== "*/*" && ` (${config.accept})`}
</p>
<Button
variant="outline"
size="sm"
className="mt-4 h-8 px-3 text-xs"
onClick={(e) => {
e.stopPropagation();
setIsFileManagerOpen(true);
}}
disabled={disabled || readonly}
>
</Button>
</div>
)}
</div>
</div>
{/* 파일 뷰어 모달 */}
<FileViewerModal
file={viewerFile}
isOpen={isViewerOpen}
onClose={handleViewerClose}
onDownload={handleFileDownload}
onDelete={!isDesignMode ? handleFileDelete : undefined}
/>
{/* 파일 관리 모달 */}
<FileManagerModal
isOpen={isFileManagerOpen}
onClose={() => setIsFileManagerOpen(false)}
uploadedFiles={uploadedFiles}
onFileUpload={handleFileUpload}
onFileDownload={handleFileDownload}
onFileDelete={handleFileDelete}
onFileView={handleFileView}
onSetRepresentative={handleSetRepresentative}
config={fileConfig}
isDesignMode={isDesignMode}
/>
</div>
);
});
V2Media.displayName = "V2Media";
export default V2Media;