jskim-node #390

Merged
kjs merged 11 commits from jskim-node into main 2026-02-23 12:17:52 +09:00
4 changed files with 880 additions and 842 deletions
Showing only changes of commit bfdf061ead - Show all commits

View File

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

View File

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

View File

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

View File

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