909 lines
32 KiB
TypeScript
909 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;
|
|
const effectiveColumnName = isRecordMode ? 'attachments' : (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) {
|
|
console.log("📝 [V2Media] formData 업데이트:", {
|
|
columnName: targetColumn,
|
|
fileIds,
|
|
isRecordMode: effectiveIsRecordMode,
|
|
});
|
|
// (fieldName: string, value: any) 형식으로 호출
|
|
onFormDataChange(targetColumn, fileIds);
|
|
}
|
|
|
|
// 그리드 파일 상태 새로고침 이벤트 발생
|
|
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) {
|
|
console.log("🗑️ [V2Media] 삭제 후 formData 업데이트:", {
|
|
columnName: targetColumn,
|
|
fileIds,
|
|
});
|
|
// (fieldName: string, value: any) 형식으로 호출
|
|
onFormDataChange(targetColumn, fileIds);
|
|
}
|
|
|
|
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="text-sm font-medium shrink-0"
|
|
>
|
|
{label}
|
|
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
|
</Label>
|
|
)}
|
|
|
|
{/* 메인 컨테이너 */}
|
|
<div
|
|
className="min-h-0"
|
|
style={{ height: componentHeight }}
|
|
>
|
|
<div
|
|
className="border-border bg-card relative flex h-full w-full flex-col rounded-lg border overflow-hidden"
|
|
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="relative h-full w-full flex items-center justify-center bg-muted/10">
|
|
<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="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-2"></div>
|
|
<p className="text-sm text-muted-foreground">이미지 로딩 중...</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex h-full w-full flex-col items-center justify-center">
|
|
{getFileIcon(representativeFile.fileExt)}
|
|
<p className="mt-3 text-sm font-medium text-center px-4">
|
|
{representativeFile.realFileName}
|
|
</p>
|
|
<Badge variant="secondary" className="mt-2">
|
|
대표 파일
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
|
|
{/* 우측 하단 자세히보기 버튼 */}
|
|
<div className="absolute bottom-3 right-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(
|
|
"flex h-full w-full flex-col items-center justify-center text-muted-foreground cursor-pointer",
|
|
dragOver && "border-primary bg-primary/5",
|
|
(disabled || readonly) && "opacity-50 cursor-not-allowed"
|
|
)}
|
|
onClick={() => !disabled && !readonly && handleFileSelect()}
|
|
>
|
|
<Upload className="mb-3 h-12 w-12" />
|
|
<p className="text-sm font-medium">파일을 드래그하거나 클릭하세요</p>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
최대 {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;
|