From 54e9f45823f56e24fec5dc7fe356ef18e4107ce4 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 17 Oct 2025 10:12:41 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B0=98=EC=9D=91=ED=98=95=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(main)/screens/[screenId]/page.tsx | 22 +++ .../screen/ResponsiveLayoutEngine.tsx | 20 ++- .../screen/ResponsivePreviewModal.tsx | 148 ++++++++++++++++++ frontend/components/screen/ScreenDesigner.tsx | 21 +++ .../components/screen/toolbar/SlimToolbar.tsx | 14 +- .../components/webtypes/RepeaterInput.tsx | 16 +- 6 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 frontend/components/screen/ResponsivePreviewModal.tsx diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 20484a2c..c09ab497 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -24,6 +24,9 @@ export default function ScreenViewPage() { const [error, setError] = useState(null); const [formData, setFormData] = useState>({}); + // 화면 너비에 따라 Y좌표 유지 여부 결정 + const [preserveYPosition, setPreserveYPosition] = useState(true); + const breakpoint = useBreakpoint(); // 편집 모달 상태 @@ -111,6 +114,24 @@ export default function ScreenViewPage() { } }, [screenId]); + // 윈도우 크기 변경 감지 - layout이 로드된 후에만 실행 + useEffect(() => { + if (!layout) return; + + const screenWidth = layout?.screenResolution?.width || 1200; + + const handleResize = () => { + const shouldPreserve = window.innerWidth >= screenWidth - 100; + setPreserveYPosition(shouldPreserve); + }; + + window.addEventListener("resize", handleResize); + // 초기 값도 설정 + handleResize(); + + return () => window.removeEventListener("resize", handleResize); + }, [layout]); + if (loading) { return (
@@ -152,6 +173,7 @@ export default function ScreenViewPage() { breakpoint={breakpoint} containerWidth={window.innerWidth} screenWidth={screenWidth} + preserveYPosition={preserveYPosition} /> ) : ( // 빈 화면일 때 diff --git a/frontend/components/screen/ResponsiveLayoutEngine.tsx b/frontend/components/screen/ResponsiveLayoutEngine.tsx index 6a92790b..6cddf2b6 100644 --- a/frontend/components/screen/ResponsiveLayoutEngine.tsx +++ b/frontend/components/screen/ResponsiveLayoutEngine.tsx @@ -15,6 +15,7 @@ interface ResponsiveLayoutEngineProps { breakpoint: Breakpoint; containerWidth: number; screenWidth?: number; + preserveYPosition?: boolean; // true: Y좌표 유지 (하이브리드), false: 16px 간격 (반응형) } /** @@ -31,6 +32,7 @@ export const ResponsiveLayoutEngine: React.FC = ({ breakpoint, containerWidth, screenWidth = 1920, + preserveYPosition = false, // 기본값: 반응형 모드 (16px 간격) }) => { // 1단계: 컴포넌트들을 Y 위치 기준으로 행(row)으로 그룹화 const rows = useMemo(() => { @@ -112,6 +114,22 @@ export const ResponsiveLayoutEngine: React.FC = ({ {rowsWithYPosition.map((row, rowIndex) => { const rowComponents = visibleComponents.filter((vc) => row.components.some((rc) => rc.id === vc.id)); + // Y 좌표 계산: preserveYPosition에 따라 다르게 처리 + let marginTop: string; + if (preserveYPosition) { + // 하이브리드 모드: 원래 Y 좌표 간격 유지 + if (rowIndex === 0) { + marginTop = `${row.yPosition}px`; + } else { + const prevRowY = rowsWithYPosition[rowIndex - 1].yPosition; + const actualGap = row.yPosition - prevRowY; + marginTop = `${actualGap}px`; + } + } else { + // 반응형 모드: 16px 고정 간격 + marginTop = rowIndex === 0 ? `${row.yPosition}px` : "16px"; + } + return (
= ({ gridTemplateColumns: `repeat(${gridColumns}, 1fr)`, gap: "16px", padding: "0 16px", - marginTop: rowIndex === 0 ? `${row.yPosition}px` : "16px", + marginTop, }} > {rowComponents.map((comp) => ( diff --git a/frontend/components/screen/ResponsivePreviewModal.tsx b/frontend/components/screen/ResponsivePreviewModal.tsx new file mode 100644 index 00000000..d3f80c3c --- /dev/null +++ b/frontend/components/screen/ResponsivePreviewModal.tsx @@ -0,0 +1,148 @@ +"use client"; + +import React, { useState, createContext, useContext } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Monitor, Tablet, Smartphone, X } from "lucide-react"; +import { ComponentData } from "@/types/screen"; +import { ResponsiveLayoutEngine } from "./ResponsiveLayoutEngine"; +import { Breakpoint } from "@/types/responsive"; + +// 미리보기 모달용 브레이크포인트 Context +const PreviewBreakpointContext = createContext(null); + +// 미리보기 모달 내에서 브레이크포인트를 가져오는 훅 +export const usePreviewBreakpoint = (): Breakpoint | null => { + return useContext(PreviewBreakpointContext); +}; + +interface ResponsivePreviewModalProps { + isOpen: boolean; + onClose: () => void; + components: ComponentData[]; + screenWidth: number; +} + +type DevicePreset = { + name: string; + width: number; + height: number; + icon: React.ReactNode; + breakpoint: Breakpoint; +}; + +const DEVICE_PRESETS: DevicePreset[] = [ + { + name: "데스크톱", + width: 1920, + height: 1080, + icon: , + breakpoint: "desktop", + }, + { + name: "태블릿", + width: 768, + height: 1024, + icon: , + breakpoint: "tablet", + }, + { + name: "모바일", + width: 375, + height: 667, + icon: , + breakpoint: "mobile", + }, +]; + +export const ResponsivePreviewModal: React.FC = ({ + isOpen, + onClose, + components, + screenWidth, +}) => { + const [selectedDevice, setSelectedDevice] = useState(DEVICE_PRESETS[0]); + const [scale, setScale] = useState(1); + + // 스케일 계산: 모달 내에서 디바이스가 잘 보이도록 + React.useEffect(() => { + // 모달 내부 너비를 1400px로 가정하고 여백 100px 제외 + const maxWidth = 1300; + const calculatedScale = Math.min(1, maxWidth / selectedDevice.width); + setScale(calculatedScale); + }, [selectedDevice]); + + return ( + + + +
+ 반응형 미리보기 + +
+ + {/* 디바이스 선택 버튼들 */} +
+ {DEVICE_PRESETS.map((device) => ( + + ))} +
+
+ + {/* 미리보기 영역 - Context Provider로 감싸서 브레이크포인트 전달 */} + +
+
+ {/* 디바이스 프레임 헤더 (선택사항) */} +
+
+ {selectedDevice.name} - {selectedDevice.width}×{selectedDevice.height} +
+
스케일: {Math.round(scale * 100)}%
+
+ + {/* 실제 컴포넌트 렌더링 */} +
+ +
+
+
+
+ + {/* 푸터 정보 */} +
+ 💡 Tip: 각 디바이스 버튼을 클릭하여 다양한 화면 크기에서 레이아웃을 확인할 수 있습니다. +
+
+
+ ); +}; diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index fbeefcb8..829b6cbe 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -57,6 +57,7 @@ import DetailSettingsPanel from "./panels/DetailSettingsPanel"; import GridPanel from "./panels/GridPanel"; import ResolutionPanel from "./panels/ResolutionPanel"; import { usePanelState, PanelConfig } from "@/hooks/usePanelState"; +import { ResponsivePreviewModal } from "./ResponsivePreviewModal"; // 새로운 통합 UI 컴포넌트 import { LeftUnifiedToolbar, defaultToolbarButtons } from "./toolbar/LeftUnifiedToolbar"; @@ -144,6 +145,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const [showFileAttachmentModal, setShowFileAttachmentModal] = useState(false); const [selectedFileComponent, setSelectedFileComponent] = useState(null); + // 반응형 미리보기 모달 상태 + const [showResponsivePreview, setShowResponsivePreview] = useState(false); + // 해상도 설정 상태 const [screenResolution, setScreenResolution] = useState( SCREEN_RESOLUTIONS[0], // 기본값: Full HD @@ -1976,6 +1980,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD "checkbox-basic": 2, // 체크박스 (16.67%) "radio-basic": 3, // 라디오 (25%) "file-basic": 4, // 파일 (33%) + "file-upload": 4, // 파일 업로드 (33%) + "slider-basic": 3, // 슬라이더 (25%) + "toggle-switch": 2, // 토글 스위치 (16.67%) + "repeater-field-group": 6, // 반복 필드 그룹 (50%) // 표시 컴포넌트 (DISPLAY 카테고리) "label-basic": 2, // 라벨 (16.67%) @@ -1984,6 +1992,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD "badge-basic": 1, // 배지 (8.33%) "alert-basic": 6, // 알림 (50%) "divider-basic": 12, // 구분선 (100%) + "divider-line": 12, // 구분선 (100%) + "accordion-basic": 12, // 아코디언 (100%) + "table-list": 12, // 테이블 리스트 (100%) + "image-display": 4, // 이미지 표시 (33%) + "split-panel-layout": 12, // 분할 패널 레이아웃 (100%) // 액션 컴포넌트 (ACTION 카테고리) "button-basic": 1, // 버튼 (8.33%) @@ -3805,6 +3818,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD onBack={onBackToList} onSave={handleSave} isSaving={isSaving} + onPreview={() => setShowResponsivePreview(true)} /> {/* 메인 컨테이너 (좌측 툴바 + 패널들 + 캔버스) */}
@@ -4252,6 +4266,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD screenId={selectedScreen.screenId} /> )} + {/* 반응형 미리보기 모달 */} + setShowResponsivePreview(false)} + components={layout.components} + screenWidth={screenResolution.width} + />
); } diff --git a/frontend/components/screen/toolbar/SlimToolbar.tsx b/frontend/components/screen/toolbar/SlimToolbar.tsx index 9b8cd8a3..2c6c7222 100644 --- a/frontend/components/screen/toolbar/SlimToolbar.tsx +++ b/frontend/components/screen/toolbar/SlimToolbar.tsx @@ -2,7 +2,7 @@ import React from "react"; import { Button } from "@/components/ui/button"; -import { Database, ArrowLeft, Save, Monitor } from "lucide-react"; +import { Database, ArrowLeft, Save, Monitor, Smartphone } from "lucide-react"; import { ScreenResolution } from "@/types/screen"; interface SlimToolbarProps { @@ -12,6 +12,7 @@ interface SlimToolbarProps { onBack: () => void; onSave: () => void; isSaving?: boolean; + onPreview?: () => void; } export const SlimToolbar: React.FC = ({ @@ -21,6 +22,7 @@ export const SlimToolbar: React.FC = ({ onBack, onSave, isSaving = false, + onPreview, }) => { return (
@@ -60,8 +62,14 @@ export const SlimToolbar: React.FC = ({ )}
- {/* 우측: 저장 버튼 */} -
+ {/* 우측: 버튼들 */} +
+ {onPreview && ( + + )}