화면관리 ui개선 및 파일업로드 설정

This commit is contained in:
kjs 2025-10-15 13:30:11 +09:00
parent 5a8efa51af
commit 3d242c1c8e
14 changed files with 598 additions and 584 deletions

View File

@ -75,6 +75,8 @@ const getCorsOrigin = (): string[] | boolean => {
"http://localhost:9771", // 로컬 개발 환경 "http://localhost:9771", // 로컬 개발 환경
"http://192.168.0.70:5555", // 내부 네트워크 접근 "http://192.168.0.70:5555", // 내부 네트워크 접근
"http://39.117.244.52:5555", // 외부 네트워크 접근 "http://39.117.244.52:5555", // 외부 네트워크 접근
"https://v1.vexplor.com", // 운영 프론트엔드
"https://api.vexplor.com", // 운영 백엔드
]; ];
}; };

View File

@ -340,7 +340,7 @@ export default function ScreenViewPage() {
webType={(() => { webType={(() => {
// 유틸리티 함수로 파일 컴포넌트 감지 // 유틸리티 함수로 파일 컴포넌트 감지
if (isFileComponent(component)) { if (isFileComponent(component)) {
console.log(`🎯 page.tsx - 파일 컴포넌트 감지 → webType: "file"`, { console.log('🎯 page.tsx - 파일 컴포넌트 감지 → webType: "file"', {
componentId: component.id, componentId: component.id,
componentType: component.type, componentType: component.type,
originalWebType: component.webType, originalWebType: component.webType,

View File

@ -202,17 +202,16 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
setAssignmentSuccess(true); setAssignmentSuccess(true);
setAssignmentMessage(successMessage); setAssignmentMessage(successMessage);
// 할당 완료 콜백 호출 // 할당 완료 콜백 호출 (모달은 아직 열린 상태 유지)
if (onAssignmentComplete) { if (onAssignmentComplete) {
onAssignmentComplete(); onAssignmentComplete();
} }
// 3초 후 자동으로 화면 목록으로 이동 // 3초 후 자동으로 모달 닫고 화면 목록으로 이동
setTimeout(() => { setTimeout(() => {
onClose(); // 모달 닫기
if (onBackToList) { if (onBackToList) {
onBackToList(); onBackToList();
} else {
onClose();
} }
}, 3000); }, 3000);
} catch (error: any) { } catch (error: any) {
@ -232,17 +231,16 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
setAssignmentSuccess(true); setAssignmentSuccess(true);
setAssignmentMessage(`"${screenInfo.screenName}" 화면이 저장되었습니다. 나중에 메뉴에 할당할 수 있습니다.`); setAssignmentMessage(`"${screenInfo.screenName}" 화면이 저장되었습니다. 나중에 메뉴에 할당할 수 있습니다.`);
// 할당 완료 콜백 호출 // 할당 완료 콜백 호출 (모달은 아직 열린 상태 유지)
if (onAssignmentComplete) { if (onAssignmentComplete) {
onAssignmentComplete(); onAssignmentComplete();
} }
// 3초 후 자동으로 화면 목록으로 이동 // 3초 후 자동으로 모달 닫고 화면 목록으로 이동
setTimeout(() => { setTimeout(() => {
onClose(); // 모달 닫기
if (onBackToList) { if (onBackToList) {
onBackToList(); onBackToList();
} else {
onClose();
} }
}, 3000); }, 3000);
}; };

View File

@ -79,7 +79,7 @@ const panelConfigs: PanelConfig[] = [
id: "tables", id: "tables",
title: "테이블 목록", title: "테이블 목록",
defaultPosition: "left", defaultPosition: "left",
defaultWidth: 380, defaultWidth: 400,
defaultHeight: 700, defaultHeight: 700,
shortcutKey: "t", shortcutKey: "t",
}, },
@ -87,7 +87,7 @@ const panelConfigs: PanelConfig[] = [
id: "components", id: "components",
title: "컴포넌트", title: "컴포넌트",
defaultPosition: "left", defaultPosition: "left",
defaultWidth: 350, defaultWidth: 400,
defaultHeight: 700, defaultHeight: 700,
shortcutKey: "c", shortcutKey: "c",
}, },
@ -104,7 +104,7 @@ const panelConfigs: PanelConfig[] = [
id: "styles", id: "styles",
title: "스타일", title: "스타일",
defaultPosition: "left", defaultPosition: "left",
defaultWidth: 360, defaultWidth: 400,
defaultHeight: 700, defaultHeight: 700,
shortcutKey: "s", shortcutKey: "s",
}, },
@ -112,7 +112,7 @@ const panelConfigs: PanelConfig[] = [
id: "resolution", id: "resolution",
title: "해상도", title: "해상도",
defaultPosition: "left", defaultPosition: "left",
defaultWidth: 300, defaultWidth: 400,
defaultHeight: 700, defaultHeight: 700,
shortcutKey: "e", shortcutKey: "e",
}, },
@ -129,7 +129,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
gap: 16, gap: 16,
padding: 0, padding: 0,
snapToGrid: true, snapToGrid: true,
showGrid: true, showGrid: false, // 기본값 false로 변경
gridColor: "#d1d5db", gridColor: "#d1d5db",
gridOpacity: 0.5, gridOpacity: 0.5,
}, },
@ -955,7 +955,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
gap: 16, gap: 16,
padding: 0, // padding은 항상 0으로 강제 padding: 0, // padding은 항상 0으로 강제
snapToGrid: true, snapToGrid: true,
showGrid: true, showGrid: false, // 기본값 false로 변경
gridColor: "#d1d5db", gridColor: "#d1d5db",
gridOpacity: 0.5, gridOpacity: 0.5,
}, },
@ -989,7 +989,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
} }
}, [selectedScreen?.screenId]); }, [selectedScreen?.screenId]);
// 스페이스바 키 이벤트 처리 (Pan 모드) // 스페이스바 키 이벤트 처리 (Pan 모드) + 전역 마우스 이벤트
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
// 입력 필드에서는 스페이스바 무시 // 입력 필드에서는 스페이스바 무시
@ -1001,10 +1001,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단 e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단
if (!isPanMode) { if (!isPanMode) {
setIsPanMode(true); setIsPanMode(true);
// 커서 변경 // body에 커서 스타일 추가
if (canvasContainerRef.current) { document.body.style.cursor = "grab";
canvasContainerRef.current.style.cursor = "grab";
}
} }
} }
}; };
@ -1014,21 +1012,58 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단 e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단
setIsPanMode(false); setIsPanMode(false);
setPanState((prev) => ({ ...prev, isPanning: false })); setPanState((prev) => ({ ...prev, isPanning: false }));
// 커서 복원 // body 커서 스타일 복원
if (canvasContainerRef.current) { document.body.style.cursor = "default";
canvasContainerRef.current.style.cursor = "default"; }
} };
const handleMouseDown = (e: MouseEvent) => {
if (isPanMode && canvasContainerRef.current) {
e.preventDefault();
setPanState({
isPanning: true,
startX: e.pageX,
startY: e.pageY,
scrollLeft: canvasContainerRef.current.scrollLeft,
scrollTop: canvasContainerRef.current.scrollTop,
});
// 드래그 중 커서 변경
document.body.style.cursor = "grabbing";
}
};
const handleMouseMove = (e: MouseEvent) => {
if (isPanMode && panState.isPanning && canvasContainerRef.current) {
e.preventDefault();
const dx = e.pageX - panState.startX;
const dy = e.pageY - panState.startY;
canvasContainerRef.current.scrollLeft = panState.scrollLeft - dx;
canvasContainerRef.current.scrollTop = panState.scrollTop - dy;
}
};
const handleMouseUp = () => {
if (isPanMode) {
setPanState((prev) => ({ ...prev, isPanning: false }));
// 드래그 종료 시 커서 복원
document.body.style.cursor = "grab";
} }
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp); window.addEventListener("keyup", handleKeyUp);
window.addEventListener("mousedown", handleMouseDown);
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => { return () => {
window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp); window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("mousedown", handleMouseDown);
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
}; };
}, [isPanMode]); }, [isPanMode, panState.isPanning, panState.startX, panState.startY, panState.scrollLeft, panState.scrollTop]);
// 마우스 휠로 줌 제어 // 마우스 휠로 줌 제어
useEffect(() => { useEffect(() => {
@ -1206,7 +1241,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 저장 // 저장
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
if (!selectedScreen?.screenId) return; if (!selectedScreen?.screenId) {
console.error("❌ 저장 실패: selectedScreen 또는 screenId가 없습니다.", selectedScreen);
toast.error("화면 정보가 없습니다.");
return;
}
try { try {
setIsSaving(true); setIsSaving(true);
@ -1215,23 +1254,27 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
...layout, ...layout,
screenResolution: screenResolution, screenResolution: screenResolution,
}; };
console.log("💾 저장할 레이아웃 데이터:", { console.log("💾 저장 시작:", {
screenId: selectedScreen.screenId,
componentsCount: layoutWithResolution.components.length, componentsCount: layoutWithResolution.components.length,
gridSettings: layoutWithResolution.gridSettings, gridSettings: layoutWithResolution.gridSettings,
screenResolution: layoutWithResolution.screenResolution, screenResolution: layoutWithResolution.screenResolution,
}); });
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
console.log("✅ 저장 성공! 메뉴 할당 모달 열기");
toast.success("화면이 저장되었습니다."); toast.success("화면이 저장되었습니다.");
// 저장 성공 후 메뉴 할당 모달 열기 // 저장 성공 후 메뉴 할당 모달 열기
setShowMenuAssignmentModal(true); setShowMenuAssignmentModal(true);
} catch (error) { } catch (error) {
// console.error("저장 실패:", error); console.error("❌ 저장 실패:", error);
toast.error("저장 중 오류가 발생했습니다."); toast.error("저장 중 오류가 발생했습니다.");
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, [selectedScreen?.screenId, layout, screenResolution]); }, [selectedScreen, layout, screenResolution]);
// 템플릿 드래그 처리 // 템플릿 드래그 처리
const handleTemplateDrop = useCallback( const handleTemplateDrop = useCallback(
@ -1861,6 +1904,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
webType: component.webType, webType: component.webType,
category: component.category, category: component.category,
defaultConfig: component.defaultConfig, defaultConfig: component.defaultConfig,
defaultSize: component.defaultSize,
}); });
// 컴포넌트별 gridColumns 설정 및 크기 계산 // 컴포넌트별 gridColumns 설정 및 크기 계산
@ -1875,15 +1919,62 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
if (isCardDisplay) { if (isCardDisplay) {
gridColumns = 8; gridColumns = 8;
} else if (isTableList) { } else if (isTableList) {
gridColumns = 1; gridColumns = 12; // 테이블은 전체 너비
} else { } else {
// 일반 컴포넌트: defaultSize.width를 기준으로 그리드 컬럼 수 계산 // 웹타입별 적절한 그리드 컬럼 수 설정
// 그리드가 활성화된 경우에만 const webType = component.webType;
if (layout.gridSettings?.snapToGrid && gridInfo) { const componentId = component.id;
const columnWidth = gridInfo.columnWidth + gridInfo.gap;
const estimatedColumns = Math.round(component.defaultSize.width / columnWidth); // 웹타입별 기본 컬럼 수 매핑
gridColumns = Math.max(1, Math.min(12, estimatedColumns)); // 1-12 범위로 제한 const gridColumnsMap: Record<string, number> = {
} // 입력 컴포넌트 (INPUT 카테고리)
"text-input": 4, // 텍스트 입력 (33%)
"number-input": 2, // 숫자 입력 (16.67%)
"email-input": 4, // 이메일 입력 (33%)
"tel-input": 3, // 전화번호 입력 (25%)
"date-input": 3, // 날짜 입력 (25%)
"datetime-input": 4, // 날짜시간 입력 (33%)
"time-input": 2, // 시간 입력 (16.67%)
"textarea-basic": 6, // 텍스트 영역 (50%)
"select-basic": 3, // 셀렉트 (25%)
"checkbox-basic": 2, // 체크박스 (16.67%)
"radio-basic": 3, // 라디오 (25%)
"file-basic": 4, // 파일 (33%)
// 표시 컴포넌트 (DISPLAY 카테고리)
"label-basic": 2, // 라벨 (16.67%)
"text-display": 3, // 텍스트 표시 (25%)
"card-display": 8, // 카드 (66.67%)
"badge-basic": 1, // 배지 (8.33%)
"alert-basic": 6, // 알림 (50%)
"divider-basic": 12, // 구분선 (100%)
// 액션 컴포넌트 (ACTION 카테고리)
"button-basic": 1, // 버튼 (8.33%)
"button-primary": 1, // 프라이머리 버튼 (8.33%)
"button-secondary": 1, // 세컨더리 버튼 (8.33%)
"icon-button": 1, // 아이콘 버튼 (8.33%)
// 레이아웃 컴포넌트
"container-basic": 6, // 컨테이너 (50%)
"section-basic": 12, // 섹션 (100%)
"panel-basic": 6, // 패널 (50%)
// 기타
"image-basic": 4, // 이미지 (33%)
"icon-basic": 1, // 아이콘 (8.33%)
"progress-bar": 4, // 프로그레스 바 (33%)
"chart-basic": 6, // 차트 (50%)
};
// componentId 또는 webType으로 매핑, 없으면 기본값 3
gridColumns = gridColumnsMap[componentId] || gridColumnsMap[webType] || 3;
console.log("🎯 컴포넌트 타입별 gridColumns 설정:", {
componentId,
webType,
gridColumns,
});
} }
// 그리드 시스템이 활성화된 경우 gridColumns에 맞춰 너비 재계산 // 그리드 시스템이 활성화된 경우 gridColumns에 맞춰 너비 재계산
@ -1914,6 +2005,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}); });
} }
console.log("🎨 최종 컴포넌트 크기:", {
componentId: component.id,
componentName: component.name,
defaultSize: component.defaultSize,
finalSize: componentSize,
gridColumns,
});
const newComponent: ComponentData = { const newComponent: ComponentData = {
id: generateComponentId(), id: generateComponentId(),
type: "component", // ✅ 새 컴포넌트 시스템 사용 type: "component", // ✅ 새 컴포넌트 시스템 사용
@ -2098,6 +2197,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
return defaultColumns; return defaultColumns;
}; };
// 웹타입별 기본 높이 계산
const getDefaultHeight = (widgetType: string): number => {
const heightMap: Record<string, number> = {
textarea: 120, // 텍스트 영역은 3줄 (40 * 3)
checkbox: 80, // 체크박스 그룹 (40 * 2)
radio: 80, // 라디오 버튼 (40 * 2)
file: 240, // 파일 업로드 (40 * 6)
};
return heightMap[widgetType] || 40; // 기본값 40
};
// 웹타입별 기본 설정 생성 // 웹타입별 기본 설정 생성
const getDefaultWebTypeConfig = (widgetType: string) => { const getDefaultWebTypeConfig = (widgetType: string) => {
switch (widgetType) { switch (widgetType) {
@ -2282,7 +2393,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정 parentId: formContainerId, // 폼 컨테이너의 자식으로 설정
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입 componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
position: { x: relativeX, y: relativeY, z: 1 } as Position, position: { x: relativeX, y: relativeY, z: 1 } as Position,
size: { width: componentWidth, height: 40 }, size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
gridColumns: calculatedGridColumns, gridColumns: calculatedGridColumns,
// 코드 타입인 경우 코드 카테고리 정보 추가 // 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" && ...(column.widgetType === "code" &&
@ -2345,7 +2456,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
readonly: false, readonly: false,
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입 componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
position: { x, y, z: 1 } as Position, position: { x, y, z: 1 } as Position,
size: { width: componentWidth, height: 40 }, size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
gridColumns: calculatedGridColumns, gridColumns: calculatedGridColumns,
// 코드 타입인 경우 코드 카테고리 정보 추가 // 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" && ...(column.widgetType === "code" &&
@ -3088,7 +3199,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
gap: 16, gap: 16,
padding: 0, padding: 0,
snapToGrid: true, snapToGrid: true,
showGrid: true, showGrid: false,
gridColor: "#d1d5db", gridColor: "#d1d5db",
gridOpacity: 0.5, gridOpacity: 0.5,
}, },
@ -3649,7 +3760,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
{/* 열린 패널들 (좌측에서 우측으로 누적) */} {/* 열린 패널들 (좌측에서 우측으로 누적) */}
{panelStates.tables?.isOpen && ( {panelStates.tables?.isOpen && (
<div className="flex h-full w-[380px] flex-col border-r border-gray-200 bg-white shadow-lg"> <div className="flex h-full w-[400px] flex-col border-r border-gray-200 bg-white shadow-lg">
<div className="flex items-center justify-between border-b border-gray-200 p-3"> <div className="flex items-center justify-between border-b border-gray-200 p-3">
<h3 className="font-semibold text-gray-900"> </h3> <h3 className="font-semibold text-gray-900"> </h3>
<button onClick={() => closePanel("tables")} className="text-gray-400 hover:text-gray-600"> <button onClick={() => closePanel("tables")} className="text-gray-400 hover:text-gray-600">
@ -3676,7 +3787,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
)} )}
{panelStates.components?.isOpen && ( {panelStates.components?.isOpen && (
<div className="flex h-full w-[350px] flex-col border-r border-gray-200 bg-white shadow-lg"> <div className="flex h-full w-[400px] flex-col border-r border-gray-200 bg-white shadow-lg">
<div className="flex items-center justify-between border-b border-gray-200 p-3"> <div className="flex items-center justify-between border-b border-gray-200 p-3">
<h3 className="font-semibold text-gray-900"></h3> <h3 className="font-semibold text-gray-900"></h3>
<button onClick={() => closePanel("components")} className="text-gray-400 hover:text-gray-600"> <button onClick={() => closePanel("components")} className="text-gray-400 hover:text-gray-600">
@ -3713,7 +3824,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
)} )}
{panelStates.styles?.isOpen && ( {panelStates.styles?.isOpen && (
<div className="flex h-full w-[360px] flex-col border-r border-gray-200 bg-white shadow-lg"> <div className="flex h-full w-[400px] flex-col border-r border-gray-200 bg-white shadow-lg">
<div className="flex items-center justify-between border-b border-gray-200 p-3"> <div className="flex items-center justify-between border-b border-gray-200 p-3">
<h3 className="font-semibold text-gray-900"></h3> <h3 className="font-semibold text-gray-900"></h3>
<button onClick={() => closePanel("styles")} className="text-gray-400 hover:text-gray-600"> <button onClick={() => closePanel("styles")} className="text-gray-400 hover:text-gray-600">
@ -3740,7 +3851,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
)} )}
{panelStates.resolution?.isOpen && ( {panelStates.resolution?.isOpen && (
<div className="flex h-full w-[300px] flex-col border-r border-gray-200 bg-white shadow-lg"> <div className="flex h-full w-[400px] flex-col border-r border-gray-200 bg-white shadow-lg">
<div className="flex items-center justify-between border-b border-gray-200 p-3"> <div className="flex items-center justify-between border-b border-gray-200 p-3">
<h3 className="font-semibold text-gray-900"></h3> <h3 className="font-semibold text-gray-900"></h3>
<button onClick={() => closePanel("resolution")} className="text-gray-400 hover:text-gray-600"> <button onClick={() => closePanel("resolution")} className="text-gray-400 hover:text-gray-600">
@ -3757,38 +3868,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
<div <div
ref={canvasContainerRef} ref={canvasContainerRef}
className="relative flex-1 overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 px-2 py-6" className="relative flex-1 overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 px-2 py-6"
style={{ cursor: isPanMode ? (panState.isPanning ? "grabbing" : "grab") : "default" }}
onMouseDown={(e) => {
if (isPanMode && canvasContainerRef.current) {
e.preventDefault();
setPanState({
isPanning: true,
startX: e.pageX,
startY: e.pageY,
scrollLeft: canvasContainerRef.current.scrollLeft,
scrollTop: canvasContainerRef.current.scrollTop,
});
}
}}
onMouseMove={(e) => {
if (isPanMode && panState.isPanning && canvasContainerRef.current) {
e.preventDefault();
const dx = e.pageX - panState.startX;
const dy = e.pageY - panState.startY;
canvasContainerRef.current.scrollLeft = panState.scrollLeft - dx;
canvasContainerRef.current.scrollTop = panState.scrollTop - dy;
}
}}
onMouseUp={() => {
if (isPanMode) {
setPanState((prev) => ({ ...prev, isPanning: false }));
}
}}
onMouseLeave={() => {
if (isPanMode) {
setPanState((prev) => ({ ...prev, isPanning: false }));
}
}}
> >
{/* Pan 모드 안내 */} {/* Pan 모드 안내 */}
{isPanMode && ( {isPanMode && (
@ -3816,7 +3895,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
> >
<div <div
ref={canvasRef} ref={canvasRef}
className="relative h-full w-full overflow-auto bg-gradient-to-br from-slate-50/30 to-gray-100/20" // 미묘한 그라데이션 배경 className="relative h-full w-full overflow-auto bg-gradient-to-br from-slate-50/30 to-gray-100/20"
onClick={(e) => { onClick={(e) => {
if (e.target === e.currentTarget && !selectionDrag.wasSelecting && !isPanMode) { if (e.target === e.currentTarget && !selectionDrag.wasSelecting && !isPanMode) {
setSelectedComponent(null); setSelectedComponent(null);
@ -4102,9 +4181,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
{/* 메뉴 할당 모달 */} {/* 메뉴 할당 모달 */}
{showMenuAssignmentModal && selectedScreen && ( {showMenuAssignmentModal && selectedScreen && (
<MenuAssignmentModal <MenuAssignmentModal
screenId={selectedScreen.screenId} screenInfo={selectedScreen}
isOpen={showMenuAssignmentModal} isOpen={showMenuAssignmentModal}
onClose={() => setShowMenuAssignmentModal(false)} onClose={() => setShowMenuAssignmentModal(false)}
onAssignmentComplete={() => {
// 모달을 즉시 닫지 않고, MenuAssignmentModal이 3초 후 자동으로 닫히도록 함
// setShowMenuAssignmentModal(false);
// toast.success("메뉴에 화면이 할당되었습니다.");
}}
onBackToList={onBackToList}
/> />
)} )}
{/* 파일첨부 상세 모달 */} {/* 파일첨부 상세 모달 */}

View File

@ -1,14 +1,11 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Palette, Type, Square, Box, Eye, RotateCcw } from "lucide-react"; import { Palette, Type, Square, Box } from "lucide-react";
import { ComponentStyle } from "@/types/screen"; import { ComponentStyle } from "@/types/screen";
interface StyleEditorProps { interface StyleEditorProps {
@ -30,243 +27,218 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
onStyleChange(newStyle); onStyleChange(newStyle);
}; };
const resetStyle = () => {
const resetStyle: ComponentStyle = {};
setLocalStyle(resetStyle);
onStyleChange(resetStyle);
};
const applyStyle = () => {
onStyleChange(localStyle);
};
return ( return (
<div className={`space-y-4 ${className}`}> <div className={`space-y-6 p-4 ${className}`}>
<Card> {/* 여백 섹션 */}
<CardHeader className="pb-3"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center gap-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium"> <Box className="h-4 w-4 text-blue-600" />
<Palette className="h-4 w-4" /> <h3 className="font-semibold text-gray-900"></h3>
</div>
</CardTitle> <Separator />
<div className="flex items-center gap-2"> <div className="space-y-4">
<Button variant="outline" size="sm" onClick={resetStyle}> <div className="grid grid-cols-2 gap-4">
<RotateCcw className="mr-1 h-3 w-3" /> <div className="space-y-2">
<Label htmlFor="margin"> </Label>
</Button> <Input
<Button size="sm" onClick={applyStyle}> id="margin"
<Eye className="mr-1 h-3 w-3" /> type="text"
placeholder="10px, 1rem"
</Button> value={localStyle.margin || ""}
onChange={(e) => handleStyleChange("margin", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="padding"> </Label>
<Input
id="padding"
type="text"
placeholder="10px, 1rem"
value={localStyle.padding || ""}
onChange={(e) => handleStyleChange("padding", e.target.value)}
/>
</div> </div>
</div> </div>
</CardHeader>
<CardContent>
<Tabs defaultValue="spacing" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="spacing">
<Box className="mr-1 h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="border">
<Square className="mr-1 h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="background">
<Palette className="mr-1 h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="typography">
<Type className="mr-1 h-3 w-3" />
</TabsTrigger>
</TabsList>
{/* 여백 탭 */} <div className="grid grid-cols-2 gap-4">
<TabsContent value="spacing" className="space-y-4"> <div className="space-y-2">
<div className="grid grid-cols-2 gap-4"> <Label htmlFor="gap"></Label>
<div className="space-y-2"> <Input
<Label htmlFor="margin"> </Label> id="gap"
<Input type="text"
id="margin" placeholder="10px, 1rem"
type="text" value={localStyle.gap || ""}
placeholder="10px, 1rem" onChange={(e) => handleStyleChange("gap", e.target.value)}
value={localStyle.margin || ""} />
onChange={(e) => handleStyleChange("margin", e.target.value)} </div>
/> </div>
</div> </div>
<div className="space-y-2"> </div>
<Label htmlFor="padding"> </Label>
<Input
id="padding"
type="text"
placeholder="10px, 1rem"
value={localStyle.padding || ""}
onChange={(e) => handleStyleChange("padding", e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4"> {/* 테두리 섹션 */}
<div className="space-y-2"> <div className="space-y-4">
<Label htmlFor="gap"></Label> <div className="flex items-center gap-2">
<Input <Square className="h-4 w-4 text-green-600" />
id="gap" <h3 className="font-semibold text-gray-900"></h3>
type="text" </div>
placeholder="10px, 1rem" <Separator />
value={localStyle.gap || ""} <div className="space-y-4">
onChange={(e) => handleStyleChange("gap", e.target.value)} <div className="grid grid-cols-2 gap-4">
/> <div className="space-y-2">
</div> <Label htmlFor="borderWidth"> </Label>
</div> <Input
</TabsContent> id="borderWidth"
type="text"
placeholder="1px, 2px"
value={localStyle.borderWidth || ""}
onChange={(e) => handleStyleChange("borderWidth", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="borderStyle"> </Label>
<Select
value={localStyle.borderStyle || "solid"}
onValueChange={(value) => handleStyleChange("borderStyle", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="solid"></SelectItem>
<SelectItem value="dashed"></SelectItem>
<SelectItem value="dotted"></SelectItem>
<SelectItem value="none"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 테두리 탭 */} <div className="grid grid-cols-2 gap-4">
<TabsContent value="border" className="space-y-4"> <div className="space-y-2">
<div className="grid grid-cols-2 gap-4"> <Label htmlFor="borderColor"> </Label>
<div className="space-y-2"> <Input
<Label htmlFor="borderWidth"> </Label> id="borderColor"
<Input type="color"
id="borderWidth" value={localStyle.borderColor || "#000000"}
type="text" onChange={(e) => handleStyleChange("borderColor", e.target.value)}
placeholder="1px, 2px" />
value={localStyle.borderWidth || ""} </div>
onChange={(e) => handleStyleChange("borderWidth", e.target.value)} <div className="space-y-2">
/> <Label htmlFor="borderRadius"> </Label>
</div> <Input
<div className="space-y-2"> id="borderRadius"
<Label htmlFor="borderStyle"> </Label> type="text"
<Select placeholder="5px, 10px"
value={localStyle.borderStyle || "solid"} value={localStyle.borderRadius || ""}
onValueChange={(value) => handleStyleChange("borderStyle", value)} onChange={(e) => handleStyleChange("borderRadius", e.target.value)}
> />
<SelectTrigger> </div>
<SelectValue /> </div>
</SelectTrigger> </div>
<SelectContent> </div>
<SelectItem value="solid"></SelectItem>
<SelectItem value="dashed"></SelectItem>
<SelectItem value="dotted"></SelectItem>
<SelectItem value="none"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4"> {/* 배경 섹션 */}
<div className="space-y-2"> <div className="space-y-4">
<Label htmlFor="borderColor"> </Label> <div className="flex items-center gap-2">
<Input <Palette className="h-4 w-4 text-purple-600" />
id="borderColor" <h3 className="font-semibold text-gray-900"></h3>
type="color" </div>
value={localStyle.borderColor || "#000000"} <Separator />
onChange={(e) => handleStyleChange("borderColor", e.target.value)} <div className="space-y-4">
/> <div className="space-y-2">
</div> <Label htmlFor="backgroundColor"> </Label>
<div className="space-y-2"> <Input
<Label htmlFor="borderRadius"> </Label> id="backgroundColor"
<Input type="color"
id="borderRadius" value={localStyle.backgroundColor || "#ffffff"}
type="text" onChange={(e) => handleStyleChange("backgroundColor", e.target.value)}
placeholder="5px, 10px" />
value={localStyle.borderRadius || ""} </div>
onChange={(e) => handleStyleChange("borderRadius", e.target.value)}
/>
</div>
</div>
</TabsContent>
{/* 배경 탭 */} <div className="space-y-2">
<TabsContent value="background" className="space-y-4"> <Label htmlFor="backgroundImage"> </Label>
<div className="space-y-2"> <Input
<Label htmlFor="backgroundColor"> </Label> id="backgroundImage"
<Input type="text"
id="backgroundColor" placeholder="url('image.jpg')"
type="color" value={localStyle.backgroundImage || ""}
value={localStyle.backgroundColor || "#ffffff"} onChange={(e) => handleStyleChange("backgroundImage", e.target.value)}
onChange={(e) => handleStyleChange("backgroundColor", e.target.value)} />
/> </div>
</div> </div>
</div>
<div className="space-y-2"> {/* 텍스트 섹션 */}
<Label htmlFor="backgroundImage"> </Label> <div className="space-y-4">
<Input <div className="flex items-center gap-2">
id="backgroundImage" <Type className="h-4 w-4 text-orange-600" />
type="text" <h3 className="font-semibold text-gray-900"></h3>
placeholder="url('image.jpg')" </div>
value={localStyle.backgroundImage || ""} <Separator />
onChange={(e) => handleStyleChange("backgroundImage", e.target.value)} <div className="space-y-4">
/> <div className="grid grid-cols-2 gap-4">
</div> <div className="space-y-2">
</TabsContent> <Label htmlFor="color"> </Label>
<Input
id="color"
type="color"
value={localStyle.color || "#000000"}
onChange={(e) => handleStyleChange("color", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="fontSize"> </Label>
<Input
id="fontSize"
type="text"
placeholder="14px, 1rem"
value={localStyle.fontSize || ""}
onChange={(e) => handleStyleChange("fontSize", e.target.value)}
/>
</div>
</div>
{/* 텍스트 탭 */} <div className="grid grid-cols-2 gap-4">
<TabsContent value="typography" className="space-y-4"> <div className="space-y-2">
<div className="grid grid-cols-2 gap-4"> <Label htmlFor="fontWeight"> </Label>
<div className="space-y-2"> <Select
<Label htmlFor="color"> </Label> value={localStyle.fontWeight || "normal"}
<Input onValueChange={(value) => handleStyleChange("fontWeight", value)}
id="color" >
type="color" <SelectTrigger>
value={localStyle.color || "#000000"} <SelectValue />
onChange={(e) => handleStyleChange("color", e.target.value)} </SelectTrigger>
/> <SelectContent>
</div> <SelectItem value="normal"></SelectItem>
<div className="space-y-2"> <SelectItem value="bold"></SelectItem>
<Label htmlFor="fontSize"> </Label> <SelectItem value="100">100</SelectItem>
<Input <SelectItem value="400">400</SelectItem>
id="fontSize" <SelectItem value="500">500</SelectItem>
type="text" <SelectItem value="600">600</SelectItem>
placeholder="14px, 1rem" <SelectItem value="700">700</SelectItem>
value={localStyle.fontSize || ""} </SelectContent>
onChange={(e) => handleStyleChange("fontSize", e.target.value)} </Select>
/> </div>
</div> <div className="space-y-2">
</div> <Label htmlFor="textAlign"> </Label>
<Select
<div className="grid grid-cols-2 gap-4"> value={localStyle.textAlign || "left"}
<div className="space-y-2"> onValueChange={(value) => handleStyleChange("textAlign", value)}
<Label htmlFor="fontWeight"> </Label> >
<Select <SelectTrigger>
value={localStyle.fontWeight || "normal"} <SelectValue />
onValueChange={(value) => handleStyleChange("fontWeight", value)} </SelectTrigger>
> <SelectContent>
<SelectTrigger> <SelectItem value="left"></SelectItem>
<SelectValue /> <SelectItem value="center"></SelectItem>
</SelectTrigger> <SelectItem value="right"></SelectItem>
<SelectContent> <SelectItem value="justify"></SelectItem>
<SelectItem value="normal"></SelectItem> </SelectContent>
<SelectItem value="bold"></SelectItem> </Select>
<SelectItem value="100">100</SelectItem> </div>
<SelectItem value="400">400</SelectItem> </div>
<SelectItem value="500">500</SelectItem> </div>
<SelectItem value="600">600</SelectItem> </div>
<SelectItem value="700">700</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="textAlign"> </Label>
<Select
value={localStyle.textAlign || "left"}
onValueChange={(value) => handleStyleChange("textAlign", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="right"></SelectItem>
<SelectItem value="justify"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div> </div>
); );
} }

View File

@ -227,23 +227,21 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
)} )}
{/* 크기 */} {/* 크기 */}
<div className="grid grid-cols-2 gap-2"> <div>
<div> <Label> (px)</Label>
<Label> (px)</Label> <Input
<Input type="number"
type="number" value={selectedComponent.size?.height || 0}
value={widget.width || 0} onChange={(e) => {
onChange={(e) => handleUpdate("width", parseInt(e.target.value) || 0)} const value = parseInt(e.target.value) || 0;
/> // 40 단위로 반올림
</div> const roundedValue = Math.max(40, Math.round(value / 40) * 40);
<div> handleUpdate("size.height", roundedValue);
<Label> (px)</Label> }}
<Input step={40}
type="number" placeholder="40 단위로 입력"
value={widget.height || 0} />
onChange={(e) => handleUpdate("height", parseInt(e.target.value) || 0)} <p className="mt-1 text-xs text-gray-500">40 </p>
/>
</div>
</div> </div>
{/* 컬럼 스팬 */} {/* 컬럼 스팬 */}

View File

@ -3,7 +3,7 @@
import React from "react"; import React from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Database, Layout, Cog, Settings, Palette, Monitor, Square } from "lucide-react"; import { Database, Layout, Cog, Settings, Palette, Monitor } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface ToolbarButton { export interface ToolbarButton {
@ -110,14 +110,6 @@ export const defaultToolbarButtons: ToolbarButton[] = [
group: "editor", group: "editor",
panelWidth: 300, panelWidth: 300,
}, },
{
id: "zone",
label: "구역",
icon: <Square className="h-5 w-5" />,
shortcut: "Z",
group: "editor",
panelWidth: 0, // 토글만 (패널 없음)
},
]; ];
export default LeftUnifiedToolbar; export default LeftUnifiedToolbar;

View File

@ -58,27 +58,56 @@ export const usePanelState = (panels: PanelConfig[]) => {
}); });
}, [panels]); }, [panels]);
// 패널 토글 // 패널 토글 (다른 패널 자동 닫기)
const togglePanel = useCallback((panelId: string) => { const togglePanel = useCallback((panelId: string) => {
setPanelStates((prev) => ({ setPanelStates((prev) => {
...prev, const isCurrentlyOpen = prev[panelId]?.isOpen;
[panelId]: { const newStates = { ...prev };
...prev[panelId],
isOpen: !prev[panelId]?.isOpen, // 다른 모든 패널 닫기
}, Object.keys(newStates).forEach((id) => {
})); if (id !== panelId) {
newStates[id] = {
...newStates[id],
isOpen: false,
};
}
});
// 현재 패널 토글
newStates[panelId] = {
...newStates[panelId],
isOpen: !isCurrentlyOpen,
};
return newStates;
});
}, []); }, []);
// 패널 열기 // 패널 열기 (다른 패널 자동 닫기)
const openPanel = useCallback((panelId: string) => { const openPanel = useCallback((panelId: string) => {
// console.log("📂 패널 열기:", panelId); // console.log("📂 패널 열기:", panelId);
setPanelStates((prev) => ({ setPanelStates((prev) => {
...prev, const newStates = { ...prev };
[panelId]: {
...prev[panelId], // 다른 모든 패널 닫기
Object.keys(newStates).forEach((id) => {
if (id !== panelId) {
newStates[id] = {
...newStates[id],
isOpen: false,
};
}
});
// 현재 패널 열기
newStates[panelId] = {
...newStates[panelId],
isOpen: true, isOpen: true,
}, };
}));
return newStates;
});
}, []); }, []);
// 패널 닫기 // 패널 닫기

View File

@ -135,7 +135,7 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
checked={checkedValues.includes(option.value)} checked={checkedValues.includes(option.value)}
onChange={(e) => handleGroupChange(option.value, e.target.checked)} onChange={(e) => handleGroupChange(option.value, e.target.checked)}
disabled={componentConfig.disabled || isDesignMode} disabled={componentConfig.disabled || isDesignMode}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-0 focus:outline-none"
/> />
<span className="text-sm text-gray-900">{option.label}</span> <span className="text-sm text-gray-900">{option.label}</span>
</label> </label>
@ -146,13 +146,7 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
// checkbox (기본 체크박스) // checkbox (기본 체크박스)
return ( return (
<label <label className="flex h-full w-full cursor-pointer items-center gap-3">
className={cn(
"flex h-full w-full cursor-pointer items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 p-3",
"transition-all hover:border-orange-400 hover:shadow-md",
isSelected && "ring-2 ring-orange-500",
)}
>
<input <input
type="checkbox" type="checkbox"
checked={isChecked} checked={isChecked}

View File

@ -23,7 +23,7 @@ export const CheckboxBasicDefinition = createComponentDefinition({
defaultConfig: { defaultConfig: {
placeholder: "입력하세요", placeholder: "입력하세요",
}, },
defaultSize: { width: 150, height: 32 }, defaultSize: { width: 150, height: 120 }, // 40 * 3 (3개 옵션)
configPanel: CheckboxBasicConfigPanel, configPanel: CheckboxBasicConfigPanel,
icon: "Edit", icon: "Edit",
tags: [], tags: [],

View File

@ -11,37 +11,37 @@ import { API_BASE_URL } from "@/lib/api/client";
// Office 문서 렌더링을 위한 CDN 라이브러리 로드 // Office 문서 렌더링을 위한 CDN 라이브러리 로드
const loadOfficeLibrariesFromCDN = async () => { const loadOfficeLibrariesFromCDN = async () => {
if (typeof window === 'undefined') return { XLSX: null, mammoth: null }; if (typeof window === "undefined") return { XLSX: null, mammoth: null };
try { try {
// XLSX 라이브러리가 이미 로드되어 있는지 확인 // XLSX 라이브러리가 이미 로드되어 있는지 확인
if (!(window as any).XLSX) { if (!(window as any).XLSX) {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const script = document.createElement('script'); const script = document.createElement("script");
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js'; script.src = "https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js";
script.onload = resolve; script.onload = resolve;
script.onerror = reject; script.onerror = reject;
document.head.appendChild(script); document.head.appendChild(script);
}); });
} }
// mammoth 라이브러리가 이미 로드되어 있는지 확인 // mammoth 라이브러리가 이미 로드되어 있는지 확인
if (!(window as any).mammoth) { if (!(window as any).mammoth) {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const script = document.createElement('script'); const script = document.createElement("script");
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.4.2/mammoth.browser.min.js'; script.src = "https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.4.2/mammoth.browser.min.js";
script.onload = resolve; script.onload = resolve;
script.onerror = reject; script.onerror = reject;
document.head.appendChild(script); document.head.appendChild(script);
}); });
} }
return { return {
XLSX: (window as any).XLSX, XLSX: (window as any).XLSX,
mammoth: (window as any).mammoth mammoth: (window as any).mammoth,
}; };
} catch (error) { } catch (error) {
console.error('Office 라이브러리 CDN 로드 실패:', error); console.error("Office 라이브러리 CDN 로드 실패:", error);
return { XLSX: null, mammoth: null }; return { XLSX: null, mammoth: null };
} }
}; };
@ -57,13 +57,7 @@ interface FileViewerModalProps {
/** /**
* *
*/ */
export const FileViewerModal: React.FC<FileViewerModalProps> = ({ export const FileViewerModal: React.FC<FileViewerModalProps> = ({ file, isOpen, onClose, onDownload, onDelete }) => {
file,
isOpen,
onClose,
onDownload,
onDelete,
}) => {
const [previewUrl, setPreviewUrl] = useState<string | null>(null); const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null); const [previewError, setPreviewError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -73,37 +67,37 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
const renderOfficeDocument = async (blob: Blob, fileExt: string, fileName: string) => { const renderOfficeDocument = async (blob: Blob, fileExt: string, fileName: string) => {
try { try {
setIsLoading(true); setIsLoading(true);
// CDN에서 라이브러리 로드 // CDN에서 라이브러리 로드
const { XLSX, mammoth } = await loadOfficeLibrariesFromCDN(); const { XLSX, mammoth } = await loadOfficeLibrariesFromCDN();
if (fileExt === "docx" && mammoth) { if (fileExt === "docx" && mammoth) {
// Word 문서 렌더링 // Word 문서 렌더링
const arrayBuffer = await blob.arrayBuffer(); const arrayBuffer = await blob.arrayBuffer();
const result = await mammoth.convertToHtml({ arrayBuffer }); const result = await mammoth.convertToHtml({ arrayBuffer });
const htmlContent = ` const htmlContent = `
<div> <div>
<h4 style="margin: 0 0 15px 0; color: #333; font-size: 16px;">📄 ${fileName}</h4> <h4 style="margin: 0 0 15px 0; color: #333; font-size: 16px;">📄 ${fileName}</h4>
<div class="word-content" style="max-height: 500px; overflow-y: auto; padding: 20px; background: white; border: 1px solid #ddd; border-radius: 5px; line-height: 1.6; font-family: 'Times New Roman', serif;"> <div class="word-content" style="max-height: 500px; overflow-y: auto; padding: 20px; background: white; border: 1px solid #ddd; border-radius: 5px; line-height: 1.6; font-family: 'Times New Roman', serif;">
${result.value || '내용을 읽을 수 없습니다.'} ${result.value || "내용을 읽을 수 없습니다."}
</div> </div>
</div> </div>
`; `;
setRenderedContent(htmlContent); setRenderedContent(htmlContent);
return true; return true;
} else if (["xlsx", "xls"].includes(fileExt) && XLSX) { } else if (["xlsx", "xls"].includes(fileExt) && XLSX) {
// Excel 문서 렌더링 // Excel 문서 렌더링
const arrayBuffer = await blob.arrayBuffer(); const arrayBuffer = await blob.arrayBuffer();
const workbook = XLSX.read(arrayBuffer, { type: 'array' }); const workbook = XLSX.read(arrayBuffer, { type: "array" });
const sheetName = workbook.SheetNames[0]; const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName]; const worksheet = workbook.Sheets[sheetName];
const html = XLSX.utils.sheet_to_html(worksheet, { const html = XLSX.utils.sheet_to_html(worksheet, {
table: { className: 'excel-table' } table: { className: "excel-table" },
}); });
const htmlContent = ` const htmlContent = `
<div> <div>
<h4 style="margin: 0 0 10px 0; color: #333; font-size: 16px;">📊 ${fileName}</h4> <h4 style="margin: 0 0 10px 0; color: #333; font-size: 16px;">📊 ${fileName}</h4>
@ -118,7 +112,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
</div> </div>
</div> </div>
`; `;
setRenderedContent(htmlContent); setRenderedContent(htmlContent);
return true; return true;
} else if (fileExt === "doc") { } else if (fileExt === "doc") {
@ -130,7 +124,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
<p style="color: #666; font-size: 14px;">(.docx )</p> <p style="color: #666; font-size: 14px;">(.docx )</p>
</div> </div>
`; `;
setRenderedContent(htmlContent); setRenderedContent(htmlContent);
return true; return true;
} else if (["ppt", "pptx"].includes(fileExt)) { } else if (["ppt", "pptx"].includes(fileExt)) {
@ -142,22 +136,22 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
<p style="color: #666; font-size: 14px;"> .</p> <p style="color: #666; font-size: 14px;"> .</p>
</div> </div>
`; `;
setRenderedContent(htmlContent); setRenderedContent(htmlContent);
return true; return true;
} }
return false; // 지원하지 않는 형식 return false; // 지원하지 않는 형식
} catch (error) { } catch (error) {
console.error("Office 문서 렌더링 오류:", error); console.error("Office 문서 렌더링 오류:", error);
const htmlContent = ` const htmlContent = `
<div style="color: red; text-align: center; padding: 20px;"> <div style="color: red; text-align: center; padding: 20px;">
Office .<br> Office .<br>
. .
</div> </div>
`; `;
setRenderedContent(htmlContent); setRenderedContent(htmlContent);
return true; // 오류 메시지라도 표시 return true; // 오류 메시지라도 표시
} finally { } finally {
@ -182,7 +176,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
const url = URL.createObjectURL(file._file); const url = URL.createObjectURL(file._file);
setPreviewUrl(url); setPreviewUrl(url);
setIsLoading(false); setIsLoading(false);
return () => URL.revokeObjectURL(url); return () => URL.revokeObjectURL(url);
} }
@ -192,20 +186,35 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
const generatePreviewUrl = async () => { const generatePreviewUrl = async () => {
try { try {
const fileExt = file.fileExt.toLowerCase(); const fileExt = file.fileExt.toLowerCase();
// 미리보기 지원 파일 타입 정의 // 미리보기 지원 파일 타입 정의
const imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "svg"]; const imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "svg"];
const documentExtensions = ["pdf","doc", "docx", "xls", "xlsx", "ppt", "pptx", "rtf", "odt", "ods", "odp", "hwp", "hwpx", "hwpml", "hcdt", "hpt", "pages", "numbers", "keynote"]; const documentExtensions = [
"pdf",
"doc",
"docx",
"xls",
"xlsx",
"ppt",
"pptx",
"rtf",
"odt",
"ods",
"odp",
"hwp",
"hwpx",
"hwpml",
"hcdt",
"hpt",
"pages",
"numbers",
"keynote",
];
const textExtensions = ["txt", "md", "json", "xml", "csv"]; const textExtensions = ["txt", "md", "json", "xml", "csv"];
const mediaExtensions = ["mp4", "webm", "ogg", "mp3", "wav"]; const mediaExtensions = ["mp4", "webm", "ogg", "mp3", "wav"];
const supportedExtensions = [ const supportedExtensions = [...imageExtensions, ...documentExtensions, ...textExtensions, ...mediaExtensions];
...imageExtensions,
...documentExtensions,
...textExtensions,
...mediaExtensions
];
if (supportedExtensions.includes(fileExt)) { if (supportedExtensions.includes(fileExt)) {
// 이미지나 PDF는 인증된 요청으로 Blob 생성 // 이미지나 PDF는 인증된 요청으로 Blob 생성
if (imageExtensions.includes(fileExt) || fileExt === "pdf") { if (imageExtensions.includes(fileExt) || fileExt === "pdf") {
@ -213,15 +222,15 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
// 인증된 요청으로 파일 데이터 가져오기 // 인증된 요청으로 파일 데이터 가져오기
const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, { const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, {
headers: { headers: {
"Authorization": `Bearer ${localStorage.getItem("authToken")}`, Authorization: `Bearer ${localStorage.getItem("authToken")}`,
}, },
}); });
if (response.ok) { if (response.ok) {
const blob = await response.blob(); const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
setPreviewUrl(blobUrl); setPreviewUrl(blobUrl);
// 컴포넌트 언마운트 시 URL 정리를 위해 cleanup 함수 저장 // 컴포넌트 언마운트 시 URL 정리를 위해 cleanup 함수 저장
cleanup = () => URL.revokeObjectURL(blobUrl); cleanup = () => URL.revokeObjectURL(blobUrl);
} else { } else {
@ -236,20 +245,20 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
try { try {
const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, { const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, {
headers: { headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`, Authorization: `Bearer ${localStorage.getItem("authToken")}`,
}, },
}); });
if (response.ok) { if (response.ok) {
const blob = await response.blob(); const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
// Office 문서를 위한 특별한 처리 - CDN 라이브러리 사용 // Office 문서를 위한 특별한 처리 - CDN 라이브러리 사용
if (["doc", "docx", "xls", "xlsx", "ppt", "pptx"].includes(fileExt)) { if (["doc", "docx", "xls", "xlsx", "ppt", "pptx"].includes(fileExt)) {
// CDN 라이브러리로 클라이언트 사이드 렌더링 시도 // CDN 라이브러리로 클라이언트 사이드 렌더링 시도
try { try {
const renderSuccess = await renderOfficeDocument(blob, fileExt, file.realFileName); const renderSuccess = await renderOfficeDocument(blob, fileExt, file.realFileName);
if (!renderSuccess) { if (!renderSuccess) {
// 렌더링 실패 시 Blob URL 사용 // 렌더링 실패 시 Blob URL 사용
setPreviewUrl(blobUrl); setPreviewUrl(blobUrl);
@ -263,7 +272,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
// 기타 문서는 직접 Blob URL 사용 // 기타 문서는 직접 Blob URL 사용
setPreviewUrl(blobUrl); setPreviewUrl(blobUrl);
} }
return () => URL.revokeObjectURL(blobUrl); // Cleanup function return () => URL.revokeObjectURL(blobUrl); // Cleanup function
} else { } else {
throw new Error(`HTTP ${response.status}`); throw new Error(`HTTP ${response.status}`);
@ -291,7 +300,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
}; };
generatePreviewUrl(); generatePreviewUrl();
// cleanup 함수 반환 // cleanup 함수 반환
return () => { return () => {
if (cleanup) { if (cleanup) {
@ -306,24 +315,20 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
const renderPreview = () => { const renderPreview = () => {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center h-96"> <div className="flex h-96 items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div> <div className="h-12 w-12 animate-spin rounded-full border-b-2 border-blue-600"></div>
</div> </div>
); );
} }
if (previewError) { if (previewError) {
return ( return (
<div className="flex flex-col items-center justify-center h-96"> <div className="flex h-96 flex-col items-center justify-center">
<AlertTriangle className="w-16 h-16 mb-4 text-yellow-500" /> <AlertTriangle className="mb-4 h-16 w-16 text-yellow-500" />
<p className="text-lg font-medium mb-2"> </p> <p className="mb-2 text-lg font-medium"> </p>
<p className="text-sm text-center">{previewError}</p> <p className="text-center text-sm">{previewError}</p>
<Button <Button variant="outline" onClick={() => onDownload?.(file)} className="mt-4">
variant="outline" <Download className="mr-2 h-4 w-4" />
onClick={() => onDownload?.(file)}
className="mt-4"
>
<Download className="w-4 h-4 mr-2" />
</Button> </Button>
</div> </div>
@ -335,11 +340,11 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
// 이미지 파일 // 이미지 파일
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(fileExt)) { if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(fileExt)) {
return ( return (
<div className="flex items-center justify-center max-h-96 overflow-hidden"> <div className="flex max-h-96 items-center justify-center overflow-hidden">
<img <img
src={previewUrl || ""} src={previewUrl || ""}
alt={file.realFileName} alt={file.realFileName}
className="max-w-full max-h-full object-contain rounded-lg shadow-lg" className="max-h-full max-w-full rounded-lg object-contain shadow-lg"
onError={(e) => { onError={(e) => {
console.error("이미지 로드 오류:", previewUrl, e); console.error("이미지 로드 오류:", previewUrl, e);
setPreviewError("이미지를 불러올 수 없습니다. 파일이 손상되었거나 서버에서 접근할 수 없습니다."); setPreviewError("이미지를 불러올 수 없습니다. 파일이 손상되었거나 서버에서 접근할 수 없습니다.");
@ -358,100 +363,83 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
<div className="h-96 overflow-auto"> <div className="h-96 overflow-auto">
<iframe <iframe
src={previewUrl || ""} src={previewUrl || ""}
className="w-full h-full border rounded-lg" className="h-full w-full rounded-lg border"
onError={() => setPreviewError("텍스트 파일을 불러올 수 없습니다.")} onError={() => setPreviewError("텍스트 파일을 불러올 수 없습니다.")}
/> />
</div> </div>
); );
} }
// PDF 파일 // PDF 파일 - 브라우저 기본 뷰어 사용
if (fileExt === "pdf") { if (fileExt === "pdf") {
return ( return (
<div className="h-96 overflow-auto"> <div className="h-[600px] overflow-auto rounded-lg border bg-gray-50">
<iframe <object
src={previewUrl || ""} data={previewUrl || ""}
className="w-full h-full border rounded-lg" type="application/pdf"
onError={() => setPreviewError("PDF 파일을 불러올 수 없습니다.")} className="h-full w-full rounded-lg"
/> title="PDF Viewer"
>
<iframe src={previewUrl || ""} className="h-full w-full rounded-lg" title="PDF Viewer Fallback">
<div className="flex h-full flex-col items-center justify-center p-8">
<FileText className="mb-4 h-16 w-16 text-gray-400" />
<p className="mb-2 text-lg font-medium">PDF를 </p>
<p className="mb-4 text-center text-sm text-gray-600">
PDF . .
</p>
<Button variant="outline" onClick={() => onDownload?.(file)}>
<Download className="mr-2 h-4 w-4" />
PDF
</Button>
</div>
</iframe>
</object>
</div> </div>
); );
} }
// Office 문서 (CDN 라이브러리 렌더링 또는 iframe) // Office 문서 - 모든 Office 문서는 다운로드 권장
if ( if (
["doc", "docx", "xls", "xlsx", "ppt", "pptx", "hwp", "hwpx", "hwpml", "hcdt", "hpt", "pages", "numbers", "keynote"].includes(fileExt) [
"doc",
"docx",
"xls",
"xlsx",
"ppt",
"pptx",
"hwp",
"hwpx",
"hwpml",
"hcdt",
"hpt",
"pages",
"numbers",
"keynote",
].includes(fileExt)
) { ) {
// CDN 라이브러리로 렌더링된 콘텐츠가 있는 경우 // Office 문서 안내 메시지 표시
if (renderedContent) {
return (
<div className="relative h-96 overflow-auto">
<div
className="w-full h-full p-4 border rounded-lg bg-white"
dangerouslySetInnerHTML={{ __html: renderedContent }}
/>
</div>
);
}
// iframe 방식 (fallback)
return ( return (
<div className="relative h-96 overflow-auto"> <div className="relative flex h-96 flex-col items-center justify-center overflow-auto rounded-lg border bg-gradient-to-br from-blue-50 to-indigo-50 p-8">
<iframe <FileText className="mb-6 h-20 w-20 text-blue-500" />
src={previewUrl || ""} <h3 className="mb-2 text-xl font-semibold text-gray-800">Office </h3>
className="w-full h-full border rounded-lg" <p className="mb-6 max-w-md text-center text-sm text-gray-600">
onError={() => { {fileExt === "docx" || fileExt === "doc"
console.log("iframe 오류 발생, fallback 옵션 제공"); ? "Word 문서"
setPreviewError("이 Office 문서는 브라우저에서 직접 미리보기할 수 없습니다. 다운로드하여 확인해주세요."); : fileExt === "xlsx" || fileExt === "xls"
}} ? "Excel 문서"
title={`${file.realFileName} 미리보기`} : fileExt === "pptx" || fileExt === "ppt"
sandbox="allow-same-origin allow-scripts allow-popups allow-forms" ? "PowerPoint 문서"
onLoad={() => setIsLoading(false)} : "Office 문서"}
/> .
<br />
{/* 로딩 상태 */} .
{isLoading && ( </p>
<div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-90"> <div className="flex gap-3">
<div className="text-center"> <Button onClick={() => onDownload?.(file)} size="lg" className="shadow-md">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div> <Download className="mr-2 h-5 w-5" />
<p className="text-sm text-gray-600">Office ...</p>
<p className="text-xs text-gray-400 mt-1"> </p> </Button>
</div> </div>
</div>
)}
{/* 오류 발생 시 fallback 옵션 */}
{previewError && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-white">
<FileText className="w-16 h-16 mb-4 text-orange-500" />
<p className="text-lg font-medium mb-2"> </p>
<p className="text-sm text-center mb-4 text-gray-600">
{previewError}
</p>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => onDownload?.(file)}
>
<Download className="w-4 h-4 mr-2" />
</Button>
<Button
variant="ghost"
onClick={() => {
// 새 탭에서 파일 열기 시도
const link = document.createElement('a');
link.href = previewUrl || '';
link.target = '_blank';
link.click();
}}
>
<ExternalLink className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
)}
</div> </div>
); );
} }
@ -460,11 +448,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
if (["mp4", "webm", "ogg"].includes(fileExt)) { if (["mp4", "webm", "ogg"].includes(fileExt)) {
return ( return (
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<video <video controls className="max-h-96 w-full" onError={() => setPreviewError("비디오를 재생할 수 없습니다.")}>
controls
className="w-full max-h-96"
onError={() => setPreviewError("비디오를 재생할 수 없습니다.")}
>
<source src={previewUrl || ""} type={`video/${fileExt}`} /> <source src={previewUrl || ""} type={`video/${fileExt}`} />
</video> </video>
</div> </div>
@ -474,9 +458,9 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
// 오디오 파일 // 오디오 파일
if (["mp3", "wav", "ogg"].includes(fileExt)) { if (["mp3", "wav", "ogg"].includes(fileExt)) {
return ( return (
<div className="flex flex-col items-center justify-center h-96"> <div className="flex h-96 flex-col items-center justify-center">
<div className="w-32 h-32 bg-gray-100 rounded-full flex items-center justify-center mb-6"> <div className="mb-6 flex h-32 w-32 items-center justify-center rounded-full bg-gray-100">
<svg className="w-16 h-16 text-gray-400" fill="currentColor" viewBox="0 0 20 20"> <svg className="h-16 w-16 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path <path
fillRule="evenodd" fillRule="evenodd"
d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM15.657 6.343a1 1 0 011.414 0A9.972 9.972 0 0119 12a9.972 9.972 0 01-1.929 5.657 1 1 0 11-1.414-1.414A7.971 7.971 0 0017 12c0-1.594-.471-3.078-1.343-4.343a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 12a5.984 5.984 0 01-.757 2.829 1 1 0 01-1.415-1.414A3.987 3.987 0 0013 12a3.988 3.988 0 00-.172-1.171 1 1 0 010-1.414z" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM15.657 6.343a1 1 0 011.414 0A9.972 9.972 0 0119 12a9.972 9.972 0 01-1.929 5.657 1 1 0 11-1.414-1.414A7.971 7.971 0 0017 12c0-1.594-.471-3.078-1.343-4.343a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 12a5.984 5.984 0 01-.757 2.829 1 1 0 01-1.415-1.414A3.987 3.987 0 0013 12a3.988 3.988 0 00-.172-1.171 1 1 0 010-1.414z"
@ -484,11 +468,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
/> />
</svg> </svg>
</div> </div>
<audio <audio controls className="w-full max-w-md" onError={() => setPreviewError("오디오를 재생할 수 없습니다.")}>
controls
className="w-full max-w-md"
onError={() => setPreviewError("오디오를 재생할 수 없습니다.")}
>
<source src={previewUrl || ""} type={`audio/${fileExt}`} /> <source src={previewUrl || ""} type={`audio/${fileExt}`} />
</audio> </audio>
</div> </div>
@ -497,17 +477,12 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
// 기타 파일 타입 // 기타 파일 타입
return ( return (
<div className="flex flex-col items-center justify-center h-96"> <div className="flex h-96 flex-col items-center justify-center">
<FileText className="w-16 h-16 mb-4 text-gray-400" /> <FileText className="mb-4 h-16 w-16 text-gray-400" />
<p className="text-lg font-medium mb-2"> </p> <p className="mb-2 text-lg font-medium"> </p>
<p className="text-sm text-center mb-4"> <p className="mb-4 text-center text-sm">{file.fileExt.toUpperCase()} .</p>
{file.fileExt.toUpperCase()} . <Button variant="outline" onClick={() => onDownload?.(file)}>
</p> <Download className="mr-2 h-4 w-4" />
<Button
variant="outline"
onClick={() => onDownload?.(file)}
>
<Download className="w-4 h-4 mr-2" />
</Button> </Button>
</div> </div>
@ -516,65 +491,53 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
return ( return (
<Dialog open={isOpen} onOpenChange={() => {}}> <Dialog open={isOpen} onOpenChange={() => {}}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto [&>button]:hidden"> <DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto [&>button]:hidden">
<DialogHeader> <DialogHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<DialogTitle className="text-lg font-semibold truncate"> <DialogTitle className="truncate text-lg font-semibold">{file.realFileName}</DialogTitle>
{file.realFileName}
</DialogTitle>
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{file.fileExt.toUpperCase()} {file.fileExt.toUpperCase()}
</Badge> </Badge>
</div> </div>
</div> </div>
<DialogDescription> <DialogDescription>
: {formatFileSize(file.size)} | : {file.fileExt.toUpperCase()} : {formatFileSize(file.fileSize || file.size || 0)} | : {file.fileExt.toUpperCase()}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">{renderPreview()}</div>
{renderPreview()}
</div>
{/* 파일 정보 및 액션 버튼 */} {/* 파일 정보 및 액션 버튼 */}
<div className="flex items-center space-x-4 text-sm text-gray-500 mt-2"> <div className="mt-2 flex items-center space-x-4 text-sm text-gray-500">
<span>: {formatFileSize(file.size)}</span> <span>: {formatFileSize(file.fileSize || file.size || 0)}</span>
{file.uploadedAt && ( {(file.uploadedAt || file.regdate) && (
<span>: {new Date(file.uploadedAt).toLocaleString()}</span> <span>: {new Date(file.uploadedAt || file.regdate || "").toLocaleString()}</span>
)} )}
</div> </div>
<div className="flex justify-end space-x-2 pt-4 border-t"> <div className="flex justify-end space-x-2 border-t pt-4">
<Button <Button variant="outline" size="sm" onClick={() => onDownload?.(file)}>
variant="outline" <Download className="mr-2 h-4 w-4" />
size="sm"
onClick={() => onDownload?.(file)}
>
<Download className="w-4 h-4 mr-2" />
</Button> </Button>
{onDelete && ( {onDelete && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="text-red-600 hover:text-red-700 hover:bg-red-50" className="text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={() => onDelete(file)} onClick={() => onDelete(file)}
> >
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="mr-2 h-4 w-4" />
</Button> </Button>
)} )}
<Button <Button variant="outline" size="sm" onClick={onClose}>
variant="outline" <X className="mr-2 h-4 w-4" />
size="sm"
onClick={onClose}
>
<X className="w-4 h-4 mr-2" />
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
}; };

View File

@ -23,7 +23,7 @@ export const FileUploadDefinition = createComponentDefinition({
defaultConfig: { defaultConfig: {
placeholder: "입력하세요", placeholder: "입력하세요",
}, },
defaultSize: { width: 350, height: 40 }, defaultSize: { width: 350, height: 240 }, // 40 * 6 (파일 선택 + 목록 표시)
configPanel: FileUploadConfigPanel, configPanel: FileUploadConfigPanel,
icon: "Edit", icon: "Edit",
tags: [], tags: [],

View File

@ -84,13 +84,7 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
// radio-horizontal: 가로 배치 // radio-horizontal: 가로 배치
if (webType === "radio-horizontal") { if (webType === "radio-horizontal") {
return ( return (
<div <div className="flex flex-row gap-4">
className={cn(
"flex flex-row gap-4 rounded-lg border border-gray-200 bg-gray-50 p-3",
"transition-all hover:border-orange-400 hover:shadow-md",
isSelected && "ring-2 ring-orange-500",
)}
>
{options.map((option: any, index: number) => ( {options.map((option: any, index: number) => (
<label key={index} className="flex cursor-pointer items-center gap-2"> <label key={index} className="flex cursor-pointer items-center gap-2">
<input <input
@ -100,7 +94,7 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
checked={selectedValue === option.value} checked={selectedValue === option.value}
onChange={() => handleRadioChange(option.value)} onChange={() => handleRadioChange(option.value)}
disabled={componentConfig.disabled || isDesignMode} disabled={componentConfig.disabled || isDesignMode}
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500" className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-0 focus:outline-none"
/> />
<span className="text-sm text-gray-900">{option.label}</span> <span className="text-sm text-gray-900">{option.label}</span>
</label> </label>
@ -112,13 +106,7 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
// radio-vertical: 세로 배치 // radio-vertical: 세로 배치
if (webType === "radio-vertical") { if (webType === "radio-vertical") {
return ( return (
<div <div className="flex flex-col gap-2">
className={cn(
"flex flex-col gap-2 rounded-lg border border-gray-200 bg-gray-50 p-3",
"transition-all hover:border-orange-400 hover:shadow-md",
isSelected && "ring-2 ring-orange-500",
)}
>
{options.map((option: any, index: number) => ( {options.map((option: any, index: number) => (
<label key={index} className="flex cursor-pointer items-center gap-2"> <label key={index} className="flex cursor-pointer items-center gap-2">
<input <input
@ -128,7 +116,7 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
checked={selectedValue === option.value} checked={selectedValue === option.value}
onChange={() => handleRadioChange(option.value)} onChange={() => handleRadioChange(option.value)}
disabled={componentConfig.disabled || isDesignMode} disabled={componentConfig.disabled || isDesignMode}
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500" className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-0 focus:outline-none"
/> />
<span className="text-sm text-gray-900">{option.label}</span> <span className="text-sm text-gray-900">{option.label}</span>
</label> </label>
@ -139,14 +127,7 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
// radio (기본 라디오 - direction 설정 따름) // radio (기본 라디오 - direction 설정 따름)
return ( return (
<div <div className={cn("flex gap-3", componentConfig.direction === "horizontal" ? "flex-row" : "flex-col")}>
className={cn(
"flex gap-3 rounded-lg border border-gray-200 bg-gray-50 p-3",
"transition-all hover:border-orange-400 hover:shadow-md",
componentConfig.direction === "horizontal" ? "flex-row" : "flex-col",
isSelected && "ring-2 ring-orange-500",
)}
>
{options.map((option: any, index: number) => ( {options.map((option: any, index: number) => (
<label key={index} className="flex cursor-pointer items-center gap-2"> <label key={index} className="flex cursor-pointer items-center gap-2">
<input <input
@ -157,7 +138,7 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
onChange={() => handleRadioChange(option.value)} onChange={() => handleRadioChange(option.value)}
disabled={componentConfig.disabled || isDesignMode} disabled={componentConfig.disabled || isDesignMode}
required={componentConfig.required || false} required={componentConfig.required || false}
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500" className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-0"
/> />
<span className="text-sm text-gray-900">{option.label}</span> <span className="text-sm text-gray-900">{option.label}</span>
</label> </label>

View File

@ -23,7 +23,7 @@ export const RadioBasicDefinition = createComponentDefinition({
defaultConfig: { defaultConfig: {
placeholder: "입력하세요", placeholder: "입력하세요",
}, },
defaultSize: { width: 150, height: 32 }, defaultSize: { width: 150, height: 80 }, // 40 * 2 (2개 옵션)
configPanel: RadioBasicConfigPanel, configPanel: RadioBasicConfigPanel,
icon: "Edit", icon: "Edit",
tags: [], tags: [],