refactor: Clean up and enhance component structure in V2Media and ComponentsPanel

- Removed redundant comments and improved clarity in the `ComponentsPanel` for better maintainability.
- Refactored the `V2Media` component to streamline the file handling logic and ensure consistent state management.
- Merged default configurations in `UniversalFormModalConfigPanel` to enhance safety and prevent potential issues with incomplete configurations.
- Updated file upload handling in `FileManagerModal` to improve user experience and maintain consistent styling across components.
This commit is contained in:
kjs 2026-02-23 10:53:10 +09:00
parent 5eab4669f0
commit bfdf061ead
4 changed files with 880 additions and 842 deletions

View File

@ -114,8 +114,7 @@ export function ComponentsPanel({
"image-display", // → v2-media (image)
// 공통코드관리로 통합 예정
"category-manager", // → 공통코드관리 기능으로 통합 예정
// 분할 패널 정리 (split-panel-layout v1 유지)
"split-panel-layout2", // → split-panel-layout로 통합
// 분할 패널 정리
"screen-split-panel", // 화면 임베딩 방식은 사용하지 않음
// 미완성/미사용 컴포넌트 (기존 화면 호환성 유지, 새 추가만 막음)
"accordion-basic", // 아코디언 컴포넌트

View File

@ -25,8 +25,22 @@ 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
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";
@ -77,8 +91,7 @@ const getFileIcon = (extension: string) => {
/**
* V2 ( )
*/
export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
(props, ref) => {
export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>((props, ref) => {
const {
id,
label,
@ -121,11 +134,11 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
const fileInputRef = useRef<HTMLInputElement>(null);
// 레코드 모드 판단
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
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';
const effectiveColumnName = columnName || id || "attachments";
// 레코드용 targetObjid 생성
const getRecordTargetObjid = useCallback(() => {
@ -269,7 +282,20 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
console.error("파일 조회 오류:", error);
}
return false;
}, [id, tableName, columnName, formData?.screenId, formData?.tableName, formData?.id, getUniqueKey, recordId, isRecordMode, recordTableName, effectiveColumnName, isDesignMode]);
}, [
id,
tableName,
columnName,
formData?.screenId,
formData?.tableName,
formData?.id,
getUniqueKey,
recordId,
isRecordMode,
recordTableName,
effectiveColumnName,
isDesignMode,
]);
// 파일 동기화
useEffect(() => {
@ -344,7 +370,8 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
}
let targetObjid;
const effectiveIsRecordMode = isRecordMode || (effectiveRecordId && !String(effectiveRecordId).startsWith('temp_'));
const effectiveIsRecordMode =
isRecordMode || (effectiveRecordId && !String(effectiveRecordId).startsWith("temp_"));
if (effectiveIsRecordMode && effectiveTableName && effectiveRecordId) {
targetObjid = `${effectiveTableName}:${effectiveRecordId}:${effectiveColumnName}`;
@ -358,7 +385,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
const finalLinkedTable = effectiveIsRecordMode
? effectiveTableName
: (formData?.linkedTable || effectiveTableName);
: formData?.linkedTable || effectiveTableName;
const uploadData = {
autoLink: formData?.autoLink || true,
@ -474,9 +501,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
if (onFormDataChange && targetColumn) {
// 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값)
// 복수 파일: 콤마 구분 문자열로 전달
const formValue = config.multiple
? fileIds.join(',')
: (fileIds[0] || '');
const formValue = config.multiple ? fileIds.join(",") : fileIds[0] || "";
console.log("📝 [V2Media] formData 업데이트:", {
columnName: targetColumn,
@ -515,7 +540,22 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
toast.error(`업로드 오류: ${error instanceof Error ? error.message : "알 수 없는 오류"}`);
}
},
[config, uploadedFiles, onChange, id, getUniqueKey, recordId, isRecordMode, recordTableName, effectiveColumnName, tableName, onUpdate, onFormDataChange, user, columnName],
[
config,
uploadedFiles,
onChange,
id,
getUniqueKey,
recordId,
isRecordMode,
recordTableName,
effectiveColumnName,
tableName,
onUpdate,
onFormDataChange,
user,
columnName,
],
);
// 파일 뷰어 열기/닫기
@ -612,9 +652,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
if (onFormDataChange && targetColumn) {
// 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값)
// 복수 파일: 콤마 구분 문자열로 전달
const formValue = config.multiple
? fileIds.join(',')
: (fileIds[0] || '');
const formValue = config.multiple ? fileIds.join(",") : fileIds[0] || "";
console.log("🗑️ [V2Media] 삭제 후 formData 업데이트:", {
columnName: targetColumn,
@ -631,7 +669,20 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
toast.error("파일 삭제 실패");
}
},
[uploadedFiles, onUpdate, id, isRecordMode, onFormDataChange, recordTableName, recordId, effectiveColumnName, getUniqueKey, onChange, config.multiple, columnName],
[
uploadedFiles,
onUpdate,
id,
isRecordMode,
onFormDataChange,
recordTableName,
recordId,
effectiveColumnName,
getUniqueKey,
onChange,
config.multiple,
columnName,
],
);
// 대표 이미지 로드
@ -639,7 +690,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
async (file: FileInfo) => {
try {
const isImage = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(
file.fileExt.toLowerCase().replace(".", "")
file.fileExt.toLowerCase().replace(".", ""),
);
if (!isImage) {
@ -691,12 +742,12 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
console.error("대표 파일 설정 실패:", e);
}
},
[uploadedFiles, loadRepresentativeImage]
[uploadedFiles, loadRepresentativeImage],
);
// uploadedFiles 변경 시 대표 이미지 로드
useEffect(() => {
const representativeFile = uploadedFiles.find(f => f.isRepresentative) || uploadedFiles[0];
const representativeFile = uploadedFiles.find((f) => f.isRepresentative) || uploadedFiles[0];
if (representativeFile) {
loadRepresentativeImage(representativeFile);
} else {
@ -711,13 +762,16 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
}, [uploadedFiles]);
// 드래그 앤 드롭 핸들러
const handleDragOver = useCallback((e: React.DragEvent) => {
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!readonly && !disabled) {
setDragOver(true);
}
}, [readonly, disabled]);
},
[readonly, disabled],
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
@ -725,7 +779,8 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
setDragOver(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
@ -736,7 +791,9 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
handleFileUpload(files);
}
}
}, [readonly, disabled, handleFileUpload]);
},
[readonly, disabled, handleFileUpload],
);
// 파일 선택
const handleFileSelect = useCallback(() => {
@ -745,13 +802,16 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
}
}, []);
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
handleFileUpload(files);
}
e.target.value = '';
}, [handleFileUpload]);
e.target.value = "";
},
[handleFileUpload],
);
// 파일 설정
const fileConfig: FileUploadConfig = {
@ -767,12 +827,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
const componentHeight = size?.height || style?.height;
return (
<div
ref={ref}
id={id}
className="flex w-full flex-col"
style={{ width: componentWidth }}
>
<div ref={ref} id={id} className="flex w-full flex-col" style={{ width: componentWidth }}>
{/* 라벨 */}
{showLabel && (
<Label
@ -783,20 +838,17 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
fontWeight: style?.labelFontWeight,
marginBottom: style?.labelMarginBottom,
}}
className="text-sm font-medium shrink-0"
className="shrink-0 text-sm font-medium"
>
{label}
{required && <span className="text-orange-500 ml-0.5">*</span>}
{required && <span className="ml-0.5 text-orange-500">*</span>}
</Label>
)}
{/* 메인 컨테이너 */}
<div className="min-h-0" style={{ height: componentHeight }}>
<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"
className="border-border bg-card relative flex h-full w-full flex-col overflow-hidden rounded-lg border"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@ -813,16 +865,19 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
/>
{/* 파일이 있는 경우: 대표 이미지/파일 표시 */}
{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(".", "")
{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">
<div className="bg-muted/10 relative flex h-full w-full items-center justify-center">
<img
src={representativeImageUrl}
alt={representativeFile.realFileName}
@ -831,15 +886,13 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
</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 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 text-sm font-medium text-center px-4">
{representativeFile.realFileName}
</p>
<p className="mt-3 px-4 text-center text-sm font-medium">{representativeFile.realFileName}</p>
<Badge variant="secondary" className="mt-2">
</Badge>
@ -847,7 +900,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
)}
{/* 우측 하단 자세히보기 버튼 */}
<div className="absolute bottom-3 right-3">
<div className="absolute right-3 bottom-3">
<Button
variant="secondary"
size="sm"
@ -859,19 +912,20 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
</div>
</>
);
})() : (
})()
) : (
// 파일이 없는 경우: 업로드 안내
<div
className={cn(
"flex h-full w-full flex-col items-center justify-center text-muted-foreground cursor-pointer",
"text-muted-foreground flex h-full w-full cursor-pointer flex-col items-center justify-center",
dragOver && "border-primary bg-primary/5",
(disabled || readonly) && "opacity-50 cursor-not-allowed"
(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-xs text-muted-foreground mt-1">
<p className="text-muted-foreground mt-1 text-xs">
{formatFileSize(config.maxSize || 10 * 1024 * 1024)}
{config.accept && config.accept !== "*/*" && ` (${config.accept})`}
</p>
@ -916,8 +970,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
/>
</div>
);
}
);
});
V2Media.displayName = "V2Media";

