반응형 미리보기 기능

This commit is contained in:
kjs 2025-10-17 10:12:41 +09:00
parent 92e7cef2bc
commit 54e9f45823
6 changed files with 235 additions and 6 deletions

View File

@ -24,6 +24,9 @@ export default function ScreenViewPage() {
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState<Record<string, any>>({});
// 화면 너비에 따라 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 (
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br from-gray-50 to-slate-100">
@ -152,6 +173,7 @@ export default function ScreenViewPage() {
breakpoint={breakpoint}
containerWidth={window.innerWidth}
screenWidth={screenWidth}
preserveYPosition={preserveYPosition}
/>
) : (
// 빈 화면일 때

View File

@ -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<ResponsiveLayoutEngineProps> = ({
breakpoint,
containerWidth,
screenWidth = 1920,
preserveYPosition = false, // 기본값: 반응형 모드 (16px 간격)
}) => {
// 1단계: 컴포넌트들을 Y 위치 기준으로 행(row)으로 그룹화
const rows = useMemo(() => {
@ -112,6 +114,22 @@ export const ResponsiveLayoutEngine: React.FC<ResponsiveLayoutEngineProps> = ({
{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 (
<div
key={`row-${rowIndex}`}
@ -121,7 +139,7 @@ export const ResponsiveLayoutEngine: React.FC<ResponsiveLayoutEngineProps> = ({
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
gap: "16px",
padding: "0 16px",
marginTop: rowIndex === 0 ? `${row.yPosition}px` : "16px",
marginTop,
}}
>
{rowComponents.map((comp) => (

View File

@ -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<Breakpoint | null>(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: <Monitor className="h-4 w-4" />,
breakpoint: "desktop",
},
{
name: "태블릿",
width: 768,
height: 1024,
icon: <Tablet className="h-4 w-4" />,
breakpoint: "tablet",
},
{
name: "모바일",
width: 375,
height: 667,
icon: <Smartphone className="h-4 w-4" />,
breakpoint: "mobile",
},
];
export const ResponsivePreviewModal: React.FC<ResponsivePreviewModalProps> = ({
isOpen,
onClose,
components,
screenWidth,
}) => {
const [selectedDevice, setSelectedDevice] = useState<DevicePreset>(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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[95vh] max-w-[95vw] p-0">
<DialogHeader className="border-b px-6 pt-6 pb-4">
<div className="flex items-center justify-between">
<DialogTitle> </DialogTitle>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
{/* 디바이스 선택 버튼들 */}
<div className="mt-4 flex gap-2">
{DEVICE_PRESETS.map((device) => (
<Button
key={device.name}
variant={selectedDevice.name === device.name ? "default" : "outline"}
size="sm"
onClick={() => setSelectedDevice(device)}
className="gap-2"
>
{device.icon}
<span>{device.name}</span>
<span className="text-xs opacity-70">
{device.width}×{device.height}
</span>
</Button>
))}
</div>
</DialogHeader>
{/* 미리보기 영역 - Context Provider로 감싸서 브레이크포인트 전달 */}
<PreviewBreakpointContext.Provider value={selectedDevice.breakpoint}>
<div className="flex min-h-[600px] items-start justify-center overflow-auto bg-gray-50 p-6">
<div
className="relative border border-gray-300 bg-white shadow-2xl"
style={{
width: `${selectedDevice.width}px`,
height: `${selectedDevice.height}px`,
transform: `scale(${scale})`,
transformOrigin: "top center",
overflow: "auto",
}}
>
{/* 디바이스 프레임 헤더 (선택사항) */}
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-gray-300 bg-gray-100 px-4 py-2">
<div className="text-xs text-gray-600">
{selectedDevice.name} - {selectedDevice.width}×{selectedDevice.height}
</div>
<div className="text-xs text-gray-500">: {Math.round(scale * 100)}%</div>
</div>
{/* 실제 컴포넌트 렌더링 */}
<div className="p-4">
<ResponsiveLayoutEngine
components={components}
breakpoint={selectedDevice.breakpoint}
containerWidth={selectedDevice.width}
screenWidth={screenWidth}
preserveYPosition={selectedDevice.breakpoint === "desktop"}
/>
</div>
</div>
</div>
</PreviewBreakpointContext.Provider>
{/* 푸터 정보 */}
<div className="border-t bg-gray-50 px-6 py-3 text-xs text-gray-600">
💡 Tip: .
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -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<ComponentData | null>(null);
// 반응형 미리보기 모달 상태
const [showResponsivePreview, setShowResponsivePreview] = useState(false);
// 해상도 설정 상태
const [screenResolution, setScreenResolution] = useState<ScreenResolution>(
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)}
/>
{/* 메인 컨테이너 (좌측 툴바 + 패널들 + 캔버스) */}
<div className="flex flex-1 overflow-hidden">
@ -4252,6 +4266,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
screenId={selectedScreen.screenId}
/>
)}
{/* 반응형 미리보기 모달 */}
<ResponsivePreviewModal
isOpen={showResponsivePreview}
onClose={() => setShowResponsivePreview(false)}
components={layout.components}
screenWidth={screenResolution.width}
/>
</div>
);
}

View File

@ -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<SlimToolbarProps> = ({
@ -21,6 +22,7 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
onBack,
onSave,
isSaving = false,
onPreview,
}) => {
return (
<div className="flex h-14 items-center justify-between border-b border-gray-200 bg-gradient-to-r from-gray-50 to-white px-4 shadow-sm">
@ -60,8 +62,14 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
)}
</div>
{/* 우측: 저장 버튼 */}
<div className="flex items-center">
{/* 우측: 버튼들 */}
<div className="flex items-center space-x-2">
{onPreview && (
<Button variant="outline" onClick={onPreview} className="flex items-center space-x-2">
<Smartphone className="h-4 w-4" />
<span> </span>
</Button>
)}
<Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2">
<Save className="h-4 w-4" />
<span>{isSaving ? "저장 중..." : "저장"}</span>

View File

@ -10,6 +10,8 @@ import { Separator } from "@/components/ui/separator";
import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react";
import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition } from "@/types/repeater";
import { cn } from "@/lib/utils";
import { useBreakpoint } from "@/hooks/useBreakpoint";
import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal";
export interface RepeaterInputProps {
value?: RepeaterData;
@ -32,6 +34,13 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
readonly = false,
className,
}) => {
// 현재 브레이크포인트 감지
const globalBreakpoint = useBreakpoint();
const previewBreakpoint = usePreviewBreakpoint();
// 미리보기 모달 내에서는 previewBreakpoint 우선 사용
const breakpoint = previewBreakpoint || globalBreakpoint;
// 설정 기본값
const {
fields = [],
@ -46,6 +55,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
emptyMessage = "항목이 없습니다. '항목 추가' 버튼을 클릭하세요.",
} = config;
// 반응형: 작은 화면(모바일/태블릿)에서는 카드 레이아웃 강제
const effectiveLayout = breakpoint === "mobile" || breakpoint === "tablet" ? "card" : layout;
// 로컬 상태 관리
const [items, setItems] = useState<RepeaterData>(
value.length > 0
@ -451,8 +463,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
return (
<div className={cn("space-y-4", className)}>
{/* 레이아웃에 따라 렌더링 방식 선택 */}
{layout === "grid" ? renderGridLayout() : renderCardLayout()}
{/* 레이아웃에 따라 렌더링 방식 선택 (반응형 고려) */}
{effectiveLayout === "grid" ? renderGridLayout() : renderCardLayout()}
{/* 추가 버튼 */}
{!readonly && !disabled && items.length < maxItems && (