반응형 레이아웃 기능 구현
This commit is contained in:
parent
bd64762d4a
commit
d7a845ad9f
|
|
@ -0,0 +1,998 @@
|
||||||
|
# 반응형 레이아웃 시스템 구현 계획서
|
||||||
|
|
||||||
|
## 📋 프로젝트 개요
|
||||||
|
|
||||||
|
### 목표
|
||||||
|
|
||||||
|
화면 디자이너는 절대 위치 기반으로 유지하되, 실제 화면 표시는 반응형으로 동작하도록 전환
|
||||||
|
|
||||||
|
### 핵심 원칙
|
||||||
|
|
||||||
|
- ✅ 화면 디자이너의 절대 위치 기반 드래그앤드롭은 그대로 유지
|
||||||
|
- ✅ 실제 화면 표시만 반응형으로 전환
|
||||||
|
- ✅ 데이터 마이그레이션 불필요 (신규 화면부터 적용)
|
||||||
|
- ✅ 기존 화면은 불러올 때 스마트 기본값 자동 생성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Phase 1: 기본 반응형 시스템 구축 (2-3일)
|
||||||
|
|
||||||
|
### 1.1 타입 정의 (2시간)
|
||||||
|
|
||||||
|
#### 파일: `frontend/types/responsive.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* 브레이크포인트 타입 정의
|
||||||
|
*/
|
||||||
|
export type Breakpoint = "desktop" | "tablet" | "mobile";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 브레이크포인트별 설정
|
||||||
|
*/
|
||||||
|
export interface BreakpointConfig {
|
||||||
|
minWidth: number; // 최소 너비 (px)
|
||||||
|
maxWidth?: number; // 최대 너비 (px)
|
||||||
|
columns: number; // 그리드 컬럼 수
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 브레이크포인트 설정
|
||||||
|
*/
|
||||||
|
export const BREAKPOINTS: Record<Breakpoint, BreakpointConfig> = {
|
||||||
|
desktop: {
|
||||||
|
minWidth: 1200,
|
||||||
|
columns: 12,
|
||||||
|
},
|
||||||
|
tablet: {
|
||||||
|
minWidth: 768,
|
||||||
|
maxWidth: 1199,
|
||||||
|
columns: 8,
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
minWidth: 0,
|
||||||
|
maxWidth: 767,
|
||||||
|
columns: 4,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 브레이크포인트별 반응형 설정
|
||||||
|
*/
|
||||||
|
export interface ResponsiveBreakpointConfig {
|
||||||
|
gridColumns?: number; // 차지할 컬럼 수 (1-12)
|
||||||
|
order?: number; // 정렬 순서
|
||||||
|
hide?: boolean; // 숨김 여부
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트별 반응형 설정
|
||||||
|
*/
|
||||||
|
export interface ResponsiveComponentConfig {
|
||||||
|
// 기본값 (디자이너에서 설정한 절대 위치)
|
||||||
|
designerPosition: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 반응형 설정 (선택적)
|
||||||
|
responsive?: {
|
||||||
|
desktop?: ResponsiveBreakpointConfig;
|
||||||
|
tablet?: ResponsiveBreakpointConfig;
|
||||||
|
mobile?: ResponsiveBreakpointConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 스마트 기본값 사용 여부
|
||||||
|
useSmartDefaults?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 스마트 기본값 생성기 (3시간)
|
||||||
|
|
||||||
|
#### 파일: `frontend/lib/utils/responsiveDefaults.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ComponentData } from "@/types/screen-management";
|
||||||
|
import { ResponsiveComponentConfig, BREAKPOINTS } from "@/types/responsive";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트 크기에 따른 스마트 기본값 생성
|
||||||
|
*
|
||||||
|
* 로직:
|
||||||
|
* - 작은 컴포넌트 (너비 25% 이하): 모바일에서도 같은 너비 유지
|
||||||
|
* - 중간 컴포넌트 (너비 25-50%): 모바일에서 전체 너비로 확장
|
||||||
|
* - 큰 컴포넌트 (너비 50% 이상): 모든 디바이스에서 전체 너비
|
||||||
|
*/
|
||||||
|
export function generateSmartDefaults(
|
||||||
|
component: ComponentData,
|
||||||
|
screenWidth: number = 1920
|
||||||
|
): ResponsiveComponentConfig["responsive"] {
|
||||||
|
const componentWidthPercent = (component.size.width / screenWidth) * 100;
|
||||||
|
|
||||||
|
// 작은 컴포넌트 (25% 이하)
|
||||||
|
if (componentWidthPercent <= 25) {
|
||||||
|
return {
|
||||||
|
desktop: {
|
||||||
|
gridColumns: 3, // 12컬럼 중 3개 (25%)
|
||||||
|
order: 1,
|
||||||
|
hide: false,
|
||||||
|
},
|
||||||
|
tablet: {
|
||||||
|
gridColumns: 2, // 8컬럼 중 2개 (25%)
|
||||||
|
order: 1,
|
||||||
|
hide: false,
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
gridColumns: 1, // 4컬럼 중 1개 (25%)
|
||||||
|
order: 1,
|
||||||
|
hide: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 중간 컴포넌트 (25-50%)
|
||||||
|
else if (componentWidthPercent <= 50) {
|
||||||
|
return {
|
||||||
|
desktop: {
|
||||||
|
gridColumns: 6, // 12컬럼 중 6개 (50%)
|
||||||
|
order: 1,
|
||||||
|
hide: false,
|
||||||
|
},
|
||||||
|
tablet: {
|
||||||
|
gridColumns: 4, // 8컬럼 중 4개 (50%)
|
||||||
|
order: 1,
|
||||||
|
hide: false,
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
gridColumns: 4, // 4컬럼 전체 (100%)
|
||||||
|
order: 1,
|
||||||
|
hide: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 큰 컴포넌트 (50% 이상)
|
||||||
|
else {
|
||||||
|
return {
|
||||||
|
desktop: {
|
||||||
|
gridColumns: 12, // 전체 너비
|
||||||
|
order: 1,
|
||||||
|
hide: false,
|
||||||
|
},
|
||||||
|
tablet: {
|
||||||
|
gridColumns: 8, // 전체 너비
|
||||||
|
order: 1,
|
||||||
|
hide: false,
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
gridColumns: 4, // 전체 너비
|
||||||
|
order: 1,
|
||||||
|
hide: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트에 반응형 설정이 없을 경우 자동 생성
|
||||||
|
*/
|
||||||
|
export function ensureResponsiveConfig(
|
||||||
|
component: ComponentData,
|
||||||
|
screenWidth?: number
|
||||||
|
): ComponentData {
|
||||||
|
if (component.responsiveConfig) {
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...component,
|
||||||
|
responsiveConfig: {
|
||||||
|
designerPosition: {
|
||||||
|
x: component.position.x,
|
||||||
|
y: component.position.y,
|
||||||
|
width: component.size.width,
|
||||||
|
height: component.size.height,
|
||||||
|
},
|
||||||
|
useSmartDefaults: true,
|
||||||
|
responsive: generateSmartDefaults(component, screenWidth),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 브레이크포인트 감지 훅 (1시간)
|
||||||
|
|
||||||
|
#### 파일: `frontend/hooks/useBreakpoint.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Breakpoint, BREAKPOINTS } from "@/types/responsive";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 윈도우 크기에 따른 브레이크포인트 반환
|
||||||
|
*/
|
||||||
|
export function useBreakpoint(): Breakpoint {
|
||||||
|
const [breakpoint, setBreakpoint] = useState<Breakpoint>("desktop");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateBreakpoint = () => {
|
||||||
|
const width = window.innerWidth;
|
||||||
|
|
||||||
|
if (width >= BREAKPOINTS.desktop.minWidth) {
|
||||||
|
setBreakpoint("desktop");
|
||||||
|
} else if (width >= BREAKPOINTS.tablet.minWidth) {
|
||||||
|
setBreakpoint("tablet");
|
||||||
|
} else {
|
||||||
|
setBreakpoint("mobile");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기 실행
|
||||||
|
updateBreakpoint();
|
||||||
|
|
||||||
|
// 리사이즈 이벤트 리스너 등록
|
||||||
|
window.addEventListener("resize", updateBreakpoint);
|
||||||
|
|
||||||
|
return () => window.removeEventListener("resize", updateBreakpoint);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return breakpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 브레이크포인트의 컬럼 수 반환
|
||||||
|
*/
|
||||||
|
export function useGridColumns(): number {
|
||||||
|
const breakpoint = useBreakpoint();
|
||||||
|
return BREAKPOINTS[breakpoint].columns;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 반응형 레이아웃 엔진 (6시간)
|
||||||
|
|
||||||
|
#### 파일: `frontend/components/screen/ResponsiveLayoutEngine.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { ComponentData } from "@/types/screen-management";
|
||||||
|
import { Breakpoint, BREAKPOINTS } from "@/types/responsive";
|
||||||
|
import {
|
||||||
|
generateSmartDefaults,
|
||||||
|
ensureResponsiveConfig,
|
||||||
|
} from "@/lib/utils/responsiveDefaults";
|
||||||
|
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||||
|
|
||||||
|
interface ResponsiveLayoutEngineProps {
|
||||||
|
components: ComponentData[];
|
||||||
|
breakpoint: Breakpoint;
|
||||||
|
containerWidth: number;
|
||||||
|
screenWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 반응형 레이아웃 엔진
|
||||||
|
*
|
||||||
|
* 절대 위치로 배치된 컴포넌트들을 반응형 그리드로 변환
|
||||||
|
*
|
||||||
|
* 변환 로직:
|
||||||
|
* 1. Y 위치 기준으로 행(row)으로 그룹화
|
||||||
|
* 2. 각 행 내에서 X 위치 기준으로 정렬
|
||||||
|
* 3. 반응형 설정 적용 (order, gridColumns, hide)
|
||||||
|
* 4. CSS Grid로 렌더링
|
||||||
|
*/
|
||||||
|
export const ResponsiveLayoutEngine: React.FC<ResponsiveLayoutEngineProps> = ({
|
||||||
|
components,
|
||||||
|
breakpoint,
|
||||||
|
containerWidth,
|
||||||
|
screenWidth = 1920,
|
||||||
|
}) => {
|
||||||
|
// 1단계: 컴포넌트들을 Y 위치 기준으로 행(row)으로 그룹화
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const sortedComponents = [...components].sort(
|
||||||
|
(a, b) => a.position.y - b.position.y
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows: ComponentData[][] = [];
|
||||||
|
let currentRow: ComponentData[] = [];
|
||||||
|
let currentRowY = 0;
|
||||||
|
const ROW_THRESHOLD = 50; // 같은 행으로 간주할 Y 오차 범위 (px)
|
||||||
|
|
||||||
|
sortedComponents.forEach((comp) => {
|
||||||
|
if (currentRow.length === 0) {
|
||||||
|
currentRow.push(comp);
|
||||||
|
currentRowY = comp.position.y;
|
||||||
|
} else if (Math.abs(comp.position.y - currentRowY) < ROW_THRESHOLD) {
|
||||||
|
currentRow.push(comp);
|
||||||
|
} else {
|
||||||
|
rows.push(currentRow);
|
||||||
|
currentRow = [comp];
|
||||||
|
currentRowY = comp.position.y;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentRow.length > 0) {
|
||||||
|
rows.push(currentRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}, [components]);
|
||||||
|
|
||||||
|
// 2단계: 각 행 내에서 X 위치 기준으로 정렬
|
||||||
|
const sortedRows = useMemo(() => {
|
||||||
|
return rows.map((row) =>
|
||||||
|
[...row].sort((a, b) => a.position.x - b.position.x)
|
||||||
|
);
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
// 3단계: 반응형 설정 적용
|
||||||
|
const responsiveComponents = useMemo(() => {
|
||||||
|
return sortedRows.flatMap((row) =>
|
||||||
|
row.map((comp) => {
|
||||||
|
// 반응형 설정이 없으면 자동 생성
|
||||||
|
const compWithConfig = ensureResponsiveConfig(comp, screenWidth);
|
||||||
|
|
||||||
|
// 현재 브레이크포인트의 설정 가져오기
|
||||||
|
const config = compWithConfig.responsiveConfig!.useSmartDefaults
|
||||||
|
? generateSmartDefaults(comp, screenWidth)[breakpoint]
|
||||||
|
: compWithConfig.responsiveConfig!.responsive?.[breakpoint];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...compWithConfig,
|
||||||
|
responsiveDisplay:
|
||||||
|
config || generateSmartDefaults(comp, screenWidth)[breakpoint],
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [sortedRows, breakpoint, screenWidth]);
|
||||||
|
|
||||||
|
// 4단계: 필터링 및 정렬
|
||||||
|
const visibleComponents = useMemo(() => {
|
||||||
|
return responsiveComponents
|
||||||
|
.filter((comp) => !comp.responsiveDisplay?.hide)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.responsiveDisplay?.order || 0) - (b.responsiveDisplay?.order || 0)
|
||||||
|
);
|
||||||
|
}, [responsiveComponents]);
|
||||||
|
|
||||||
|
const gridColumns = BREAKPOINTS[breakpoint].columns;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="responsive-grid w-full"
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
|
||||||
|
gap: "16px",
|
||||||
|
padding: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{visibleComponents.map((comp) => (
|
||||||
|
<div
|
||||||
|
key={comp.id}
|
||||||
|
className="responsive-grid-item"
|
||||||
|
style={{
|
||||||
|
gridColumn: `span ${
|
||||||
|
comp.responsiveDisplay?.gridColumns || gridColumns
|
||||||
|
}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DynamicComponentRenderer component={comp} isPreview={true} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.5 화면 표시 페이지 수정 (4시간)
|
||||||
|
|
||||||
|
#### 파일: `frontend/app/(main)/screens/[screenId]/page.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 import 유지
|
||||||
|
import { ResponsiveLayoutEngine } from "@/components/screen/ResponsiveLayoutEngine";
|
||||||
|
import { useBreakpoint } from "@/hooks/useBreakpoint";
|
||||||
|
|
||||||
|
export default function ScreenViewPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { screenId: string };
|
||||||
|
}) {
|
||||||
|
const [layout, setLayout] = useState<LayoutData | null>(null);
|
||||||
|
const breakpoint = useBreakpoint();
|
||||||
|
|
||||||
|
// 반응형 모드 토글 (사용자 설정 또는 화면 설정에 따라)
|
||||||
|
const [useResponsive, setUseResponsive] = useState(true);
|
||||||
|
|
||||||
|
// 기존 로직 유지...
|
||||||
|
|
||||||
|
if (!layout) {
|
||||||
|
return <div>로딩 중...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const screenWidth = layout.screenResolution?.width || 1920;
|
||||||
|
const screenHeight = layout.screenResolution?.height || 1080;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full bg-white">
|
||||||
|
{useResponsive ? (
|
||||||
|
// 반응형 모드
|
||||||
|
<ResponsiveLayoutEngine
|
||||||
|
components={layout.components || []}
|
||||||
|
breakpoint={breakpoint}
|
||||||
|
containerWidth={window.innerWidth}
|
||||||
|
screenWidth={screenWidth}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// 기존 스케일 모드 (하위 호환성)
|
||||||
|
<div className="overflow-auto" style={{ padding: "16px 0" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${screenWidth * scale}px`,
|
||||||
|
minHeight: `${screenHeight * scale}px`,
|
||||||
|
marginLeft: "16px",
|
||||||
|
marginRight: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative bg-white"
|
||||||
|
style={{
|
||||||
|
width: `${screenWidth}px`,
|
||||||
|
minHeight: `${screenHeight}px`,
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
transformOrigin: "top left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layout.components?.map((component) => (
|
||||||
|
<div
|
||||||
|
key={component.id}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: `${component.position.x}px`,
|
||||||
|
top: `${component.position.y}px`,
|
||||||
|
width:
|
||||||
|
component.style?.width || `${component.size.width}px`,
|
||||||
|
minHeight:
|
||||||
|
component.style?.height || `${component.size.height}px`,
|
||||||
|
zIndex: component.position.z || 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DynamicComponentRenderer
|
||||||
|
component={component}
|
||||||
|
isPreview={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Phase 2: 디자이너 통합 (1-2일)
|
||||||
|
|
||||||
|
### 2.1 반응형 설정 패널 (5시간)
|
||||||
|
|
||||||
|
#### 파일: `frontend/components/screen/panels/ResponsiveConfigPanel.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { ComponentData } from "@/types/screen-management";
|
||||||
|
import {
|
||||||
|
Breakpoint,
|
||||||
|
BREAKPOINTS,
|
||||||
|
ResponsiveComponentConfig,
|
||||||
|
} from "@/types/responsive";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
|
interface ResponsiveConfigPanelProps {
|
||||||
|
component: ComponentData;
|
||||||
|
onUpdate: (config: ResponsiveComponentConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResponsiveConfigPanel: React.FC<ResponsiveConfigPanelProps> = ({
|
||||||
|
component,
|
||||||
|
onUpdate,
|
||||||
|
}) => {
|
||||||
|
const [activeTab, setActiveTab] = useState<Breakpoint>("desktop");
|
||||||
|
|
||||||
|
const config = component.responsiveConfig || {
|
||||||
|
designerPosition: {
|
||||||
|
x: component.position.x,
|
||||||
|
y: component.position.y,
|
||||||
|
width: component.size.width,
|
||||||
|
height: component.size.height,
|
||||||
|
},
|
||||||
|
useSmartDefaults: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>반응형 설정</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* 스마트 기본값 토글 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="smartDefaults"
|
||||||
|
checked={config.useSmartDefaults}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
onUpdate({
|
||||||
|
...config,
|
||||||
|
useSmartDefaults: checked as boolean,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="smartDefaults">스마트 기본값 사용 (권장)</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 수동 설정 */}
|
||||||
|
{!config.useSmartDefaults && (
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(v) => setActiveTab(v as Breakpoint)}
|
||||||
|
>
|
||||||
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="desktop">데스크톱</TabsTrigger>
|
||||||
|
<TabsTrigger value="tablet">태블릿</TabsTrigger>
|
||||||
|
<TabsTrigger value="mobile">모바일</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value={activeTab} className="space-y-4">
|
||||||
|
{/* 그리드 컬럼 수 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>너비 (그리드 컬럼)</Label>
|
||||||
|
<Select
|
||||||
|
value={config.responsive?.[
|
||||||
|
activeTab
|
||||||
|
]?.gridColumns?.toString()}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
onUpdate({
|
||||||
|
...config,
|
||||||
|
responsive: {
|
||||||
|
...config.responsive,
|
||||||
|
[activeTab]: {
|
||||||
|
...config.responsive?.[activeTab],
|
||||||
|
gridColumns: parseInt(v),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="컬럼 수 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[...Array(BREAKPOINTS[activeTab].columns)].map((_, i) => {
|
||||||
|
const cols = i + 1;
|
||||||
|
const percent = (
|
||||||
|
(cols / BREAKPOINTS[activeTab].columns) *
|
||||||
|
100
|
||||||
|
).toFixed(0);
|
||||||
|
return (
|
||||||
|
<SelectItem key={cols} value={cols.toString()}>
|
||||||
|
{cols} / {BREAKPOINTS[activeTab].columns} ({percent}%)
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 표시 순서 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>표시 순서</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={config.responsive?.[activeTab]?.order || 1}
|
||||||
|
onChange={(e) => {
|
||||||
|
onUpdate({
|
||||||
|
...config,
|
||||||
|
responsive: {
|
||||||
|
...config.responsive,
|
||||||
|
[activeTab]: {
|
||||||
|
...config.responsive?.[activeTab],
|
||||||
|
order: parseInt(e.target.value),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 숨김 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`hide-${activeTab}`}
|
||||||
|
checked={config.responsive?.[activeTab]?.hide || false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
onUpdate({
|
||||||
|
...config,
|
||||||
|
responsive: {
|
||||||
|
...config.responsive,
|
||||||
|
[activeTab]: {
|
||||||
|
...config.responsive?.[activeTab],
|
||||||
|
hide: checked as boolean,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`hide-${activeTab}`}>
|
||||||
|
{activeTab === "desktop"
|
||||||
|
? "데스크톱"
|
||||||
|
: activeTab === "tablet"
|
||||||
|
? "태블릿"
|
||||||
|
: "모바일"}
|
||||||
|
에서 숨김
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 속성 패널 통합 (1시간)
|
||||||
|
|
||||||
|
#### 파일: `frontend/components/screen/panels/UnifiedPropertiesPanel.tsx` 수정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 import에 추가
|
||||||
|
import { ResponsiveConfigPanel } from './ResponsiveConfigPanel';
|
||||||
|
|
||||||
|
// 컴포넌트 내부에 추가
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 기존 패널들 */}
|
||||||
|
<PropertiesPanel ... />
|
||||||
|
<StyleEditor ... />
|
||||||
|
|
||||||
|
{/* 반응형 설정 패널 추가 */}
|
||||||
|
<ResponsiveConfigPanel
|
||||||
|
component={selectedComponent}
|
||||||
|
onUpdate={(config) => {
|
||||||
|
onUpdateComponent({
|
||||||
|
...selectedComponent,
|
||||||
|
responsiveConfig: config
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 기존 세부 설정 패널 */}
|
||||||
|
<DetailSettingsPanel ... />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 미리보기 모드 (3시간)
|
||||||
|
|
||||||
|
#### 파일: `frontend/components/screen/ScreenDesigner.tsx` 수정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 추가 import
|
||||||
|
import { Breakpoint } from '@/types/responsive';
|
||||||
|
import { ResponsiveLayoutEngine } from './ResponsiveLayoutEngine';
|
||||||
|
import { useBreakpoint } from '@/hooks/useBreakpoint';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export const ScreenDesigner: React.FC = () => {
|
||||||
|
// 미리보기 모드: 'design' | 'desktop' | 'tablet' | 'mobile'
|
||||||
|
const [previewMode, setPreviewMode] = useState<'design' | Breakpoint>('design');
|
||||||
|
const currentBreakpoint = useBreakpoint();
|
||||||
|
|
||||||
|
// ... 기존 로직 ...
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
{/* 상단 툴바 */}
|
||||||
|
<div className="flex gap-2 p-2 border-b bg-white">
|
||||||
|
<Button
|
||||||
|
variant={previewMode === 'design' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPreviewMode('design')}
|
||||||
|
>
|
||||||
|
디자인 모드
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={previewMode === 'desktop' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPreviewMode('desktop')}
|
||||||
|
>
|
||||||
|
데스크톱 미리보기
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={previewMode === 'tablet' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPreviewMode('tablet')}
|
||||||
|
>
|
||||||
|
태블릿 미리보기
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={previewMode === 'mobile' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPreviewMode('mobile')}
|
||||||
|
>
|
||||||
|
모바일 미리보기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 캔버스 영역 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{previewMode === 'design' ? (
|
||||||
|
// 기존 절대 위치 기반 디자이너
|
||||||
|
<Canvas ... />
|
||||||
|
) : (
|
||||||
|
// 반응형 미리보기
|
||||||
|
<div
|
||||||
|
className="mx-auto border border-gray-300"
|
||||||
|
style={{
|
||||||
|
width: previewMode === 'desktop' ? '100%' :
|
||||||
|
previewMode === 'tablet' ? '768px' :
|
||||||
|
'375px',
|
||||||
|
minHeight: '100%'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ResponsiveLayoutEngine
|
||||||
|
components={components}
|
||||||
|
breakpoint={previewMode}
|
||||||
|
containerWidth={
|
||||||
|
previewMode === 'desktop' ? window.innerWidth :
|
||||||
|
previewMode === 'tablet' ? 768 :
|
||||||
|
375
|
||||||
|
}
|
||||||
|
screenWidth={selectedScreen?.screenResolution?.width || 1920}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💾 Phase 3: 저장/불러오기 (1일)
|
||||||
|
|
||||||
|
### 3.1 타입 업데이트 (2시간)
|
||||||
|
|
||||||
|
#### 파일: `frontend/types/screen-management.ts` 수정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ResponsiveComponentConfig } from "./responsive";
|
||||||
|
|
||||||
|
export interface ComponentData {
|
||||||
|
// ... 기존 필드들 ...
|
||||||
|
|
||||||
|
// 반응형 설정 추가
|
||||||
|
responsiveConfig?: ResponsiveComponentConfig;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 저장 로직 (2시간)
|
||||||
|
|
||||||
|
#### 파일: `frontend/components/screen/ScreenDesigner.tsx` 수정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 저장 함수 수정
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
const layoutData: LayoutData = {
|
||||||
|
screenResolution: {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
},
|
||||||
|
components: components.map((comp) => ({
|
||||||
|
...comp,
|
||||||
|
// 반응형 설정이 없으면 자동 생성
|
||||||
|
responsiveConfig: comp.responsiveConfig || {
|
||||||
|
designerPosition: {
|
||||||
|
x: comp.position.x,
|
||||||
|
y: comp.position.y,
|
||||||
|
width: comp.size.width,
|
||||||
|
height: comp.size.height,
|
||||||
|
},
|
||||||
|
useSmartDefaults: true,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
await screenApi.updateLayout(selectedScreen.id, layoutData);
|
||||||
|
// ... 기존 로직 ...
|
||||||
|
} catch (error) {
|
||||||
|
console.error("저장 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 불러오기 로직 (2시간)
|
||||||
|
|
||||||
|
#### 파일: `frontend/components/screen/ScreenDesigner.tsx` 수정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ensureResponsiveConfig } from "@/lib/utils/responsiveDefaults";
|
||||||
|
|
||||||
|
// 화면 불러오기
|
||||||
|
useEffect(() => {
|
||||||
|
const loadScreen = async () => {
|
||||||
|
if (!selectedScreenId) return;
|
||||||
|
|
||||||
|
const screen = await screenApi.getScreenById(selectedScreenId);
|
||||||
|
const layout = await screenApi.getLayout(selectedScreenId);
|
||||||
|
|
||||||
|
// 반응형 설정이 없는 컴포넌트에 자동 생성
|
||||||
|
const componentsWithResponsive = layout.components.map((comp) =>
|
||||||
|
ensureResponsiveConfig(comp, layout.screenResolution?.width)
|
||||||
|
);
|
||||||
|
|
||||||
|
setSelectedScreen(screen);
|
||||||
|
setComponents(componentsWithResponsive);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadScreen();
|
||||||
|
}, [selectedScreenId]);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Phase 4: 테스트 및 최적화 (1일)
|
||||||
|
|
||||||
|
### 4.1 기능 테스트 체크리스트 (3시간)
|
||||||
|
|
||||||
|
- [ ] 브레이크포인트 전환 테스트
|
||||||
|
- [ ] 윈도우 크기 변경 시 자동 전환
|
||||||
|
- [ ] desktop → tablet → mobile 순차 테스트
|
||||||
|
- [ ] 스마트 기본값 생성 테스트
|
||||||
|
- [ ] 작은 컴포넌트 (25% 이하)
|
||||||
|
- [ ] 중간 컴포넌트 (25-50%)
|
||||||
|
- [ ] 큰 컴포넌트 (50% 이상)
|
||||||
|
- [ ] 수동 설정 적용 테스트
|
||||||
|
- [ ] 그리드 컬럼 변경
|
||||||
|
- [ ] 표시 순서 변경
|
||||||
|
- [ ] 디바이스별 숨김
|
||||||
|
- [ ] 미리보기 모드 테스트
|
||||||
|
- [ ] 디자인 모드 ↔ 미리보기 모드 전환
|
||||||
|
- [ ] 각 브레이크포인트 미리보기
|
||||||
|
- [ ] 저장/불러오기 테스트
|
||||||
|
- [ ] 반응형 설정 저장
|
||||||
|
- [ ] 기존 화면 불러오기 시 자동 변환
|
||||||
|
|
||||||
|
### 4.2 성능 최적화 (3시간)
|
||||||
|
|
||||||
|
#### 레이아웃 계산 메모이제이션
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ResponsiveLayoutEngine.tsx
|
||||||
|
const memoizedLayout = useMemo(() => {
|
||||||
|
// 레이아웃 계산 로직
|
||||||
|
}, [components, breakpoint, screenWidth]);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ResizeObserver 최적화
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// useBreakpoint.ts
|
||||||
|
// debounce 적용
|
||||||
|
const debouncedResize = debounce(updateBreakpoint, 150);
|
||||||
|
window.addEventListener("resize", debouncedResize);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 불필요한 리렌더링 방지
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// React.memo 적용
|
||||||
|
export const ResponsiveLayoutEngine = React.memo<ResponsiveLayoutEngineProps>(({...}) => {
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 UI/UX 개선 (2시간)
|
||||||
|
|
||||||
|
- [ ] 반응형 설정 패널 툴팁 추가
|
||||||
|
- [ ] 미리보기 모드 전환 애니메이션
|
||||||
|
- [ ] 로딩 상태 표시
|
||||||
|
- [ ] 에러 처리 및 사용자 피드백
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📅 최종 타임라인
|
||||||
|
|
||||||
|
| Phase | 작업 내용 | 소요 시간 | 누적 시간 |
|
||||||
|
| ------- | --------------------- | --------- | ------------ |
|
||||||
|
| Phase 1 | 타입 정의 및 유틸리티 | 6시간 | 6시간 |
|
||||||
|
| Phase 1 | 반응형 레이아웃 엔진 | 6시간 | 12시간 |
|
||||||
|
| Phase 1 | 화면 표시 페이지 수정 | 4시간 | 16시간 (2일) |
|
||||||
|
| Phase 2 | 반응형 설정 패널 | 5시간 | 21시간 |
|
||||||
|
| Phase 2 | 디자이너 통합 | 4시간 | 25시간 (3일) |
|
||||||
|
| Phase 3 | 저장/불러오기 | 6시간 | 31시간 (4일) |
|
||||||
|
| Phase 4 | 테스트 및 최적화 | 8시간 | 39시간 (5일) |
|
||||||
|
|
||||||
|
**총 예상 시간: 39시간 (약 5일)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 구현 우선순위
|
||||||
|
|
||||||
|
### 1단계: 핵심 기능 (필수)
|
||||||
|
|
||||||
|
1. ✅ 타입 정의
|
||||||
|
2. ✅ 스마트 기본값 생성기
|
||||||
|
3. ✅ 브레이크포인트 훅
|
||||||
|
4. ✅ 반응형 레이아웃 엔진
|
||||||
|
5. ✅ 화면 표시 페이지 수정
|
||||||
|
|
||||||
|
### 2단계: 디자이너 UI (중요)
|
||||||
|
|
||||||
|
6. ✅ 반응형 설정 패널
|
||||||
|
7. ✅ 속성 패널 통합
|
||||||
|
8. ✅ 미리보기 모드
|
||||||
|
|
||||||
|
### 3단계: 데이터 처리 (중요)
|
||||||
|
|
||||||
|
9. ✅ 타입 업데이트
|
||||||
|
10. ✅ 저장/불러오기 로직
|
||||||
|
|
||||||
|
### 4단계: 완성도 (선택)
|
||||||
|
|
||||||
|
11. 테스트
|
||||||
|
12. 최적화
|
||||||
|
13. UI/UX 개선
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 완료 체크리스트
|
||||||
|
|
||||||
|
### Phase 1: 기본 시스템
|
||||||
|
|
||||||
|
- [ ] `frontend/types/responsive.ts` 생성
|
||||||
|
- [ ] `frontend/lib/utils/responsiveDefaults.ts` 생성
|
||||||
|
- [ ] `frontend/hooks/useBreakpoint.ts` 생성
|
||||||
|
- [ ] `frontend/components/screen/ResponsiveLayoutEngine.tsx` 생성
|
||||||
|
- [ ] `frontend/app/(main)/screens/[screenId]/page.tsx` 수정
|
||||||
|
|
||||||
|
### Phase 2: 디자이너 통합
|
||||||
|
|
||||||
|
- [ ] `frontend/components/screen/panels/ResponsiveConfigPanel.tsx` 생성
|
||||||
|
- [ ] `frontend/components/screen/panels/UnifiedPropertiesPanel.tsx` 수정
|
||||||
|
- [ ] `frontend/components/screen/ScreenDesigner.tsx` 수정
|
||||||
|
|
||||||
|
### Phase 3: 데이터 처리
|
||||||
|
|
||||||
|
- [ ] `frontend/types/screen-management.ts` 수정
|
||||||
|
- [ ] 저장 로직 수정
|
||||||
|
- [ ] 불러오기 로직 수정
|
||||||
|
|
||||||
|
### Phase 4: 테스트
|
||||||
|
|
||||||
|
- [ ] 기능 테스트 완료
|
||||||
|
- [ ] 성능 최적화 완료
|
||||||
|
- [ ] UI/UX 개선 완료
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 시작 준비 완료
|
||||||
|
|
||||||
|
이제 Phase 1부터 순차적으로 구현을 시작합니다.
|
||||||
|
|
@ -1,20 +1,17 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState, useRef, useMemo } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { ScreenDefinition, LayoutData } from "@/types/screen";
|
import { ScreenDefinition, LayoutData } from "@/types/screen";
|
||||||
import { InteractiveScreenViewer } from "@/components/screen/InteractiveScreenViewerDynamic";
|
|
||||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
|
||||||
import { DynamicWebTypeRenderer } from "@/lib/registry";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { initializeComponents } from "@/lib/registry/components";
|
import { initializeComponents } from "@/lib/registry/components";
|
||||||
import { EditModal } from "@/components/screen/EditModal";
|
import { EditModal } from "@/components/screen/EditModal";
|
||||||
import { isFileComponent, getComponentWebType } from "@/lib/utils/componentTypeUtils";
|
import { ResponsiveLayoutEngine } from "@/components/screen/ResponsiveLayoutEngine";
|
||||||
// import { ResponsiveScreenContainer } from "@/components/screen/ResponsiveScreenContainer"; // 컨테이너 제거
|
import { useBreakpoint } from "@/hooks/useBreakpoint";
|
||||||
|
|
||||||
export default function ScreenViewPage() {
|
export default function ScreenViewPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
@ -27,15 +24,7 @@ export default function ScreenViewPage() {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
// 테이블 선택된 행 상태 (화면 레벨에서 관리)
|
const breakpoint = useBreakpoint();
|
||||||
const [selectedRows, setSelectedRows] = useState<any[]>([]);
|
|
||||||
const [selectedRowsData, setSelectedRowsData] = useState<any[]>([]);
|
|
||||||
|
|
||||||
// 테이블 새로고침을 위한 키 상태
|
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
|
||||||
|
|
||||||
// 스케일 상태
|
|
||||||
const [scale, setScale] = useState(1);
|
|
||||||
|
|
||||||
// 편집 모달 상태
|
// 편집 모달 상태
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
|
|
@ -122,73 +111,6 @@ export default function ScreenViewPage() {
|
||||||
}
|
}
|
||||||
}, [screenId]);
|
}, [screenId]);
|
||||||
|
|
||||||
// 가로폭 기준 자동 스케일 계산
|
|
||||||
useEffect(() => {
|
|
||||||
const updateScale = () => {
|
|
||||||
if (layout) {
|
|
||||||
// main 요소의 실제 너비를 직접 사용
|
|
||||||
const mainElement = document.querySelector("main");
|
|
||||||
const mainWidth = mainElement ? mainElement.clientWidth : window.innerWidth - 288;
|
|
||||||
|
|
||||||
// 좌우 마진 16px씩 제외
|
|
||||||
const margin = 32; // 16px * 2
|
|
||||||
const availableWidth = mainWidth - margin;
|
|
||||||
|
|
||||||
const screenWidth = layout?.screenResolution?.width || 1200;
|
|
||||||
const newScale = availableWidth / screenWidth;
|
|
||||||
|
|
||||||
console.log("🎯 스케일 계산 (마진 포함):", {
|
|
||||||
mainWidth,
|
|
||||||
margin,
|
|
||||||
availableWidth,
|
|
||||||
screenWidth,
|
|
||||||
newScale,
|
|
||||||
});
|
|
||||||
|
|
||||||
setScale(newScale);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
updateScale();
|
|
||||||
}, [layout]);
|
|
||||||
|
|
||||||
// 실제 컨텐츠의 동적 높이 상태
|
|
||||||
const [actualContentHeight, setActualContentHeight] = useState(layout?.screenResolution?.height || 800);
|
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// ResizeObserver로 컨텐츠 높이 실시간 모니터링
|
|
||||||
useEffect(() => {
|
|
||||||
if (!contentRef.current) return;
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
|
||||||
if (!contentRef.current) return;
|
|
||||||
|
|
||||||
// 모든 컴포넌트의 실제 높이를 측정
|
|
||||||
const components = contentRef.current.querySelectorAll("[data-component-id]");
|
|
||||||
let maxBottom = layout?.screenResolution?.height || 800;
|
|
||||||
|
|
||||||
components.forEach((element) => {
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
const parentRect = contentRef.current!.getBoundingClientRect();
|
|
||||||
const relativeTop = rect.top - parentRect.top;
|
|
||||||
const bottom = relativeTop + rect.height;
|
|
||||||
maxBottom = Math.max(maxBottom, bottom);
|
|
||||||
});
|
|
||||||
|
|
||||||
setActualContentHeight(maxBottom);
|
|
||||||
});
|
|
||||||
|
|
||||||
resizeObserver.observe(contentRef.current);
|
|
||||||
|
|
||||||
// 모든 자식 요소도 관찰
|
|
||||||
const childElements = contentRef.current.querySelectorAll("[data-component-id]");
|
|
||||||
childElements.forEach((child) => resizeObserver.observe(child));
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
};
|
|
||||||
}, [layout]);
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
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">
|
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br from-gray-50 to-slate-100">
|
||||||
|
|
@ -219,275 +141,21 @@ export default function ScreenViewPage() {
|
||||||
|
|
||||||
// 화면 해상도 정보가 있으면 해당 크기로, 없으면 기본 크기 사용
|
// 화면 해상도 정보가 있으면 해당 크기로, 없으면 기본 크기 사용
|
||||||
const screenWidth = layout?.screenResolution?.width || 1200;
|
const screenWidth = layout?.screenResolution?.width || 1200;
|
||||||
const screenHeight = layout?.screenResolution?.height || 800;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full bg-white" style={{ padding: "16px 0" }}>
|
<div className="h-full w-full bg-white">
|
||||||
{layout && layout.components.length > 0 ? (
|
<div style={{ padding: "16px 0" }}>
|
||||||
// 스케일링된 화면을 감싸는 래퍼 (실제 크기 조정 + 좌우 마진 16px)
|
{/* 항상 반응형 모드로 렌더링 */}
|
||||||
<div
|
{layout && layout.components.length > 0 ? (
|
||||||
style={{
|
<ResponsiveLayoutEngine
|
||||||
width: `${screenWidth * scale}px`,
|
components={layout?.components || []}
|
||||||
minHeight: `${actualContentHeight * scale}px`,
|
breakpoint={breakpoint}
|
||||||
marginLeft: "16px",
|
containerWidth={window.innerWidth}
|
||||||
marginRight: "16px",
|
screenWidth={screenWidth}
|
||||||
}}
|
/>
|
||||||
>
|
) : (
|
||||||
{/* 캔버스 컴포넌트들을 가로폭에 맞춰 스케일링하여 표시 */}
|
// 빈 화면일 때
|
||||||
<div
|
<div className="flex items-center justify-center bg-white" style={{ minHeight: "600px" }}>
|
||||||
ref={contentRef}
|
|
||||||
className="relative bg-white"
|
|
||||||
style={{
|
|
||||||
width: `${screenWidth}px`,
|
|
||||||
minHeight: `${actualContentHeight}px`,
|
|
||||||
transform: `scale(${scale})`,
|
|
||||||
transformOrigin: "top left",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{layout.components
|
|
||||||
.filter((comp) => !comp.parentId) // 최상위 컴포넌트만 렌더링 (그룹 포함)
|
|
||||||
.map((component) => {
|
|
||||||
// 그룹 컴포넌트인 경우 특별 처리
|
|
||||||
if (component.type === "group") {
|
|
||||||
const groupChildren = layout.components.filter((child) => child.parentId === component.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={component.id}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: `${component.position.x}px`,
|
|
||||||
top: `${component.position.y}px`,
|
|
||||||
width: component.style?.width || `${component.size.width}px`,
|
|
||||||
height: component.style?.height || `${component.size.height}px`,
|
|
||||||
zIndex: component.position.z || 1,
|
|
||||||
backgroundColor: (component as any).backgroundColor || "rgba(59, 130, 246, 0.05)",
|
|
||||||
border: (component as any).border || "1px solid rgba(59, 130, 246, 0.2)",
|
|
||||||
borderRadius: (component as any).borderRadius || "12px",
|
|
||||||
padding: "20px",
|
|
||||||
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 그룹 제목 */}
|
|
||||||
{(component as any).title && (
|
|
||||||
<div className="mb-3 inline-block rounded-lg bg-blue-50 px-3 py-1 text-sm font-semibold text-blue-700">
|
|
||||||
{(component as any).title}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 그룹 내 자식 컴포넌트들 렌더링 */}
|
|
||||||
{groupChildren.map((child) => (
|
|
||||||
<div
|
|
||||||
key={child.id}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: `${child.position.x}px`,
|
|
||||||
top: `${child.position.y}px`,
|
|
||||||
width: child.style?.width || `${child.size.width}px`,
|
|
||||||
height: child.style?.height || `${child.size.height}px`,
|
|
||||||
zIndex: child.position.z || 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<InteractiveScreenViewer
|
|
||||||
component={child}
|
|
||||||
allComponents={layout.components}
|
|
||||||
formData={formData}
|
|
||||||
onFormDataChange={(fieldName, value) => {
|
|
||||||
console.log("📝 폼 데이터 변경:", { fieldName, value });
|
|
||||||
setFormData((prev) => {
|
|
||||||
const newFormData = {
|
|
||||||
...prev,
|
|
||||||
[fieldName]: value,
|
|
||||||
};
|
|
||||||
console.log("📊 전체 폼 데이터:", newFormData);
|
|
||||||
return newFormData;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
screenInfo={{
|
|
||||||
id: screenId,
|
|
||||||
tableName: screen?.tableName,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 라벨 표시 여부 계산
|
|
||||||
const templateTypes = ["datatable"];
|
|
||||||
const shouldShowLabel =
|
|
||||||
component.style?.labelDisplay !== false &&
|
|
||||||
(component.label || component.style?.labelText) &&
|
|
||||||
!templateTypes.includes(component.type);
|
|
||||||
|
|
||||||
const labelText = component.style?.labelText || component.label || "";
|
|
||||||
const labelStyle = {
|
|
||||||
fontSize: component.style?.labelFontSize || "14px",
|
|
||||||
color: component.style?.labelColor || "#212121",
|
|
||||||
fontWeight: component.style?.labelFontWeight || "500",
|
|
||||||
backgroundColor: component.style?.labelBackgroundColor || "transparent",
|
|
||||||
padding: component.style?.labelPadding || "0",
|
|
||||||
borderRadius: component.style?.labelBorderRadius || "0",
|
|
||||||
marginBottom: component.style?.labelMarginBottom || "4px",
|
|
||||||
};
|
|
||||||
|
|
||||||
// 일반 컴포넌트 렌더링
|
|
||||||
return (
|
|
||||||
<div key={component.id}>
|
|
||||||
{/* 라벨을 외부에 별도로 렌더링 */}
|
|
||||||
{shouldShowLabel && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: `${component.position.x}px`,
|
|
||||||
top: `${component.position.y - 25}px`, // 컴포넌트 위쪽에 라벨 배치
|
|
||||||
zIndex: (component.position.z || 1) + 1,
|
|
||||||
...labelStyle,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{labelText}
|
|
||||||
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 실제 컴포넌트 */}
|
|
||||||
<div
|
|
||||||
data-component-id={component.id}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: `${component.position.x}px`,
|
|
||||||
top: `${component.position.y}px`,
|
|
||||||
width: component.style?.width || `${component.size.width}px`,
|
|
||||||
minHeight: component.style?.height || `${component.size.height}px`,
|
|
||||||
zIndex: component.position.z || 1,
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
// console.log("🎯 할당된 화면 컴포넌트:", {
|
|
||||||
// id: component.id,
|
|
||||||
// type: component.type,
|
|
||||||
// position: component.position,
|
|
||||||
// size: component.size,
|
|
||||||
// styleWidth: component.style?.width,
|
|
||||||
// styleHeight: component.style?.height,
|
|
||||||
// finalWidth: `${component.size.width}px`,
|
|
||||||
// finalHeight: `${component.size.height}px`,
|
|
||||||
// });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용 */}
|
|
||||||
{component.type !== "widget" ? (
|
|
||||||
<DynamicComponentRenderer
|
|
||||||
component={{
|
|
||||||
...component,
|
|
||||||
style: {
|
|
||||||
...component.style,
|
|
||||||
labelDisplay: shouldShowLabel ? false : (component.style?.labelDisplay ?? true), // 상위에서 라벨을 표시했으면 컴포넌트 내부에서는 숨김
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
isInteractive={true}
|
|
||||||
formData={formData}
|
|
||||||
onFormDataChange={(fieldName, value) => {
|
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[fieldName]: value,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
screenId={screenId}
|
|
||||||
tableName={screen?.tableName}
|
|
||||||
onRefresh={() => {
|
|
||||||
console.log("화면 새로고침 요청");
|
|
||||||
// 테이블 컴포넌트 강제 새로고침을 위한 키 업데이트
|
|
||||||
setRefreshKey((prev) => prev + 1);
|
|
||||||
// 선택된 행 상태도 초기화
|
|
||||||
setSelectedRows([]);
|
|
||||||
setSelectedRowsData([]);
|
|
||||||
}}
|
|
||||||
onClose={() => {
|
|
||||||
console.log("화면 닫기 요청");
|
|
||||||
}}
|
|
||||||
// 테이블 선택된 행 정보 전달
|
|
||||||
selectedRows={selectedRows}
|
|
||||||
selectedRowsData={selectedRowsData}
|
|
||||||
onSelectedRowsChange={(newSelectedRows, newSelectedRowsData) => {
|
|
||||||
setSelectedRows(newSelectedRows);
|
|
||||||
setSelectedRowsData(newSelectedRowsData);
|
|
||||||
}}
|
|
||||||
// 테이블 새로고침 키 전달
|
|
||||||
refreshKey={refreshKey}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DynamicWebTypeRenderer
|
|
||||||
webType={(() => {
|
|
||||||
// 유틸리티 함수로 파일 컴포넌트 감지
|
|
||||||
if (isFileComponent(component)) {
|
|
||||||
console.log('🎯 page.tsx - 파일 컴포넌트 감지 → webType: "file"', {
|
|
||||||
componentId: component.id,
|
|
||||||
componentType: component.type,
|
|
||||||
originalWebType: component.webType,
|
|
||||||
});
|
|
||||||
return "file";
|
|
||||||
}
|
|
||||||
// 다른 컴포넌트는 유틸리티 함수로 webType 결정
|
|
||||||
return getComponentWebType(component) || "text";
|
|
||||||
})()}
|
|
||||||
config={component.webTypeConfig}
|
|
||||||
props={{
|
|
||||||
component: component,
|
|
||||||
value: formData[component.columnName || component.id] || "",
|
|
||||||
onChange: (value: any) => {
|
|
||||||
const fieldName = component.columnName || component.id;
|
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[fieldName]: value,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
onFormDataChange: (fieldName, value) => {
|
|
||||||
console.log(`🎯 page.tsx onFormDataChange 호출: ${fieldName} = "${value}"`);
|
|
||||||
console.log("📋 현재 formData:", formData);
|
|
||||||
setFormData((prev) => {
|
|
||||||
const newFormData = {
|
|
||||||
...prev,
|
|
||||||
[fieldName]: value,
|
|
||||||
};
|
|
||||||
console.log("📝 업데이트된 formData:", newFormData);
|
|
||||||
return newFormData;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
isInteractive: true,
|
|
||||||
formData: formData,
|
|
||||||
readonly: component.readonly,
|
|
||||||
required: component.required,
|
|
||||||
placeholder: component.placeholder,
|
|
||||||
className: "w-full h-full",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// 빈 화면일 때도 같은 스케일로 표시 + 좌우 마진 16px
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: `${screenWidth * scale}px`,
|
|
||||||
minHeight: `${screenHeight * scale}px`,
|
|
||||||
marginLeft: "16px",
|
|
||||||
marginRight: "16px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-center bg-white"
|
|
||||||
style={{
|
|
||||||
width: `${screenWidth}px`,
|
|
||||||
minHeight: `${screenHeight}px`,
|
|
||||||
transform: `scale(${scale})`,
|
|
||||||
transformOrigin: "top left",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-white shadow-sm">
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-white shadow-sm">
|
||||||
<span className="text-2xl">📄</span>
|
<span className="text-2xl">📄</span>
|
||||||
|
|
@ -496,8 +164,8 @@ export default function ScreenViewPage() {
|
||||||
<p className="text-gray-600">이 화면에는 아직 설계된 컴포넌트가 없습니다.</p>
|
<p className="text-gray-600">이 화면에는 아직 설계된 컴포넌트가 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* 편집 모달 */}
|
{/* 편집 모달 */}
|
||||||
<EditModal
|
<EditModal
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
/**
|
||||||
|
* 반응형 레이아웃 엔진
|
||||||
|
*
|
||||||
|
* 절대 위치로 배치된 컴포넌트들을 반응형 그리드로 변환
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { ComponentData } from "@/types/screen-management";
|
||||||
|
import { Breakpoint, BREAKPOINTS } from "@/types/responsive";
|
||||||
|
import { generateSmartDefaults, ensureResponsiveConfig } from "@/lib/utils/responsiveDefaults";
|
||||||
|
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||||
|
|
||||||
|
interface ResponsiveLayoutEngineProps {
|
||||||
|
components: ComponentData[];
|
||||||
|
breakpoint: Breakpoint;
|
||||||
|
containerWidth: number;
|
||||||
|
screenWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 반응형 레이아웃 엔진
|
||||||
|
*
|
||||||
|
* 변환 로직:
|
||||||
|
* 1. Y 위치 기준으로 행(row)으로 그룹화
|
||||||
|
* 2. 각 행 내에서 X 위치 기준으로 정렬
|
||||||
|
* 3. 반응형 설정 적용 (order, gridColumns, hide)
|
||||||
|
* 4. CSS Grid로 렌더링
|
||||||
|
*/
|
||||||
|
export const ResponsiveLayoutEngine: React.FC<ResponsiveLayoutEngineProps> = ({
|
||||||
|
components,
|
||||||
|
breakpoint,
|
||||||
|
containerWidth,
|
||||||
|
screenWidth = 1920,
|
||||||
|
}) => {
|
||||||
|
// 1단계: 컴포넌트들을 Y 위치 기준으로 행(row)으로 그룹화
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const sortedComponents = [...components].sort((a, b) => a.position.y - b.position.y);
|
||||||
|
|
||||||
|
const rows: ComponentData[][] = [];
|
||||||
|
let currentRow: ComponentData[] = [];
|
||||||
|
let currentRowY = 0;
|
||||||
|
const ROW_THRESHOLD = 150; // 같은 행으로 간주할 Y 오차 범위 (px) - 여유있게 설정
|
||||||
|
|
||||||
|
sortedComponents.forEach((comp) => {
|
||||||
|
if (currentRow.length === 0) {
|
||||||
|
currentRow.push(comp);
|
||||||
|
currentRowY = comp.position.y;
|
||||||
|
} else if (Math.abs(comp.position.y - currentRowY) < ROW_THRESHOLD) {
|
||||||
|
currentRow.push(comp);
|
||||||
|
} else {
|
||||||
|
rows.push(currentRow);
|
||||||
|
currentRow = [comp];
|
||||||
|
currentRowY = comp.position.y;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentRow.length > 0) {
|
||||||
|
rows.push(currentRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}, [components]);
|
||||||
|
|
||||||
|
// 2단계: 각 행 내에서 X 위치 기준으로 정렬
|
||||||
|
const sortedRows = useMemo(() => {
|
||||||
|
return rows.map((row) => [...row].sort((a, b) => a.position.x - b.position.x));
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
// 3단계: 반응형 설정 적용
|
||||||
|
const responsiveComponents = useMemo(() => {
|
||||||
|
const result = sortedRows.flatMap((row, rowIndex) =>
|
||||||
|
row.map((comp, compIndex) => {
|
||||||
|
// 반응형 설정이 없으면 자동 생성
|
||||||
|
const compWithConfig = ensureResponsiveConfig(comp, screenWidth);
|
||||||
|
|
||||||
|
// 현재 브레이크포인트의 설정 가져오기 (같은 행의 컴포넌트 개수 전달)
|
||||||
|
const config = compWithConfig.responsiveConfig!.useSmartDefaults
|
||||||
|
? generateSmartDefaults(comp, screenWidth, row.length)[breakpoint]
|
||||||
|
: compWithConfig.responsiveConfig!.responsive?.[breakpoint];
|
||||||
|
|
||||||
|
const finalConfig = config || generateSmartDefaults(comp, screenWidth, row.length)[breakpoint];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...compWithConfig,
|
||||||
|
responsiveDisplay: finalConfig,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [sortedRows, breakpoint, screenWidth]);
|
||||||
|
|
||||||
|
// 4단계: 필터링 및 정렬
|
||||||
|
const visibleComponents = useMemo(() => {
|
||||||
|
return responsiveComponents
|
||||||
|
.filter((comp) => !comp.responsiveDisplay?.hide)
|
||||||
|
.sort((a, b) => (a.responsiveDisplay?.order || 0) - (b.responsiveDisplay?.order || 0));
|
||||||
|
}, [responsiveComponents]);
|
||||||
|
|
||||||
|
const gridColumns = BREAKPOINTS[breakpoint].columns;
|
||||||
|
|
||||||
|
// 각 행의 Y 위치를 추적
|
||||||
|
const rowsWithYPosition = useMemo(() => {
|
||||||
|
return sortedRows.map((row) => ({
|
||||||
|
components: row,
|
||||||
|
yPosition: Math.min(...row.map((c) => c.position.y)), // 행의 최소 Y 위치
|
||||||
|
}));
|
||||||
|
}, [sortedRows]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="responsive-container w-full" style={{ position: "relative" }}>
|
||||||
|
{rowsWithYPosition.map((row, rowIndex) => {
|
||||||
|
const rowComponents = visibleComponents.filter((vc) => row.components.some((rc) => rc.id === vc.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`row-${rowIndex}`}
|
||||||
|
className="responsive-grid w-full"
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
|
||||||
|
gap: "16px",
|
||||||
|
padding: "0 16px",
|
||||||
|
marginTop: rowIndex === 0 ? `${row.yPosition}px` : "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rowComponents.map((comp) => (
|
||||||
|
<div
|
||||||
|
key={comp.id}
|
||||||
|
className="responsive-grid-item"
|
||||||
|
style={{
|
||||||
|
gridColumn: `span ${comp.responsiveDisplay?.gridColumns || gridColumns}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DynamicComponentRenderer component={comp} isPreview={true} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
/**
|
||||||
|
* 반응형 설정 패널
|
||||||
|
*
|
||||||
|
* 컴포넌트별로 브레이크포인트마다 다른 레이아웃 설정 가능
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { ComponentData } from "@/types/screen-management";
|
||||||
|
import { Breakpoint, BREAKPOINTS, ResponsiveComponentConfig } from "@/types/responsive";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
|
interface ResponsiveConfigPanelProps {
|
||||||
|
component: ComponentData;
|
||||||
|
onUpdate: (config: ResponsiveComponentConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResponsiveConfigPanel: React.FC<ResponsiveConfigPanelProps> = ({ component, onUpdate }) => {
|
||||||
|
const [activeTab, setActiveTab] = useState<Breakpoint>("desktop");
|
||||||
|
|
||||||
|
const config = component.responsiveConfig || {
|
||||||
|
designerPosition: {
|
||||||
|
x: component.position.x,
|
||||||
|
y: component.position.y,
|
||||||
|
width: component.size.width,
|
||||||
|
height: component.size.height,
|
||||||
|
},
|
||||||
|
useSmartDefaults: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>반응형 설정</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* 스마트 기본값 토글 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="smartDefaults"
|
||||||
|
checked={config.useSmartDefaults}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
onUpdate({
|
||||||
|
...config,
|
||||||
|
useSmartDefaults: checked as boolean,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="smartDefaults">스마트 기본값 사용 (권장)</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
스마트 기본값은 컴포넌트 크기에 따라 자동으로 반응형 레이아웃을 생성합니다.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 수동 설정 */}
|
||||||
|
{!config.useSmartDefaults && (
|
||||||
|
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as Breakpoint)}>
|
||||||
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="desktop">데스크톱</TabsTrigger>
|
||||||
|
<TabsTrigger value="tablet">태블릿</TabsTrigger>
|
||||||
|
<TabsTrigger value="mobile">모바일</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value={activeTab} className="space-y-4">
|
||||||
|
{/* 그리드 컬럼 수 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>너비 (그리드 컬럼)</Label>
|
||||||
|
<Select
|
||||||
|
value={config.responsive?.[activeTab]?.gridColumns?.toString()}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
onUpdate({
|
||||||
|
...config,
|
||||||
|
responsive: {
|
||||||
|
...config.responsive,
|
||||||
|
[activeTab]: {
|
||||||
|
...config.responsive?.[activeTab],
|
||||||
|
gridColumns: parseInt(v),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="컬럼 수 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[...Array(BREAKPOINTS[activeTab].columns)].map((_, i) => {
|
||||||
|
const cols = i + 1;
|
||||||
|
const percent = ((cols / BREAKPOINTS[activeTab].columns) * 100).toFixed(0);
|
||||||
|
return (
|
||||||
|
<SelectItem key={cols} value={cols.toString()}>
|
||||||
|
{cols} / {BREAKPOINTS[activeTab].columns} ({percent}%)
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 표시 순서 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>표시 순서</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={config.responsive?.[activeTab]?.order || 1}
|
||||||
|
onChange={(e) => {
|
||||||
|
onUpdate({
|
||||||
|
...config,
|
||||||
|
responsive: {
|
||||||
|
...config.responsive,
|
||||||
|
[activeTab]: {
|
||||||
|
...config.responsive?.[activeTab],
|
||||||
|
order: parseInt(e.target.value),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-gray-500">작은 숫자가 먼저 표시됩니다.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 숨김 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`hide-${activeTab}`}
|
||||||
|
checked={config.responsive?.[activeTab]?.hide || false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
onUpdate({
|
||||||
|
...config,
|
||||||
|
responsive: {
|
||||||
|
...config.responsive,
|
||||||
|
[activeTab]: {
|
||||||
|
...config.responsive?.[activeTab],
|
||||||
|
hide: checked as boolean,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`hide-${activeTab}`}>
|
||||||
|
{activeTab === "desktop" ? "데스크톱" : activeTab === "tablet" ? "태블릿" : "모바일"}에서 숨김
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -48,6 +48,7 @@ import { DashboardConfigPanel } from "../config-panels/DashboardConfigPanel";
|
||||||
import { StatsCardConfigPanel } from "../config-panels/StatsCardConfigPanel";
|
import { StatsCardConfigPanel } from "../config-panels/StatsCardConfigPanel";
|
||||||
import { ProgressBarConfigPanel } from "../config-panels/ProgressBarConfigPanel";
|
import { ProgressBarConfigPanel } from "../config-panels/ProgressBarConfigPanel";
|
||||||
import { ChartConfigPanel } from "../config-panels/ChartConfigPanel";
|
import { ChartConfigPanel } from "../config-panels/ChartConfigPanel";
|
||||||
|
import { ResponsiveConfigPanel } from "./ResponsiveConfigPanel";
|
||||||
import { AlertConfigPanel } from "../config-panels/AlertConfigPanel";
|
import { AlertConfigPanel } from "../config-panels/AlertConfigPanel";
|
||||||
import { BadgeConfigPanel } from "../config-panels/BadgeConfigPanel";
|
import { BadgeConfigPanel } from "../config-panels/BadgeConfigPanel";
|
||||||
import { DynamicComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
|
import { DynamicComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
|
||||||
|
|
@ -707,6 +708,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
<TabsTrigger value="basic">기본</TabsTrigger>
|
<TabsTrigger value="basic">기본</TabsTrigger>
|
||||||
<TabsTrigger value="detail">상세</TabsTrigger>
|
<TabsTrigger value="detail">상세</TabsTrigger>
|
||||||
<TabsTrigger value="data">데이터</TabsTrigger>
|
<TabsTrigger value="data">데이터</TabsTrigger>
|
||||||
|
<TabsTrigger value="responsive">반응형</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
|
@ -719,6 +721,14 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
<TabsContent value="data" className="m-0 p-4">
|
<TabsContent value="data" className="m-0 p-4">
|
||||||
{renderDataTab()}
|
{renderDataTab()}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="responsive" className="m-0 p-4">
|
||||||
|
<ResponsiveConfigPanel
|
||||||
|
component={selectedComponent}
|
||||||
|
onUpdate={(config) => {
|
||||||
|
onUpdateProperty(selectedComponent.id, "responsiveConfig", config);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
/**
|
||||||
|
* 반응형 브레이크포인트 감지 훅
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Breakpoint, BREAKPOINTS } from "@/types/responsive";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 윈도우 크기에 따른 브레이크포인트 반환
|
||||||
|
*/
|
||||||
|
export function useBreakpoint(): Breakpoint {
|
||||||
|
const [breakpoint, setBreakpoint] = useState<Breakpoint>("desktop");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateBreakpoint = () => {
|
||||||
|
const width = window.innerWidth;
|
||||||
|
|
||||||
|
if (width >= BREAKPOINTS.desktop.minWidth) {
|
||||||
|
setBreakpoint("desktop");
|
||||||
|
} else if (width >= BREAKPOINTS.tablet.minWidth) {
|
||||||
|
setBreakpoint("tablet");
|
||||||
|
} else {
|
||||||
|
setBreakpoint("mobile");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기 실행
|
||||||
|
updateBreakpoint();
|
||||||
|
|
||||||
|
// 리사이즈 이벤트 리스너 등록
|
||||||
|
window.addEventListener("resize", updateBreakpoint);
|
||||||
|
|
||||||
|
return () => window.removeEventListener("resize", updateBreakpoint);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return breakpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 브레이크포인트의 컬럼 수 반환
|
||||||
|
*/
|
||||||
|
export function useGridColumns(): number {
|
||||||
|
const breakpoint = useBreakpoint();
|
||||||
|
return BREAKPOINTS[breakpoint].columns;
|
||||||
|
}
|
||||||
|
|
@ -76,6 +76,7 @@ export const componentRegistry = legacyComponentRegistry;
|
||||||
export interface DynamicComponentRendererProps {
|
export interface DynamicComponentRendererProps {
|
||||||
component: ComponentData;
|
component: ComponentData;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
|
isPreview?: boolean; // 반응형 모드 플래그
|
||||||
onClick?: (e?: React.MouseEvent) => void;
|
onClick?: (e?: React.MouseEvent) => void;
|
||||||
onDragStart?: (e: React.DragEvent) => void;
|
onDragStart?: (e: React.DragEvent) => void;
|
||||||
onDragEnd?: () => void;
|
onDragEnd?: () => void;
|
||||||
|
|
@ -105,6 +106,7 @@ export interface DynamicComponentRendererProps {
|
||||||
export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> = ({
|
export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> = ({
|
||||||
component,
|
component,
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
|
isPreview = false,
|
||||||
onClick,
|
onClick,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
|
|
@ -233,6 +235,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
// 설정 변경 핸들러 전달
|
// 설정 변경 핸들러 전달
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
refreshKey,
|
refreshKey,
|
||||||
|
// 반응형 모드 플래그 전달
|
||||||
|
isPreview,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 렌더러가 클래스인지 함수인지 확인
|
// 렌더러가 클래스인지 함수인지 확인
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
component,
|
component,
|
||||||
isDesignMode = false,
|
isDesignMode = false,
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
|
isPreview = false,
|
||||||
onClick,
|
onClick,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -52,16 +53,25 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 컴포넌트 스타일
|
// 컴포넌트 스타일
|
||||||
const componentStyle: React.CSSProperties = {
|
const componentStyle: React.CSSProperties = isPreview
|
||||||
position: "absolute",
|
? {
|
||||||
left: `${component.style?.positionX || 0}px`,
|
// 반응형 모드: position relative, width/height 100%
|
||||||
top: `${component.style?.positionY || 0}px`,
|
position: "relative",
|
||||||
width: `${component.style?.width || 1000}px`,
|
width: "100%",
|
||||||
height: `${component.style?.height || 600}px`,
|
height: `${component.style?.height || 600}px`,
|
||||||
zIndex: component.style?.positionZ || 1,
|
border: "1px solid #e5e7eb",
|
||||||
cursor: isDesignMode ? "pointer" : "default",
|
}
|
||||||
border: isSelected ? "2px solid #3b82f6" : "1px solid #e5e7eb",
|
: {
|
||||||
};
|
// 디자이너 모드: position absolute
|
||||||
|
position: "absolute",
|
||||||
|
left: `${component.style?.positionX || 0}px`,
|
||||||
|
top: `${component.style?.positionY || 0}px`,
|
||||||
|
width: `${component.style?.width || 1000}px`,
|
||||||
|
height: `${component.style?.height || 600}px`,
|
||||||
|
zIndex: component.style?.positionZ || 1,
|
||||||
|
cursor: isDesignMode ? "pointer" : "default",
|
||||||
|
border: isSelected ? "2px solid #3b82f6" : "1px solid #e5e7eb",
|
||||||
|
};
|
||||||
|
|
||||||
// 좌측 데이터 로드
|
// 좌측 데이터 로드
|
||||||
const loadLeftData = useCallback(async () => {
|
const loadLeftData = useCallback(async () => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
/**
|
||||||
|
* 반응형 스마트 기본값 생성 유틸리티
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ComponentData } from "@/types/screen-management";
|
||||||
|
import { ResponsiveComponentConfig, BREAKPOINTS } from "@/types/responsive";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트 크기에 따른 스마트 기본값 생성
|
||||||
|
*
|
||||||
|
* 로직:
|
||||||
|
* - 작은 컴포넌트 (너비 25% 이하): 모바일에서도 같은 너비 유지
|
||||||
|
* - 중간 컴포넌트 (너비 25-50%): 모바일에서 전체 너비로 확장
|
||||||
|
* - 큰 컴포넌트 (너비 50% 이상): 모든 디바이스에서 전체 너비
|
||||||
|
*/
|
||||||
|
export function generateSmartDefaults(
|
||||||
|
component: ComponentData,
|
||||||
|
screenWidth: number = 1920,
|
||||||
|
rowComponentCount: number = 1, // 같은 행에 있는 컴포넌트 개수
|
||||||
|
): ResponsiveComponentConfig["responsive"] {
|
||||||
|
// 특정 컴포넌트는 항상 전체 너비 (split-panel-layout, datatable 등)
|
||||||
|
const fullWidthComponents = ["split-panel-layout", "datatable", "data-table"];
|
||||||
|
const componentType = (component as any).componentType || component.type;
|
||||||
|
|
||||||
|
if (fullWidthComponents.includes(componentType)) {
|
||||||
|
return {
|
||||||
|
desktop: {
|
||||||
|
gridColumns: 12, // 전체 너비
|
||||||
|
order: 1,
|
||||||
|
hide: false,
|
||||||
|
},
|
||||||
|
tablet: {
|
||||||
|
gridColumns: 8, // 전체 너비
|
||||||
|
order: 1,
|
||||||
|
hide: false,
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
gridColumns: 4, // 전체 너비
|
||||||
|
order: 1,
|
||||||
|
hide: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentWidthPercent = (component.size.width / screenWidth) * 100;
|
||||||
|
|
||||||
|
// 같은 행에 여러 컴포넌트가 있으면 컬럼을 나눔
|
||||||
|
if (rowComponentCount > 1) {
|
||||||
|
const desktopColumns = Math.round(12 / rowComponentCount);
|
||||||
|
const tabletColumns = Math.round(8 / rowComponentCount);
|
||||||
|
const mobileColumns = 4; // 모바일에서는 항상 전체 너비
|
||||||
|
|
||||||
|
return {
|
||||||
|
desktop: {
|
||||||
|
gridColumns: desktopColumns,
|
||||||
|
order: 1,
|
||||||
|
hide: false,
|
||||||
|
},
|
||||||
|
tablet: {
|
||||||
|
gridColumns: tabletColumns,
|
||||||
|
order: 1,
|
||||||
|
hide: false,
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
gridColumns: mobileColumns,
|
||||||
|
order: 1,
|
||||||
|
hide: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 작은 컴포넌트 (25% 이하)
|
||||||
|
else if (componentWidthPercent <= 25) {
|
||||||
|
return {
|
||||||
|
desktop: {
|
||||||
|
gridColumns: 3, // 12컬럼 중 3개 (25%)
|
||||||
|
order: 1,
|
||||||
|
hide: false,
|
||||||
|
},
|
||||||
|
tablet: {
|
||||||
|
gridColumns: 2, // 8컬럼 중 2개 (25%)
|
||||||
|
order: 1,
|
||||||
|
hide: false,
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
gridColumns: 1, // 4컬럼 중 1개 (25%)
|
||||||
|
order: 1,
|
||||||
|
hide: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 중간 컴포넌트 (25-50%)
|
||||||
|
else if (componentWidthPercent <= 50) {
|
||||||
|
return {
|
||||||
|
desktop: {
|
||||||
|
gridColumns: 6, // 12컬럼 중 6개 (50%)
|
||||||
|
order: 1,
|
||||||
|
hide: false,
|
||||||
|
},
|
||||||
|
tablet: {
|
||||||
|
gridColumns: 4, // 8컬럼 중 4개 (50%)
|
||||||
|
order: 1,
|
||||||
|
hide: false,
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
gridColumns: 4, // 4컬럼 전체 (100%)
|
||||||
|
order: 1,
|
||||||
|
hide: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 큰 컴포넌트 (50% 이상)
|
||||||
|
else {
|
||||||
|
return {
|
||||||
|
desktop: {
|
||||||
|
gridColumns: 12, // 전체 너비
|
||||||
|
order: 1,
|
||||||
|
hide: false,
|
||||||
|
},
|
||||||
|
tablet: {
|
||||||
|
gridColumns: 8, // 전체 너비
|
||||||
|
order: 1,
|
||||||
|
hide: false,
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
gridColumns: 4, // 전체 너비
|
||||||
|
order: 1,
|
||||||
|
hide: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트에 반응형 설정이 없을 경우 자동 생성
|
||||||
|
*/
|
||||||
|
export function ensureResponsiveConfig(component: ComponentData, screenWidth?: number): ComponentData {
|
||||||
|
if (component.responsiveConfig) {
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...component,
|
||||||
|
responsiveConfig: {
|
||||||
|
designerPosition: {
|
||||||
|
x: component.position.x,
|
||||||
|
y: component.position.y,
|
||||||
|
width: component.size.width,
|
||||||
|
height: component.size.height,
|
||||||
|
},
|
||||||
|
useSmartDefaults: true,
|
||||||
|
responsive: generateSmartDefaults(component, screenWidth),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
/**
|
||||||
|
* 반응형 레이아웃 시스템 타입 정의
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 브레이크포인트 타입 정의
|
||||||
|
*/
|
||||||
|
export type Breakpoint = "desktop" | "tablet" | "mobile";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 브레이크포인트별 설정
|
||||||
|
*/
|
||||||
|
export interface BreakpointConfig {
|
||||||
|
minWidth: number; // 최소 너비 (px)
|
||||||
|
maxWidth?: number; // 최대 너비 (px)
|
||||||
|
columns: number; // 그리드 컬럼 수
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 브레이크포인트 설정
|
||||||
|
*/
|
||||||
|
export const BREAKPOINTS: Record<Breakpoint, BreakpointConfig> = {
|
||||||
|
desktop: {
|
||||||
|
minWidth: 1200,
|
||||||
|
columns: 12,
|
||||||
|
},
|
||||||
|
tablet: {
|
||||||
|
minWidth: 768,
|
||||||
|
maxWidth: 1199,
|
||||||
|
columns: 8,
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
minWidth: 0,
|
||||||
|
maxWidth: 767,
|
||||||
|
columns: 4,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 브레이크포인트별 반응형 설정
|
||||||
|
*/
|
||||||
|
export interface ResponsiveBreakpointConfig {
|
||||||
|
gridColumns?: number; // 차지할 컬럼 수 (1-12)
|
||||||
|
order?: number; // 정렬 순서
|
||||||
|
hide?: boolean; // 숨김 여부
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트별 반응형 설정
|
||||||
|
*/
|
||||||
|
export interface ResponsiveComponentConfig {
|
||||||
|
// 기본값 (디자이너에서 설정한 절대 위치)
|
||||||
|
designerPosition: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 반응형 설정 (선택적)
|
||||||
|
responsive?: {
|
||||||
|
desktop?: ResponsiveBreakpointConfig;
|
||||||
|
tablet?: ResponsiveBreakpointConfig;
|
||||||
|
mobile?: ResponsiveBreakpointConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 스마트 기본값 사용 여부
|
||||||
|
useSmartDefaults?: boolean;
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
isWebType,
|
isWebType,
|
||||||
} from "./unified-core";
|
} from "./unified-core";
|
||||||
import { ColumnSpanPreset } from "@/lib/constants/columnSpans";
|
import { ColumnSpanPreset } from "@/lib/constants/columnSpans";
|
||||||
|
import { ResponsiveComponentConfig } from "./responsive";
|
||||||
|
|
||||||
// ===== 기본 컴포넌트 인터페이스 =====
|
// ===== 기본 컴포넌트 인터페이스 =====
|
||||||
|
|
||||||
|
|
@ -50,6 +51,10 @@ export interface BaseComponent {
|
||||||
componentConfig?: any; // 컴포넌트별 설정
|
componentConfig?: any; // 컴포넌트별 설정
|
||||||
componentType?: string; // 새 컴포넌트 시스템의 ID
|
componentType?: string; // 새 컴포넌트 시스템의 ID
|
||||||
webTypeConfig?: WebTypeConfig; // 웹타입별 설정
|
webTypeConfig?: WebTypeConfig; // 웹타입별 설정
|
||||||
|
|
||||||
|
// 반응형 설정
|
||||||
|
responsiveConfig?: ResponsiveComponentConfig;
|
||||||
|
responsiveDisplay?: any; // 런타임에 추가되는 임시 필드
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue