반응형 미리보기 기능
This commit is contained in:
parent
92e7cef2bc
commit
54e9f45823
|
|
@ -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}
|
||||
/>
|
||||
) : (
|
||||
// 빈 화면일 때
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
Loading…
Reference in New Issue