View File

@ -21,7 +21,7 @@ import {
MODAL_SIZE_OPTIONS,
SECTION_TYPE_OPTIONS,
} from "./types";
import { defaultSectionConfig, defaultTableSectionConfig, generateSectionId } from "./config";
import { defaultConfig, defaultSectionConfig, defaultTableSectionConfig, generateSectionId } from "./config";
// 모달 import
import { FieldDetailSettingsModal } from "./modals/FieldDetailSettingsModal";
@ -43,10 +43,20 @@ interface AvailableParentField {
}
export function UniversalFormModalConfigPanel({
config,
config: rawConfig,
onChange,
allComponents = [],
}: UniversalFormModalConfigPanelProps) {
// config가 불완전할 수 있으므로 defaultConfig와 병합하여 안전하게 사용
const config: UniversalFormModalConfig = {
...defaultConfig,
...rawConfig,
modal: { ...defaultConfig.modal, ...rawConfig?.modal },
sections: rawConfig?.sections ?? defaultConfig.sections,
saveConfig: { ...defaultConfig.saveConfig, ...rawConfig?.saveConfig },
editMode: { ...defaultConfig.editMode, ...rawConfig?.editMode },
};
// 테이블 목록
const [tables, setTables] = useState<{ name: string; label: string }[]>([]);
const [tableColumns, setTableColumns] = useState<{
@ -255,10 +265,10 @@ export function UniversalFormModalConfigPanel({
// 저장 테이블 변경 시 컬럼 로드
useEffect(() => {
if (config.saveConfig.tableName) {
if (config.saveConfig?.tableName) {
loadTableColumns(config.saveConfig.tableName);
}
}, [config.saveConfig.tableName]);
}, [config.saveConfig?.tableName]);
const loadTables = async () => {
try {
@ -564,9 +574,9 @@ export function UniversalFormModalConfigPanel({
<div className="w-full min-w-0 space-y-3">
<div className="min-w-0 flex-1">
<Label className="mb-1.5 block text-xs font-medium"> </Label>
<p className="text-muted-foreground text-sm">{config.saveConfig.tableName || "(미설정)"}</p>
{config.saveConfig.customApiSave?.enabled &&
config.saveConfig.customApiSave?.multiTable?.enabled && (
<p className="text-muted-foreground text-sm">{config.saveConfig?.tableName || "(미설정)"}</p>
{config.saveConfig?.customApiSave?.enabled &&
config.saveConfig?.customApiSave?.multiTable?.enabled && (
<Badge variant="secondary" className="mt-2 px-2 py-0.5 text-xs">
</Badge>
@ -816,9 +826,9 @@ export function UniversalFormModalConfigPanel({
setSelectedField(field);
setFieldDetailModalOpen(true);
}}
tableName={config.saveConfig.tableName}
tableName={config.saveConfig?.tableName}
tableColumns={
tableColumns[config.saveConfig.tableName || ""]?.map((col) => ({
tableColumns[config.saveConfig?.tableName || ""]?.map((col) => ({
name: col.name,
type: col.type,
label: col.label || col.name,

View File

@ -68,22 +68,22 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
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" />;
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext)) {
return <ImageIcon className="h-5 w-5 text-blue-500" />;
} else if (["pdf", "doc", "docx", "txt", "rtf"].includes(ext)) {
return <FileText className="h-5 w-5 text-red-500" />;
} else if (["xls", "xlsx", "csv"].includes(ext)) {
return <FileText className="h-5 w-5 text-green-500" />;
} else if (["ppt", "pptx"].includes(ext)) {
return <Presentation className="h-5 w-5 text-orange-500" />;
} else if (["mp4", "avi", "mov", "webm"].includes(ext)) {
return <Video className="h-5 w-5 text-purple-500" />;
} else if (["mp3", "wav", "ogg"].includes(ext)) {
return <Music className="h-5 w-5 text-pink-500" />;
} else if (["zip", "rar", "7z"].includes(ext)) {
return <Archive className="h-5 w-5 text-yellow-500" />;
} else {
return <File className="w-5 h-5 text-gray-500" />;
return <File className="h-5 w-5 text-gray-500" />;
}
};
@ -95,12 +95,12 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
try {
const fileArray = Array.from(files);
await onFileUpload(fileArray);
console.log('✅ FileManagerModal: 파일 업로드 완료');
console.log("✅ FileManagerModal: 파일 업로드 완료");
} catch (error) {
console.error('❌ FileManagerModal: 파일 업로드 오류:', error);
console.error("❌ FileManagerModal: 파일 업로드 오류:", error);
} finally {
setUploading(false);
console.log('🔄 FileManagerModal: 업로드 상태 초기화');
console.log("🔄 FileManagerModal: 업로드 상태 초기화");
}
};
@ -137,7 +137,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
handleFileUpload(files);
}
// 입력값 초기화
e.target.value = '';
e.target.value = "";
};
// 파일 뷰어 핸들러
@ -159,8 +159,8 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
// 이미지 파일인 경우 미리보기 로드
// 🔑 점(.)을 제거하고 확장자만 비교
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
const ext = file.fileExt.toLowerCase().replace('.', '');
const imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "svg"];
const ext = file.fileExt.toLowerCase().replace(".", "");
if (imageExtensions.includes(ext) || file.isImage) {
try {
// 이전 Blob URL 해제
@ -171,7 +171,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
// 🔑 항상 apiClient를 통해 Blob 다운로드 (Docker 환경에서 상대 경로 문제 방지)
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/files/preview/${file.objid}`, {
responseType: 'blob'
responseType: "blob",
});
const blob = new Blob([response.data]);
@ -238,32 +238,19 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
return (
<>
<Dialog open={isOpen} onOpenChange={() => {}}>
<DialogContent className="max-w-[95vw] w-[1400px] max-h-[90vh] overflow-hidden [&>button]:hidden">
<DialogContent className="max-h-[90vh] w-[1400px] max-w-[95vw] 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" />
<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="h-4 w-4" />
</Button>
</DialogHeader>
<div className="flex flex-col space-y-3 h-[75vh]">
<div className="flex h-[75vh] flex-col space-y-3">
{/* 파일 업로드 영역 - 높이 축소 */}
{!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' : ''}
`}
className={`cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors ${dragOver ? "border-blue-400 bg-blue-50" : "border-gray-300"} ${config.disabled ? "cursor-not-allowed opacity-50" : "hover:border-gray-400"} ${uploading ? "opacity-75" : ""} `}
onClick={() => {
if (!config.disabled && !isDesignMode) {
fileInputRef.current?.click();
@ -285,44 +272,40 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
{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 className="h-5 w-5 animate-spin rounded-full border-b-2 border-blue-600"></div>
<span className="text-sm font-medium text-blue-600"> ...</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>
<p className="text-sm font-medium text-gray-700"> </p>
</div>
)}
</div>
)}
{/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */}
<div className="flex-1 flex gap-4 min-h-0">
<div className="flex min-h-0 flex-1 gap-4">
{/* 좌측: 이미지 미리보기 (확대/축소 가능) */}
<div className="flex-1 border border-gray-200 rounded-lg bg-gray-900 flex flex-col overflow-hidden relative">
<div className="relative flex flex-1 flex-col overflow-hidden rounded-lg border border-gray-200 bg-gray-900">
{/* 확대/축소 컨트롤 */}
{selectedFile && previewImageUrl && (
<div className="absolute top-3 left-3 z-10 flex items-center gap-1 bg-black/60 rounded-lg p-1">
<div className="absolute top-3 left-3 z-10 flex items-center gap-1 rounded-lg bg-black/60 p-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-white hover:bg-white/20"
onClick={() => setZoomLevel(prev => Math.max(0.25, prev - 0.25))}
onClick={() => setZoomLevel((prev) => Math.max(0.25, prev - 0.25))}
disabled={zoomLevel <= 0.25}
>
<ZoomOut className="h-4 w-4" />
</Button>
<span className="text-white text-xs min-w-[50px] text-center">
{Math.round(zoomLevel * 100)}%
</span>
<span className="min-w-[50px] text-center text-xs text-white">{Math.round(zoomLevel * 100)}%</span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-white hover:bg-white/20"
onClick={() => setZoomLevel(prev => Math.min(4, prev + 0.25))}
onClick={() => setZoomLevel((prev) => Math.min(4, prev + 0.25))}
disabled={zoomLevel >= 4}
>
<ZoomIn className="h-4 w-4" />
@ -341,14 +324,14 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
{/* 이미지 미리보기 영역 - 마우스 휠로 확대/축소, 드래그로 이동 */}
<div
ref={imageContainerRef}
className={`flex-1 flex items-center justify-center overflow-hidden p-4 ${
zoomLevel > 1 ? (isDragging ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-zoom-in'
className={`flex flex-1 items-center justify-center overflow-hidden p-4 ${
zoomLevel > 1 ? (isDragging ? "cursor-grabbing" : "cursor-grab") : "cursor-zoom-in"
}`}
onWheel={(e) => {
if (selectedFile && previewImageUrl) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setZoomLevel(prev => Math.min(4, Math.max(0.25, prev + delta)));
setZoomLevel((prev) => Math.min(4, Math.max(0.25, prev + delta)));
}
}}
onMouseDown={handleMouseDown}
@ -363,7 +346,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
className="transition-transform duration-100 select-none"
style={{
transform: `translate(${imagePosition.x}px, ${imagePosition.y}px) scale(${zoomLevel})`,
transformOrigin: 'center center',
transformOrigin: "center center",
}}
draggable={false}
/>
@ -374,7 +357,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
</div>
) : (
<div className="flex flex-col items-center text-gray-400">
<ImageIcon className="w-16 h-16 mb-2" />
<ImageIcon className="mb-2 h-16 w-16" />
<p className="text-sm"> </p>
</div>
)}
@ -382,19 +365,17 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
{/* 파일 정보 바 */}
{selectedFile && (
<div className="bg-black/60 text-white text-xs px-3 py-2 text-center truncate">
<div className="truncate bg-black/60 px-3 py-2 text-center text-xs text-white">
{selectedFile.realFileName}
</div>
)}
</div>
{/* 우측: 파일 목록 (고정 너비) */}
<div className="w-[400px] shrink-0 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 w-[400px] shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200">
<div className="border-b border-gray-200 bg-gray-50 p-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-700">
</h3>
<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))}
@ -409,20 +390,13 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
{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'}
`}
className={`flex cursor-pointer items-center space-x-3 rounded-lg p-2 transition-colors ${selectedFile?.objid === file.objid ? "border border-blue-200 bg-blue-50" : "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-shrink-0">{getFileIcon(file.fileExt)}</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 truncate">
{file.realFileName}
</span>
<span className="truncate text-sm font-medium text-gray-900">{file.realFileName}</span>
{file.isRepresentative && (
<Badge variant="default" className="h-5 px-1.5 text-xs">
@ -445,7 +419,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
}}
title={file.isRepresentative ? "현재 대표 파일" : "대표 파일로 설정"}
>
<Star className={`w-3 h-3 ${file.isRepresentative ? "fill-white" : ""}`} />
<Star className={`h-3 w-3 ${file.isRepresentative ? "fill-white" : ""}`} />
</Button>
)}
<Button
@ -458,7 +432,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
}}
title="미리보기"
>
<Eye className="w-3 h-3" />
<Eye className="h-3 w-3" />
</Button>
<Button
variant="ghost"
@ -470,7 +444,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
}}
title="다운로드"
>
<Download className="w-3 h-3" />
<Download className="h-3 w-3" />
</Button>
{!isDesignMode && (
<Button
@ -483,7 +457,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
}}
title="삭제"
>
<Trash2 className="w-3 h-3" />
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
@ -492,10 +466,12 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
</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" />
<File className="mb-3 h-12 w-12 text-gray-300" />
<p className="text-sm font-medium text-gray-600"> </p>
<p className="text-xs text-gray-500 mt-1">
{isDesignMode ? '디자인 모드에서는 파일을 업로드할 수 없습니다' : '위의 영역에 파일을 업로드하세요'}
<p className="mt-1 text-xs text-gray-500">
{isDesignMode
? "디자인 모드에서는 파일을 업로드할 수 없습니다"
: "위의 영역에 파일을 업로드하세요"}
</p>
</div>
)}