From 368d641ae85dea2e2ac4c934646d4c4034df418c Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 3 Feb 2026 11:25:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(pop-designer):=20POP=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B4=EB=84=88=20v2.0=20-=204=EA=B0=80=EC=A7=80=20=EB=94=94?= =?UTF-8?q?=EB=B0=94=EC=9D=B4=EC=8A=A4=20=EB=AA=A8=EB=93=9C=20=EB=B0=8F=20?= =?UTF-8?q?=EC=BA=94=EB=B2=84=EC=8A=A4=20UX=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - v2 레이아웃 데이터 구조 도입 (4모드별 별도 레이아웃 + 공유 컴포넌트 정의) - tablet_landscape, tablet_portrait, mobile_landscape, mobile_portrait - sections/components를 Record 객체로 관리 - v1 → v2 자동 마이그레이션 지원 - 캔버스 UX 개선 - 줌 기능 (30%~150%, 마우스 휠 + 버튼) - 패닝 기능 (중앙 마우스, Space+드래그, 배경 드래그) - 2개 캔버스 동시 표시 (가로/세로 모드) - Delete 키로 섹션/컴포넌트 삭제 기능 추가 - layout.sections 순회하여 componentIds에서 부모 섹션 찾는 방식 - 미리보기 v2 레이아웃 호환성 수정 - Object.keys(layout.sections).length 체크로 변경 수정 파일: PopDesigner.tsx, PopCanvas.tsx, SectionGridV2.tsx(신규), types/pop-layout.ts, PopPanel.tsx, PopScreenPreview.tsx, PopCategoryTree.tsx, screenManagementService.ts --- .../src/services/screenManagementService.ts | 139 +++- .../components/pop/designer/PopCanvas.tsx | 592 +++++++++++----- .../components/pop/designer/PopDesigner.tsx | 359 ++++++---- .../components/pop/designer/SectionGridV2.tsx | 373 ++++++++++ .../pop/designer/panels/PopPanel.tsx | 291 ++++---- .../pop/designer/types/pop-layout.ts | 657 +++++++++++++++++- .../pop/management/PopCategoryTree.tsx | 2 + .../pop/management/PopScreenPreview.tsx | 72 +- 8 files changed, 1919 insertions(+), 566 deletions(-) create mode 100644 frontend/components/pop/designer/SectionGridV2.tsx diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 7dfab16d..88f9f25f 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -4710,12 +4710,89 @@ export class ScreenManagementService { // ======================================== // POP 레이아웃 관리 (모바일/태블릿) + // v2.0: 4모드 레이아웃 지원 (태블릿 가로/세로, 모바일 가로/세로) // ======================================== + /** + * POP v1 → v2 마이그레이션 (백엔드) + * - 단일 sections 배열 → 4모드별 layouts + 공유 sections/components + */ + private migratePopV1ToV2(v1Data: any): any { + console.log("POP v1 → v2 마이그레이션 시작"); + + // 기본 v2 구조 + const v2Data: any = { + version: "pop-2.0", + layouts: { + tablet_landscape: { sectionPositions: {}, componentPositions: {} }, + tablet_portrait: { sectionPositions: {}, componentPositions: {} }, + mobile_landscape: { sectionPositions: {}, componentPositions: {} }, + mobile_portrait: { sectionPositions: {}, componentPositions: {} }, + }, + sections: {}, + components: {}, + dataFlow: { + sectionConnections: [], + }, + settings: { + touchTargetMin: 48, + mode: "normal", + canvasGrid: v1Data.canvasGrid || { columns: 24, rowHeight: 20, gap: 4 }, + }, + metadata: v1Data.metadata, + }; + + // v1 섹션 배열 처리 + const sections = v1Data.sections || []; + const modeKeys = ["tablet_landscape", "tablet_portrait", "mobile_landscape", "mobile_portrait"]; + + for (const section of sections) { + // 섹션 정의 생성 + v2Data.sections[section.id] = { + id: section.id, + label: section.label, + componentIds: (section.components || []).map((c: any) => c.id), + innerGrid: section.innerGrid || { columns: 3, rows: 3, gap: 4 }, + style: section.style, + }; + + // 섹션 위치 복사 (4모드 모두 동일) + const sectionPos = section.grid || { col: 1, row: 1, colSpan: 3, rowSpan: 4 }; + for (const mode of modeKeys) { + v2Data.layouts[mode].sectionPositions[section.id] = { ...sectionPos }; + } + + // 컴포넌트별 처리 + for (const comp of section.components || []) { + // 컴포넌트 정의 생성 + v2Data.components[comp.id] = { + id: comp.id, + type: comp.type, + label: comp.label, + dataBinding: comp.dataBinding, + style: comp.style, + config: comp.config, + }; + + // 컴포넌트 위치 복사 (4모드 모두 동일) + const compPos = comp.grid || { col: 1, row: 1, colSpan: 1, rowSpan: 1 }; + for (const mode of modeKeys) { + v2Data.layouts[mode].componentPositions[comp.id] = { ...compPos }; + } + } + } + + const sectionCount = Object.keys(v2Data.sections).length; + const componentCount = Object.keys(v2Data.components).length; + console.log(`POP v1 → v2 마이그레이션 완료: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`); + + return v2Data; + } + /** * POP 레이아웃 조회 * - screen_layouts_pop 테이블에서 화면당 1개 레코드 조회 - * - V2와 동일한 로직, 테이블명만 다름 + * - v1 데이터는 자동으로 v2로 마이그레이션하여 반환 */ async getLayoutPop( screenId: number, @@ -4792,16 +4869,32 @@ export class ScreenManagementService { return null; } - console.log( - `POP 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`, - ); - return layout.layout_data; + const layoutData = layout.layout_data; + + // v1 → v2 자동 마이그레이션 + if (layoutData && layoutData.version === "pop-1.0") { + console.log("POP v1 레이아웃 감지, v2로 마이그레이션"); + return this.migratePopV1ToV2(layoutData); + } + + // v2 또는 버전 태그 없는 경우 (버전 태그 없으면 sections 구조 확인) + if (layoutData && !layoutData.version && layoutData.sections && Array.isArray(layoutData.sections)) { + console.log("버전 태그 없는 v1 레이아웃 감지, v2로 마이그레이션"); + return this.migratePopV1ToV2({ ...layoutData, version: "pop-1.0" }); + } + + // v2 레이아웃 그대로 반환 + const sectionCount = layoutData?.sections ? Object.keys(layoutData.sections).length : 0; + const componentCount = layoutData?.components ? Object.keys(layoutData.components).length : 0; + console.log(`POP v2 레이아웃 로드 완료: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`); + + return layoutData; } /** * POP 레이아웃 저장 * - screen_layouts_pop 테이블에 화면당 1개 레코드 저장 - * - V2와 동일한 로직, 테이블명만 다름 + * - v2 형식으로 저장 (version: "pop-2.0") */ async saveLayoutPop( screenId: number, @@ -4811,7 +4904,18 @@ export class ScreenManagementService { ): Promise { console.log(`=== POP 레이아웃 저장 시작 ===`); console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`); - console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`); + + // v2 구조 확인 + const isV2 = layoutData.version === "pop-2.0" || + (layoutData.layouts && layoutData.sections && layoutData.components); + + if (isV2) { + const sectionCount = Object.keys(layoutData.sections || {}).length; + const componentCount = Object.keys(layoutData.components || {}).length; + console.log(`v2 레이아웃: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`); + } else { + console.log(`v1 레이아웃 (섹션 수: ${layoutData.sections?.length || 0})`); + } // 권한 확인 const screens = await query<{ company_code: string | null }>( @@ -4829,11 +4933,20 @@ export class ScreenManagementService { throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다."); } - // 버전 정보 추가 (프론트엔드 pop-1.0과 통일) - const dataToSave = { - version: "pop-1.0", - ...layoutData - }; + // 버전 정보 보장 (v2 우선, v1은 프론트엔드에서 마이그레이션 후 저장 권장) + let dataToSave: any; + if (isV2) { + dataToSave = { + ...layoutData, + version: "pop-2.0", + }; + } else { + // v1 형식으로 저장 (하위 호환) + dataToSave = { + version: "pop-1.0", + ...layoutData, + }; + } // UPSERT (있으면 업데이트, 없으면 삽입) await query( @@ -4844,7 +4957,7 @@ export class ScreenManagementService { [screenId, companyCode, JSON.stringify(dataToSave), userId || null], ); - console.log(`POP 레이아웃 저장 완료`); + console.log(`POP 레이아웃 저장 완료 (version: ${dataToSave.version})`); } /** diff --git a/frontend/components/pop/designer/PopCanvas.tsx b/frontend/components/pop/designer/PopCanvas.tsx index 3effc560..b68f806d 100644 --- a/frontend/components/pop/designer/PopCanvas.tsx +++ b/frontend/components/pop/designer/PopCanvas.tsx @@ -1,162 +1,283 @@ "use client"; -import { useCallback, useMemo, useRef } from "react"; +import { useCallback, useMemo, useRef, useState, useEffect } from "react"; import { useDrop } from "react-dnd"; import GridLayout, { Layout } from "react-grid-layout"; import { cn } from "@/lib/utils"; import { - PopLayoutData, - PopSectionData, - PopComponentData, + PopLayoutDataV2, + PopLayoutModeKey, PopComponentType, GridPosition, + MODE_RESOLUTIONS, } from "./types/pop-layout"; -import { DND_ITEM_TYPES, DragItemSection, DragItemComponent } from "./panels/PopPanel"; -import { GripVertical, Trash2 } from "lucide-react"; +import { DND_ITEM_TYPES, DragItemSection } from "./panels/PopPanel"; +import { GripVertical, Trash2, ZoomIn, ZoomOut, Maximize2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { SectionGrid } from "./SectionGrid"; +import { SectionGridV2 } from "./SectionGridV2"; import "react-grid-layout/css/styles.css"; import "react-resizable/css/styles.css"; +// ======================================== +// 타입 정의 +// ======================================== type DeviceType = "mobile" | "tablet"; -// 디바이스별 캔버스 크기 (dp) -const DEVICE_SIZES = { - mobile: { - portrait: { width: 360, height: 640 }, - landscape: { width: 640, height: 360 }, - }, - tablet: { - portrait: { width: 768, height: 1024 }, - landscape: { width: 1024, height: 768 }, - }, -} as const; +// 모드별 라벨 +const MODE_LABELS: Record = { + tablet_landscape: "태블릿 가로", + tablet_portrait: "태블릿 세로", + mobile_landscape: "모바일 가로", + mobile_portrait: "모바일 세로", +}; +// ======================================== +// Props +// ======================================== interface PopCanvasProps { - layout: PopLayoutData; + layout: PopLayoutDataV2; activeDevice: DeviceType; - showBothDevices: boolean; - isLandscape: boolean; + activeModeKey: PopLayoutModeKey; + onModeKeyChange: (modeKey: PopLayoutModeKey) => void; selectedSectionId: string | null; selectedComponentId: string | null; onSelectSection: (id: string | null) => void; onSelectComponent: (id: string | null) => void; - onUpdateSection: (id: string, updates: Partial) => void; + onUpdateSectionPosition: (sectionId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void; + onUpdateComponentPosition: (componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void; onDeleteSection: (id: string) => void; - onLayoutChange: (sections: PopSectionData[]) => void; onDropSection: (gridPosition: GridPosition) => void; onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void; - onUpdateComponent: (sectionId: string, componentId: string, updates: Partial) => void; onDeleteComponent: (sectionId: string, componentId: string) => void; } +// ======================================== +// 메인 컴포넌트 +// ======================================== export function PopCanvas({ layout, activeDevice, - showBothDevices, - isLandscape, + activeModeKey, + onModeKeyChange, selectedSectionId, selectedComponentId, onSelectSection, onSelectComponent, - onUpdateSection, + onUpdateSectionPosition, + onUpdateComponentPosition, onDeleteSection, - onLayoutChange, onDropSection, onDropComponent, - onUpdateComponent, onDeleteComponent, }: PopCanvasProps) { - const { canvasGrid, sections } = layout; + const { settings, sections, components, layouts } = layout; + const canvasGrid = settings.canvasGrid; - // GridLayout용 레이아웃 변환 - const gridLayoutItems: Layout[] = useMemo(() => { - return sections.map((section) => ({ - i: section.id, - x: section.grid.col - 1, - y: section.grid.row - 1, - w: section.grid.colSpan, - h: section.grid.rowSpan, - minW: 2, // 최소 너비 2칸 - minH: 1, // 최소 높이 1행 (20px) - 헤더만 보임 - })); - }, [sections]); + // 줌 상태 (0.3 ~ 1.0 범위) + const [canvasScale, setCanvasScale] = useState(0.6); - // 드래그/리사이즈 완료 핸들러 (onDragStop, onResizeStop 사용) - const handleDragResizeStop = useCallback( - (layout: Layout[], oldItem: Layout, newItem: Layout) => { - const section = sections.find((s) => s.id === newItem.i); - if (!section) return; + // 패닝 상태 + const [isPanning, setIsPanning] = useState(false); + const [panStart, setPanStart] = useState({ x: 0, y: 0 }); + const [isSpacePressed, setIsSpacePressed] = useState(false); // Space 키 눌림 상태 + const containerRef = useRef(null); - const newGrid: GridPosition = { - col: newItem.x + 1, - row: newItem.y + 1, - colSpan: newItem.w, - rowSpan: newItem.h, - }; + // 줌 인 (최대 1.5로 증가) + const handleZoomIn = () => { + setCanvasScale((prev) => Math.min(1.5, prev + 0.1)); + }; - // 변경된 경우에만 업데이트 - if ( - section.grid.col !== newGrid.col || - section.grid.row !== newGrid.row || - section.grid.colSpan !== newGrid.colSpan || - section.grid.rowSpan !== newGrid.rowSpan - ) { - const updatedSections = sections.map((s) => - s.id === newItem.i ? { ...s, grid: newGrid } : s - ); - onLayoutChange(updatedSections); + // 줌 아웃 (최소 0.3) + const handleZoomOut = () => { + setCanvasScale((prev) => Math.max(0.3, prev - 0.1)); + }; + + // 맞춤 (1.0) + const handleZoomFit = () => { + setCanvasScale(1.0); + }; + + // 패닝 시작 (중앙 마우스 버튼 또는 배경 영역 드래그) + const handlePanStart = (e: React.MouseEvent) => { + // 중앙 마우스 버튼(휠 버튼, button === 1) 또는 Space 키 누른 상태 + // 또는 내부 컨테이너(스크롤 영역) 직접 클릭 시 + const isMiddleButton = e.button === 1; + const isScrollAreaClick = (e.target as HTMLElement).classList.contains("canvas-scroll-area"); + + if (isMiddleButton || isSpacePressed || isScrollAreaClick) { + setIsPanning(true); + setPanStart({ x: e.clientX, y: e.clientY }); + e.preventDefault(); + } + }; + + // 패닝 중 + const handlePanMove = (e: React.MouseEvent) => { + if (!isPanning || !containerRef.current) return; + + const deltaX = e.clientX - panStart.x; + const deltaY = e.clientY - panStart.y; + + containerRef.current.scrollLeft -= deltaX; + containerRef.current.scrollTop -= deltaY; + + setPanStart({ x: e.clientX, y: e.clientY }); + }; + + // 패닝 종료 + const handlePanEnd = () => { + setIsPanning(false); + }; + + // 마우스 휠 줌 (0.3 ~ 1.5 범위) + const handleWheel = useCallback((e: React.WheelEvent) => { + e.preventDefault(); // 브라우저 스크롤 방지 + + const delta = e.deltaY > 0 ? -0.1 : 0.1; // 위로 스크롤: 줌인, 아래로 스크롤: 줌아웃 + setCanvasScale((prev) => Math.max(0.3, Math.min(1.5, prev + delta))); + }, []); + + // Space 키 감지 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.code === "Space" && !isSpacePressed) { + setIsSpacePressed(true); } - }, - [sections, onLayoutChange] - ); + }; - // 디바이스 프레임 렌더링 - const renderDeviceFrame = (device: DeviceType) => { - const orientation = isLandscape ? "landscape" : "portrait"; - const size = DEVICE_SIZES[device][orientation]; - const isActive = device === activeDevice; + const handleKeyUp = (e: KeyboardEvent) => { + if (e.code === "Space") { + setIsSpacePressed(false); + } + }; + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + }; + }, [isSpacePressed]); + + // 초기 로드 시 캔버스를 중앙으로 스크롤 + useEffect(() => { + if (containerRef.current) { + const container = containerRef.current; + // 약간의 딜레이 후 중앙으로 스크롤 (DOM이 완전히 렌더링된 후) + const timer = setTimeout(() => { + const scrollX = (container.scrollWidth - container.clientWidth) / 2; + const scrollY = (container.scrollHeight - container.clientHeight) / 2; + container.scrollTo(scrollX, scrollY); + }, 100); + return () => clearTimeout(timer); + } + }, [activeDevice]); // 디바이스 변경 시 재중앙화 + + // 현재 디바이스의 가로/세로 모드 키 + const landscapeModeKey: PopLayoutModeKey = activeDevice === "tablet" + ? "tablet_landscape" + : "mobile_landscape"; + const portraitModeKey: PopLayoutModeKey = activeDevice === "tablet" + ? "tablet_portrait" + : "mobile_portrait"; + + // 단일 캔버스 프레임 렌더링 + const renderDeviceFrame = (modeKey: PopLayoutModeKey) => { + const resolution = MODE_RESOLUTIONS[modeKey]; + const isActive = modeKey === activeModeKey; + const modeLayout = layouts[modeKey]; + + // 이 모드의 섹션 위치 목록 + const sectionPositions = modeLayout.sectionPositions; + const sectionIds = Object.keys(sectionPositions); + + // GridLayout용 레이아웃 아이템 생성 + const gridLayoutItems: Layout[] = sectionIds.map((sectionId) => { + const pos = sectionPositions[sectionId]; + return { + i: sectionId, + x: pos.col - 1, + y: pos.row - 1, + w: pos.colSpan, + h: pos.rowSpan, + minW: 2, + minH: 1, + }; + }); const cols = canvasGrid.columns; const rowHeight = canvasGrid.rowHeight; const margin: [number, number] = [canvasGrid.gap, canvasGrid.gap]; - const sizeLabel = `${size.width}x${size.height}`; - const deviceLabel = - device === "mobile" ? `모바일 (${sizeLabel})` : `태블릿 (${sizeLabel})`; + const sizeLabel = `${resolution.width}x${resolution.height}`; + const modeLabel = `${MODE_LABELS[modeKey]} (${sizeLabel})`; + + // 드래그/리사이즈 완료 핸들러 + const handleDragResizeStop = ( + layoutItems: Layout[], + oldItem: Layout, + newItem: Layout + ) => { + const newPos: GridPosition = { + col: newItem.x + 1, + row: newItem.y + 1, + colSpan: newItem.w, + rowSpan: newItem.h, + }; + onUpdateSectionPosition(newItem.i, newPos, modeKey); + }; return (
{ + if (!isActive) { + onModeKeyChange(modeKey); + } }} > - {/* 디바이스 라벨 */} + {/* 모드 라벨 */}
- {deviceLabel} + {modeLabel}
+ {/* 활성 표시 배지 */} + {isActive && ( +
+ 편집 중 +
+ )} + {/* 드롭 영역 */} onUpdateComponentPosition(compId, pos, modeKey)} onDeleteSection={onDeleteSection} - onUpdateComponent={onUpdateComponent} onDeleteComponent={onDeleteComponent} />
@@ -174,28 +295,91 @@ export function PopCanvas({ }; return ( -
- {showBothDevices ? ( - <> - {renderDeviceFrame("tablet")} - {renderDeviceFrame("mobile")} - - ) : ( - renderDeviceFrame(activeDevice) - )} +
+ {/* 줌 컨트롤 바 */} +
+ + 줌: {Math.round(canvasScale * 100)}% + + + + +
+ + {/* 캔버스 영역 (패닝 가능) */} +
+ {/* 스크롤 가능한 큰 영역 - 빈 공간 클릭 시 패닝 가능 */} +
+ {/* 가로 모드 캔버스 */} + {renderDeviceFrame(landscapeModeKey)} + + {/* 세로 모드 캔버스 */} + {renderDeviceFrame(portraitModeKey)} +
+
); } -// 캔버스 드롭 영역 +// ======================================== +// 캔버스 드롭 영역 컴포넌트 +// ======================================== interface CanvasDropZoneProps { - device: DeviceType; + modeKey: PopLayoutModeKey; isActive: boolean; - size: { width: number; height: number }; + resolution: { width: number; height: number }; + scale: number; cols: number; rowHeight: number; margin: [number, number]; - sections: PopSectionData[]; + sections: PopLayoutDataV2["sections"]; + components: PopLayoutDataV2["components"]; + sectionPositions: Record; + componentPositions: Record; gridLayoutItems: Layout[]; selectedSectionId: string | null; selectedComponentId: string | null; @@ -204,19 +388,23 @@ interface CanvasDropZoneProps { onDragResizeStop: (layout: Layout[], oldItem: Layout, newItem: Layout) => void; onDropSection: (gridPosition: GridPosition) => void; onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void; + onUpdateComponentPosition: (componentId: string, position: GridPosition) => void; onDeleteSection: (id: string) => void; - onUpdateComponent: (sectionId: string, componentId: string, updates: Partial) => void; onDeleteComponent: (sectionId: string, componentId: string) => void; } function CanvasDropZone({ - device, + modeKey, isActive, - size, + resolution, + scale, cols, rowHeight, margin, sections, + components, + sectionPositions, + componentPositions, gridLayoutItems, selectedSectionId, selectedComponentId, @@ -225,55 +413,70 @@ function CanvasDropZone({ onDragResizeStop, onDropSection, onDropComponent, + onUpdateComponentPosition, onDeleteSection, - onUpdateComponent, onDeleteComponent, }: CanvasDropZoneProps) { const dropRef = useRef(null); + // 스케일 적용된 크기 + const scaledWidth = resolution.width * scale; + const scaledHeight = resolution.height * scale; + // 섹션 드롭 핸들러 - const [{ isOver, canDrop }, drop] = useDrop(() => ({ - accept: DND_ITEM_TYPES.SECTION, - drop: (item: DragItemSection, monitor) => { - if (!isActive) return; - - // 드롭 위치 계산 - const clientOffset = monitor.getClientOffset(); - if (!clientOffset || !dropRef.current) return; + const [{ isOver, canDrop }, drop] = useDrop( + () => ({ + accept: DND_ITEM_TYPES.SECTION, + drop: (item: DragItemSection, monitor) => { + if (!isActive) return; - const dropRect = dropRef.current.getBoundingClientRect(); - const x = clientOffset.x - dropRect.left; - const y = clientOffset.y - dropRect.top; + const clientOffset = monitor.getClientOffset(); + if (!clientOffset || !dropRef.current) return; - // 그리드 위치 계산 - const colWidth = (size.width - 16) / cols; - const col = Math.max(1, Math.min(cols, Math.floor(x / colWidth) + 1)); - const row = Math.max(1, Math.floor(y / rowHeight) + 1); + const dropRect = dropRef.current.getBoundingClientRect(); + // 스케일 보정 + const x = (clientOffset.x - dropRect.left) / scale; + const y = (clientOffset.y - dropRect.top) / scale; - onDropSection({ - col, - row, - colSpan: 3, // 기본 너비 - rowSpan: 4, // 기본 높이 (20px * 4 = 80px) - }); - }, - canDrop: () => isActive, - collect: (monitor) => ({ - isOver: monitor.isOver(), - canDrop: monitor.canDrop(), + // 그리드 위치 계산 + const colWidth = (resolution.width - 16) / cols; + const col = Math.max(1, Math.min(cols, Math.floor(x / colWidth) + 1)); + const row = Math.max(1, Math.floor(y / (rowHeight * scale)) + 1); + + onDropSection({ + col, + row, + colSpan: 3, + rowSpan: 4, + }); + }, + canDrop: () => isActive, + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), }), - }), [isActive, size, cols, rowHeight, onDropSection]); + [isActive, resolution, scale, cols, rowHeight, onDropSection] + ); - // ref 결합 drop(dropRef); + const sectionIds = Object.keys(sectionPositions); + return (
{ if (e.target === e.currentTarget) { onSelectSection(null); @@ -281,13 +484,13 @@ function CanvasDropZone({ } }} > - {sections.length > 0 ? ( + {sectionIds.length > 0 ? ( - {sections.map((section) => ( -
{ - e.stopPropagation(); - onSelectSection(section.id); - }} - > - {/* 섹션 헤더 - 고정 높이 */} -
-
- - - {section.label || `섹션`} - -
- {selectedSectionId === section.id && ( - - )} -
+ {sectionIds.map((sectionId) => { + const sectionDef = sections[sectionId]; + if (!sectionDef) return null; - {/* 섹션 내부 - 나머지 영역 전부 차지 */} -
- + return ( +
{ + e.stopPropagation(); + onSelectSection(sectionId); + }} + > + {/* 섹션 헤더 */} +
+
+ + + {sectionDef.label || "섹션"} + +
+ {selectedSectionId === sectionId && isActive && ( + + )} +
+ + {/* 섹션 내부 - 컴포넌트들 */} +
+ +
-
- ))} + ); + })} ) : (
{isOver && canDrop ? "여기에 섹션을 놓으세요" - : "왼쪽 패널에서 섹션을 드래그하세요"} + : isActive + ? "왼쪽 패널에서 섹션을 드래그하세요" + : "클릭하여 편집 모드로 전환"}
)}
); } - diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index 2d6df485..c0a7269e 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -1,9 +1,9 @@ "use client"; -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useMemo } from "react"; import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; -import { ArrowLeft, Save, Smartphone, Tablet, Columns2, RotateCcw } from "lucide-react"; +import { ArrowLeft, Save, Smartphone, Tablet } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { @@ -16,84 +16,130 @@ import { toast } from "sonner"; import { PopCanvas } from "./PopCanvas"; import { PopPanel } from "./panels/PopPanel"; import { - PopLayoutData, - PopSectionData, - PopComponentData, + PopLayoutDataV2, + PopLayoutModeKey, PopComponentType, - createEmptyPopLayout, - createPopSection, - createPopComponent, GridPosition, + PopSectionDefinition, + createEmptyPopLayoutV2, + createSectionDefinition, + createComponentDefinition, + ensureV2Layout, + addSectionToV2Layout, + addComponentToV2Layout, + removeSectionFromV2Layout, + removeComponentFromV2Layout, + updateSectionPositionInMode, + updateComponentPositionInMode, + isV2Layout, } from "./types/pop-layout"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; +// ======================================== // 디바이스 타입 +// ======================================== type DeviceType = "mobile" | "tablet"; +/** + * 디바이스 + 방향 → 모드 키 변환 + */ +const getModeKey = (device: DeviceType, isLandscape: boolean): PopLayoutModeKey => { + if (device === "tablet") { + return isLandscape ? "tablet_landscape" : "tablet_portrait"; + } + return isLandscape ? "mobile_landscape" : "mobile_portrait"; +}; + +// ======================================== +// Props +// ======================================== interface PopDesignerProps { selectedScreen: ScreenDefinition; onBackToList: () => void; onScreenUpdate?: (updatedScreen: Partial) => void; } +// ======================================== +// 메인 컴포넌트 +// ======================================== export default function PopDesigner({ selectedScreen, onBackToList, onScreenUpdate, }: PopDesignerProps) { - // 레이아웃 상태 - const [layout, setLayout] = useState(createEmptyPopLayout()); + // ======================================== + // 레이아웃 상태 (v2) + // ======================================== + const [layout, setLayout] = useState(createEmptyPopLayoutV2()); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [hasChanges, setHasChanges] = useState(false); - // 디바이스 프리뷰 상태 + // ======================================== + // 디바이스/모드 상태 + // ======================================== const [activeDevice, setActiveDevice] = useState("tablet"); - const [showBothDevices, setShowBothDevices] = useState(false); - const [isLandscape, setIsLandscape] = useState(true); + + // 활성 모드 키 (가로/세로 중 현재 포커스된 캔버스) + // 기본값: 태블릿 가로 + const [activeModeKey, setActiveModeKey] = useState("tablet_landscape"); - // 선택된 섹션/컴포넌트 + // ======================================== + // 선택 상태 + // ======================================== const [selectedSectionId, setSelectedSectionId] = useState(null); const [selectedComponentId, setSelectedComponentId] = useState(null); - // 선택된 섹션 객체 - const selectedSection = selectedSectionId - ? layout.sections.find((s) => s.id === selectedSectionId) || null - : null; + // ======================================== + // 파생 상태 + // ======================================== + + // 선택된 섹션 정의 + const selectedSection: PopSectionDefinition | null = useMemo(() => { + if (!selectedSectionId) return null; + return layout.sections[selectedSectionId] || null; + }, [layout.sections, selectedSectionId]); + // 현재 활성 모드의 섹션 ID 목록 + const activeSectionIds = useMemo(() => { + return Object.keys(layout.layouts[activeModeKey].sectionPositions); + }, [layout.layouts, activeModeKey]); + + // ======================================== // 레이아웃 로드 - // API는 이미 언래핑된 layout_data를 반환하므로 response 자체가 레이아웃 데이터 + // ======================================== useEffect(() => { const loadLayout = async () => { if (!selectedScreen?.screenId) return; setIsLoading(true); try { - // API가 layout_data 내용을 직접 반환함 (언래핑된 상태) + // API가 layout_data 내용을 직접 반환 (언래핑 상태) const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId); - if (loadedLayout && loadedLayout.version === "pop-1.0") { - // 유효한 POP 레이아웃 - setLayout(loadedLayout as PopLayoutData); - console.log("POP 레이아웃 로드 성공:", loadedLayout.sections?.length || 0, "개 섹션"); - } else if (loadedLayout && loadedLayout.sections) { - // 버전 태그 없지만 sections 구조가 있으면 사용 - console.warn("버전 태그 없음, sections 구조 감지하여 사용"); - setLayout({ - ...createEmptyPopLayout(), - ...loadedLayout, - version: "pop-1.0", - } as PopLayoutData); + if (loadedLayout) { + // v1 또는 v2 → v2로 변환 + const v2Layout = ensureV2Layout(loadedLayout); + setLayout(v2Layout); + + const sectionCount = Object.keys(v2Layout.sections).length; + const componentCount = Object.keys(v2Layout.components).length; + console.log(`POP v2 레이아웃 로드 성공: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`); + + // v1에서 마이그레이션된 경우 알림 + if (!isV2Layout(loadedLayout)) { + console.log("v1 → v2 자동 마이그레이션 완료"); + } } else { - // 레이아웃 없음 - 빈 레이아웃 생성 - console.log("POP 레이아웃 없음, 빈 레이아웃 생성"); - setLayout(createEmptyPopLayout()); + // 레이아웃 없음 - 빈 v2 레이아웃 생성 + console.log("POP 레이아웃 없음, 빈 v2 레이아웃 생성"); + setLayout(createEmptyPopLayoutV2()); } } catch (error) { console.error("레이아웃 로드 실패:", error); toast.error("레이아웃을 불러오는데 실패했습니다"); - setLayout(createEmptyPopLayout()); + setLayout(createEmptyPopLayoutV2()); } finally { setIsLoading(false); } @@ -102,7 +148,9 @@ export default function PopDesigner({ loadLayout(); }, [selectedScreen?.screenId]); + // ======================================== // 저장 + // ======================================== const handleSave = useCallback(async () => { if (!selectedScreen?.screenId) return; @@ -119,111 +167,116 @@ export default function PopDesigner({ } }, [selectedScreen?.screenId, layout]); - // 섹션 드롭 (팔레트 → 캔버스) + // ======================================== + // 섹션 추가 (4모드 동기화) + // ======================================== const handleDropSection = useCallback((gridPosition: GridPosition) => { const newId = `section-${Date.now()}`; - const newSection = createPopSection(newId, gridPosition); - - setLayout((prev) => ({ - ...prev, - sections: [...prev.sections, newSection], - })); + + setLayout((prev) => addSectionToV2Layout(prev, newId, gridPosition)); setSelectedSectionId(newId); setHasChanges(true); }, []); - // 컴포넌트 드롭 (팔레트 → 섹션) + // ======================================== + // 컴포넌트 추가 (4모드 동기화) + // ======================================== const handleDropComponent = useCallback( (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => { const newId = `${type}-${Date.now()}`; - const newComponent = createPopComponent(newId, type, gridPosition); - - setLayout((prev) => ({ - ...prev, - sections: prev.sections.map((s) => - s.id === sectionId - ? { ...s, components: [...s.components, newComponent] } - : s - ), - })); + + setLayout((prev) => addComponentToV2Layout(prev, sectionId, newId, type, gridPosition)); setSelectedComponentId(newId); setHasChanges(true); }, [] ); - // 섹션 업데이트 - const handleUpdateSection = useCallback( - (id: string, updates: Partial) => { + // ======================================== + // 섹션 정의 업데이트 (공유) + // ======================================== + const handleUpdateSectionDefinition = useCallback( + (sectionId: string, updates: Partial) => { setLayout((prev) => ({ ...prev, - sections: prev.sections.map((s) => - s.id === id ? { ...s, ...updates } : s - ), + sections: { + ...prev.sections, + [sectionId]: { + ...prev.sections[sectionId], + ...updates, + }, + }, })); setHasChanges(true); }, [] ); - // 섹션 삭제 - const handleDeleteSection = useCallback((id: string) => { - setLayout((prev) => ({ - ...prev, - sections: prev.sections.filter((s) => s.id !== id), - })); + // ======================================== + // 섹션 위치 업데이트 (현재 모드만) + // ======================================== + const handleUpdateSectionPosition = useCallback( + (sectionId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => { + const targetMode = modeKey || activeModeKey; + setLayout((prev) => updateSectionPositionInMode(prev, targetMode, sectionId, position)); + setHasChanges(true); + }, + [activeModeKey] + ); + + // ======================================== + // 컴포넌트 위치 업데이트 (현재 모드만) + // ======================================== + const handleUpdateComponentPosition = useCallback( + (componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => { + const targetMode = modeKey || activeModeKey; + setLayout((prev) => updateComponentPositionInMode(prev, targetMode, componentId, position)); + setHasChanges(true); + }, + [activeModeKey] + ); + + // ======================================== + // 섹션 삭제 (4모드 동기화) + // ======================================== + const handleDeleteSection = useCallback((sectionId: string) => { + setLayout((prev) => removeSectionFromV2Layout(prev, sectionId)); setSelectedSectionId(null); + setSelectedComponentId(null); setHasChanges(true); }, []); - // 레이아웃 변경 (드래그/리사이즈) - const handleLayoutChange = useCallback((sections: PopSectionData[]) => { - setLayout((prev) => ({ - ...prev, - sections, - })); - setHasChanges(true); - }, []); - - // 컴포넌트 업데이트 - const handleUpdateComponent = useCallback( - (sectionId: string, componentId: string, updates: Partial) => { - setLayout((prev) => ({ - ...prev, - sections: prev.sections.map((s) => - s.id === sectionId - ? { - ...s, - components: s.components.map((c) => - c.id === componentId ? { ...c, ...updates } : c - ), - } - : s - ), - })); - setHasChanges(true); - }, - [] - ); - - // 컴포넌트 삭제 + // ======================================== + // 컴포넌트 삭제 (4모드 동기화) + // ======================================== const handleDeleteComponent = useCallback( (sectionId: string, componentId: string) => { - setLayout((prev) => ({ - ...prev, - sections: prev.sections.map((s) => - s.id === sectionId - ? { ...s, components: s.components.filter((c) => c.id !== componentId) } - : s - ), - })); + setLayout((prev) => removeComponentFromV2Layout(prev, sectionId, componentId)); setSelectedComponentId(null); setHasChanges(true); }, [] ); + // ======================================== + // 디바이스 전환 + // ======================================== + const handleDeviceChange = useCallback((device: DeviceType) => { + setActiveDevice(device); + // 기본 모드 키 설정 (가로) + setActiveModeKey(device === "tablet" ? "tablet_landscape" : "mobile_landscape"); + }, []); + + // ======================================== + // 모드 키 전환 (캔버스 포커스) + // ======================================== + const handleModeKeyChange = useCallback((modeKey: PopLayoutModeKey) => { + setActiveModeKey(modeKey); + }, []); + + // ======================================== // 뒤로가기 + // ======================================== const handleBack = useCallback(() => { if (hasChanges) { if (confirm("저장하지 않은 변경사항이 있습니다. 나가시겠습니까?")) { @@ -234,6 +287,63 @@ export default function PopDesigner({ } }, [hasChanges, onBackToList]); + // ======================================== + // Delete 키 삭제 기능 + // ======================================== + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // input/textarea 포커스 시 제외 + const target = e.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) { + return; + } + + // Delete 또는 Backspace 키 + if (e.key === "Delete" || e.key === "Backspace") { + e.preventDefault(); // 브라우저 뒤로가기 방지 + + // 컴포넌트가 선택되어 있으면 컴포넌트 삭제 + if (selectedComponentId) { + // v2 구조: 컴포넌트가 속한 섹션을 sections의 componentIds에서 찾기 + // (PopComponentDefinition에는 sectionId가 없으므로 섹션을 순회하여 찾음) + let foundSectionId: string | null = null; + for (const [sectionId, sectionDef] of Object.entries(layout.sections)) { + if (sectionDef.componentIds.includes(selectedComponentId)) { + foundSectionId = sectionId; + break; + } + } + + if (foundSectionId) { + handleDeleteComponent(foundSectionId, selectedComponentId); + } + } + // 컴포넌트가 선택되지 않았고 섹션이 선택되어 있으면 섹션 삭제 + else if (selectedSectionId) { + handleDeleteSection(selectedSectionId); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [ + selectedComponentId, + selectedSectionId, + layout.sections, + handleDeleteComponent, + handleDeleteSection, + ]); + + // ======================================== + // 로딩 상태 + // ======================================== if (isLoading) { return (
@@ -242,6 +352,9 @@ export default function PopDesigner({ ); } + // ======================================== + // 렌더링 + // ======================================== return (
@@ -261,11 +374,11 @@ export default function PopDesigner({ )}
- {/* 중앙: 디바이스 전환 */} + {/* 중앙: 디바이스 전환 (가로/세로 전환 버튼 제거 - 캔버스 2개 동시 표시) */}
setActiveDevice(v as DeviceType)} + onValueChange={(v) => handleDeviceChange(v as DeviceType)} > @@ -278,24 +391,6 @@ export default function PopDesigner({ - - - -
{/* 오른쪽: 저장 */} @@ -322,9 +417,10 @@ export default function PopDesigner({ > @@ -332,23 +428,22 @@ export default function PopDesigner({ - {/* 오른쪽: 캔버스 */} + {/* 오른쪽: 캔버스 (가로+세로 2개 동시 표시) */} diff --git a/frontend/components/pop/designer/SectionGridV2.tsx b/frontend/components/pop/designer/SectionGridV2.tsx new file mode 100644 index 00000000..5e97e6f7 --- /dev/null +++ b/frontend/components/pop/designer/SectionGridV2.tsx @@ -0,0 +1,373 @@ +"use client"; + +import { useCallback, useMemo, useRef, useState, useEffect } from "react"; +import { useDrop } from "react-dnd"; +import GridLayout, { Layout } from "react-grid-layout"; +import { cn } from "@/lib/utils"; +import { + PopSectionDefinition, + PopComponentDefinition, + PopComponentType, + GridPosition, +} from "./types/pop-layout"; +import { DND_ITEM_TYPES, DragItemComponent } from "./panels/PopPanel"; +import { Trash2, Move } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +import "react-grid-layout/css/styles.css"; +import "react-resizable/css/styles.css"; + +// ======================================== +// Props +// ======================================== +interface SectionGridV2Props { + sectionId: string; + sectionDef: PopSectionDefinition; + components: Record; + componentPositions: Record; + isActive: boolean; + selectedComponentId: string | null; + onSelectComponent: (id: string | null) => void; + onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void; + onUpdateComponentPosition: (componentId: string, position: GridPosition) => void; + onDeleteComponent: (sectionId: string, componentId: string) => void; +} + +// ======================================== +// 메인 컴포넌트 +// ======================================== +export function SectionGridV2({ + sectionId, + sectionDef, + components, + componentPositions, + isActive, + selectedComponentId, + onSelectComponent, + onDropComponent, + onUpdateComponentPosition, + onDeleteComponent, +}: SectionGridV2Props) { + const containerRef = useRef(null); + + // 이 섹션에 포함된 컴포넌트 ID 목록 + const componentIds = sectionDef.componentIds || []; + + // 컨테이너 크기 측정 + const [containerSize, setContainerSize] = useState({ width: 300, height: 200 }); + + useEffect(() => { + const updateSize = () => { + if (containerRef.current) { + setContainerSize({ + width: containerRef.current.offsetWidth, + height: containerRef.current.offsetHeight, + }); + } + }; + + updateSize(); + + const resizeObserver = new ResizeObserver(updateSize); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + return () => resizeObserver.disconnect(); + }, []); + + // 셀 크기 계산 - 고정 셀 크기 기반으로 자동 계산 + const padding = 8; // p-2 = 8px + const gap = 4; // 고정 간격 + const availableWidth = containerSize.width - padding * 2; + const availableHeight = containerSize.height - padding * 2; + + // 고정 셀 크기 (40px) 기반으로 열/행 수 자동 계산 + const CELL_SIZE = 40; + const cols = Math.max(1, Math.floor((availableWidth + gap) / (CELL_SIZE + gap))); + const rows = Math.max(1, Math.floor((availableHeight + gap) / (CELL_SIZE + gap))); + const cellHeight = CELL_SIZE; + + // GridLayout용 레이아웃 변환 + const gridLayoutItems: Layout[] = useMemo(() => { + return componentIds + .map((compId) => { + const pos = componentPositions[compId]; + if (!pos) return null; + + // 위치가 그리드 범위를 벗어나지 않도록 조정 + const x = Math.min(Math.max(0, pos.col - 1), Math.max(0, cols - 1)); + const y = Math.min(Math.max(0, pos.row - 1), Math.max(0, rows - 1)); + const w = Math.min(Math.max(1, pos.colSpan), Math.max(1, cols - x)); + const h = Math.min(Math.max(1, pos.rowSpan), Math.max(1, rows - y)); + + return { + i: compId, + x, + y, + w, + h, + minW: 1, + minH: 1, + }; + }) + .filter((item): item is Layout => item !== null); + }, [componentIds, componentPositions, cols, rows]); + + // 드래그 완료 핸들러 + const handleDragStop = useCallback( + (layout: Layout[], oldItem: Layout, newItem: Layout) => { + const newPos: GridPosition = { + col: newItem.x + 1, + row: newItem.y + 1, + colSpan: newItem.w, + rowSpan: newItem.h, + }; + + const oldPos = componentPositions[newItem.i]; + if (!oldPos || oldPos.col !== newPos.col || oldPos.row !== newPos.row) { + onUpdateComponentPosition(newItem.i, newPos); + } + }, + [componentPositions, onUpdateComponentPosition] + ); + + // 리사이즈 완료 핸들러 + const handleResizeStop = useCallback( + (layout: Layout[], oldItem: Layout, newItem: Layout) => { + const newPos: GridPosition = { + col: newItem.x + 1, + row: newItem.y + 1, + colSpan: newItem.w, + rowSpan: newItem.h, + }; + + const oldPos = componentPositions[newItem.i]; + if (!oldPos || oldPos.colSpan !== newPos.colSpan || oldPos.rowSpan !== newPos.rowSpan) { + onUpdateComponentPosition(newItem.i, newPos); + } + }, + [componentPositions, onUpdateComponentPosition] + ); + + // 빈 셀 찾기 (드롭 위치용) + const findEmptyCell = useCallback((): GridPosition => { + const occupied = new Set(); + + componentIds.forEach((compId) => { + const pos = componentPositions[compId]; + if (!pos) return; + + for (let c = pos.col; c < pos.col + pos.colSpan; c++) { + for (let r = pos.row; r < pos.row + pos.rowSpan; r++) { + occupied.add(`${c}-${r}`); + } + } + }); + + // 빈 셀 찾기 + for (let r = 1; r <= rows; r++) { + for (let c = 1; c <= cols; c++) { + if (!occupied.has(`${c}-${r}`)) { + return { col: c, row: r, colSpan: 1, rowSpan: 1 }; + } + } + } + + // 빈 셀 없으면 첫 번째 위치 + return { col: 1, row: 1, colSpan: 1, rowSpan: 1 }; + }, [componentIds, componentPositions, cols, rows]); + + // 컴포넌트 드롭 핸들러 + const [{ isOver, canDrop }, drop] = useDrop( + () => ({ + accept: DND_ITEM_TYPES.COMPONENT, + drop: (item: DragItemComponent) => { + if (!isActive) return; + const emptyCell = findEmptyCell(); + onDropComponent(sectionId, item.componentType, emptyCell); + return { dropped: true }; + }, + canDrop: () => isActive, + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + }), + [isActive, sectionId, findEmptyCell, onDropComponent] + ); + + return ( +
{ + containerRef.current = node; + drop(node); + }} + className={cn( + "relative h-full w-full p-2 transition-colors", + isOver && canDrop && "bg-blue-50" + )} + onClick={(e) => { + e.stopPropagation(); + onSelectComponent(null); + }} + onMouseDown={(e) => { + e.stopPropagation(); + }} + > + {/* 빈 상태 안내 텍스트 */} + {componentIds.length === 0 && ( +
+ + {isOver && canDrop ? "여기에 놓으세요" : "컴포넌트를 드래그하세요"} + +
+ )} + + {/* 컴포넌트 GridLayout */} + {componentIds.length > 0 && availableWidth > 0 && cols > 0 && ( + + {componentIds.map((compId) => { + const compDef = components[compId]; + if (!compDef) return null; + + return ( +
{ + e.stopPropagation(); + onSelectComponent(compId); + }} + onMouseDown={(e) => e.stopPropagation()} + > + {/* 드래그 핸들 바 */} +
+ +
+ + {/* 컴포넌트 내용 */} +
+ +
+ + {/* 삭제 버튼 */} + {selectedComponentId === compId && isActive && ( + + )} +
+ ); + })} +
+ )} +
+ ); +} + +// ======================================== +// 컴포넌트 미리보기 +// ======================================== +interface ComponentPreviewV2Props { + component: PopComponentDefinition; +} + +function ComponentPreviewV2({ component }: ComponentPreviewV2Props) { + const { type, label } = component; + + const renderPreview = () => { + switch (type) { + case "pop-field": + return ( +
+ {label || "필드"} +
+
+ ); + case "pop-button": + return ( +
+ {label || "버튼"} +
+ ); + case "pop-list": + return ( +
+ {label || "리스트"} +
+
+
+
+ ); + case "pop-indicator": + return ( +
+ {label || "KPI"} + 0 +
+ ); + case "pop-scanner": + return ( +
+
+ QR +
+ {label || "스캐너"} +
+ ); + case "pop-numpad": + return ( +
+ {[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => ( +
+ {key} +
+ ))} +
+ ); + default: + return {label || type}; + } + }; + + return
{renderPreview()}
; +} diff --git a/frontend/components/pop/designer/panels/PopPanel.tsx b/frontend/components/pop/designer/panels/PopPanel.tsx index 226c1e2e..ba9613eb 100644 --- a/frontend/components/pop/designer/panels/PopPanel.tsx +++ b/frontend/components/pop/designer/panels/PopPanel.tsx @@ -34,18 +34,21 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; import { - PopLayoutData, - PopSectionData, + PopLayoutDataV2, + PopLayoutModeKey, + PopSectionDefinition, PopComponentType, + MODE_RESOLUTIONS, } from "../types/pop-layout"; +// ======================================== // 드래그 아이템 타입 +// ======================================== export const DND_ITEM_TYPES = { SECTION: "section", COMPONENT: "component", } as const; -// 드래그 아이템 데이터 export interface DragItemSection { type: typeof DND_ITEM_TYPES.SECTION; } @@ -55,16 +58,9 @@ export interface DragItemComponent { componentType: PopComponentType; } -interface PopPanelProps { - layout: PopLayoutData; - selectedSectionId: string | null; - selectedSection: PopSectionData | null; - onUpdateSection: (id: string, updates: Partial) => void; - onDeleteSection: (id: string) => void; - activeDevice: "mobile" | "tablet"; -} - +// ======================================== // 컴포넌트 팔레트 정의 +// ======================================== const COMPONENT_PALETTE: { type: PopComponentType; label: string; @@ -109,16 +105,39 @@ const COMPONENT_PALETTE: { }, ]; +// ======================================== +// Props +// ======================================== +interface PopPanelProps { + layout: PopLayoutDataV2; + activeModeKey: PopLayoutModeKey; + selectedSectionId: string | null; + selectedSection: PopSectionDefinition | null; + onUpdateSectionDefinition: (id: string, updates: Partial) => void; + onDeleteSection: (id: string) => void; + activeDevice: "mobile" | "tablet"; +} + +// ======================================== +// 메인 컴포넌트 +// ======================================== export function PopPanel({ layout, + activeModeKey, selectedSectionId, selectedSection, - onUpdateSection, + onUpdateSectionDefinition, onDeleteSection, activeDevice, }: PopPanelProps) { const [activeTab, setActiveTab] = useState("components"); + // 현재 모드의 섹션 위치 + const currentModeLayout = layout.layouts[activeModeKey]; + const selectedSectionPosition = selectedSectionId + ? currentModeLayout.sectionPositions[selectedSectionId] + : null; + return (
+ {/* 현재 모드 표시 */} +
+

+ 편집 중: {getModeLabel(activeModeKey)} +

+

+ {MODE_RESOLUTIONS[activeModeKey].width} x {MODE_RESOLUTIONS[activeModeKey].height} +

+
+ {/* 섹션 드래그 아이템 */}

@@ -176,12 +205,15 @@ export function PopPanel({ {/* 편집 탭 */} - {selectedSection ? ( - onUpdateSection(selectedSection.id, updates)} + position={selectedSectionPosition} + activeModeKey={activeModeKey} + onUpdateDefinition={(updates) => + onUpdateSectionDefinition(selectedSection.id, updates) + } onDelete={() => onDeleteSection(selectedSection.id)} - activeDevice={activeDevice} /> ) : (
@@ -194,7 +226,22 @@ export function PopPanel({ ); } +// ======================================== +// 모드 라벨 헬퍼 +// ======================================== +function getModeLabel(modeKey: PopLayoutModeKey): string { + const labels: Record = { + tablet_landscape: "태블릿 가로", + tablet_portrait: "태블릿 세로", + mobile_landscape: "모바일 가로", + mobile_portrait: "모바일 세로", + }; + return labels[modeKey]; +} + +// ======================================== // 드래그 가능한 섹션 아이템 +// ======================================== function DraggableSectionItem() { const [{ isDragging }, drag] = useDrag(() => ({ type: DND_ITEM_TYPES.SECTION, @@ -223,7 +270,9 @@ function DraggableSectionItem() { ); } +// ======================================== // 드래그 가능한 컴포넌트 아이템 +// ======================================== interface DraggableComponentItemProps { type: PopComponentType; label: string; @@ -264,28 +313,31 @@ function DraggableComponentItem({ ); } -// 섹션 편집기 -interface SectionEditorProps { - section: PopSectionData; - onUpdate: (updates: Partial) => void; +// ======================================== +// v2 섹션 편집기 +// ======================================== +interface SectionEditorV2Props { + section: PopSectionDefinition; + position: { col: number; row: number; colSpan: number; rowSpan: number }; + activeModeKey: PopLayoutModeKey; + onUpdateDefinition: (updates: Partial) => void; onDelete: () => void; - activeDevice: "mobile" | "tablet"; } -function SectionEditor({ +function SectionEditorV2({ section, - onUpdate, + position, + activeModeKey, + onUpdateDefinition, onDelete, - activeDevice, -}: SectionEditorProps) { +}: SectionEditorV2Props) { const [isGridOpen, setIsGridOpen] = useState(true); - const [isMobileOpen, setIsMobileOpen] = useState(false); return (
{/* 섹션 기본 정보 */}
- 섹션 + 섹션 설정
- {/* 그리드 위치/크기 */} + {/* 현재 모드 위치 (읽기 전용 - 드래그로 조정) */} - 그리드 위치 + 현재 모드 위치 -
-
- - - onUpdate({ - grid: { ...section.grid, col: parseInt(e.target.value) || 1 }, - }) - } - className="h-8 text-xs" - /> -
-
- - - onUpdate({ - grid: { ...section.grid, row: parseInt(e.target.value) || 1 }, - }) - } - className="h-8 text-xs" - /> -
-
- - - onUpdate({ - grid: { - ...section.grid, - colSpan: parseInt(e.target.value) || 1, - }, - }) - } - className="h-8 text-xs" - /> -
-
- - - onUpdate({ - grid: { - ...section.grid, - rowSpan: parseInt(e.target.value) || 1, - }, - }) - } - className="h-8 text-xs" - /> +
+

{getModeLabel(activeModeKey)}

+
+
+ 시작 열: + {position.col} +
+
+ 시작 행: + {position.row} +
+
+ 열 크기: + {position.colSpan} +
+
+ 행 크기: + {position.rowSpan} +
-

- 캔버스는 24열 그리드입니다 +

+ 위치/크기는 캔버스에서 드래그하여 조정하세요. + 각 모드(가로/세로)별로 별도 저장됩니다.

{/* 내부 그리드 설정 */}
+

내부 그리드 (공유)

- onUpdate({ + onUpdateDefinition({ innerGrid: { ...section.innerGrid, rows: parseInt(v) }, }) } @@ -440,70 +452,33 @@ function SectionEditor({
-

- 섹션 내부에서 컴포넌트를 배치할 그리드 (점으로 표시) +

+ 내부 그리드 설정은 4개 모드에서 공유됩니다

- {/* 모바일 전용 설정 */} - - - 모바일 전용 설정 - - - -
-
- - - onUpdate({ - mobileGrid: { - col: section.mobileGrid?.col || 1, - row: section.mobileGrid?.row || section.grid.row, - colSpan: parseInt(e.target.value) || 4, - rowSpan: - section.mobileGrid?.rowSpan || section.grid.rowSpan, - }, - }) - } - className="h-8 text-xs" - /> -
-
- - - onUpdate({ - mobileGrid: { - col: section.mobileGrid?.col || 1, - row: section.mobileGrid?.row || section.grid.row, - colSpan: section.mobileGrid?.colSpan || 4, - rowSpan: parseInt(e.target.value) || 1, - }, - }) - } - className="h-8 text-xs" - /> -
+ {/* 컴포넌트 목록 */} +
+

+ 포함된 컴포넌트 ({section.componentIds.length}개) +

+ {section.componentIds.length > 0 ? ( +
+ {section.componentIds.map((compId) => ( +
+ {compId} +
+ ))}
+ ) : (

- 모바일에서는 4열 그리드로 자동 변환됩니다 + 아직 컴포넌트가 없습니다

- - + )} +
); } diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index eec57e9d..a8125407 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -1,21 +1,209 @@ // POP 디자이너 레이아웃 타입 정의 // 그리드 기반 반응형 레이아웃 (픽셀 좌표 없음, 그리드 셀 기반) +// v2.0: 4모드 레이아웃 지원 (태블릿 가로/세로, 모바일 가로/세로) + +// ======================================== +// 레이아웃 모드 키 (4가지) +// ======================================== +export type PopLayoutModeKey = + | "tablet_landscape" // 태블릿 가로 (1024x768) + | "tablet_portrait" // 태블릿 세로 (768x1024) + | "mobile_landscape" // 모바일 가로 (667x375) + | "mobile_portrait"; // 모바일 세로 (375x667) /** - * POP 레이아웃 전체 데이터 - * - 캔버스는 12열 그리드 - * - 섹션은 그리드 셀 위치/크기로 배치 - * - 컴포넌트는 섹션 내부 그리드에 배치 + * 모드별 해상도 상수 */ -export interface PopLayoutData { +export const MODE_RESOLUTIONS: Record = { + tablet_landscape: { width: 1024, height: 768 }, + tablet_portrait: { width: 768, height: 1024 }, + mobile_landscape: { width: 667, height: 375 }, + mobile_portrait: { width: 375, height: 667 }, +}; + +// ======================================== +// v1.0 레이아웃 (기존 - 하위 호환) +// ======================================== + +/** + * POP 레이아웃 v1.0 (기존 구조) + * - 단일 sections 배열로 모든 모드 공유 + * @deprecated v2.0 사용 권장 + */ +export interface PopLayoutDataV1 { version: "pop-1.0"; - layoutMode: "grid"; // 그리드 기반 (반응형) + layoutMode: "grid"; deviceTarget: PopDeviceTarget; - canvasGrid: PopCanvasGrid; // 캔버스 그리드 설정 - sections: PopSectionData[]; // 섹션 목록 + canvasGrid: PopCanvasGrid; + sections: PopSectionDataV1[]; metadata?: PopLayoutMetadata; } +// ======================================== +// v2.0 레이아웃 (신규) +// ======================================== + +/** + * POP 레이아웃 v2.0 + * - 4개 모드별 섹션/컴포넌트 위치 분리 + * - 섹션/컴포넌트 정의는 공유 + * - 3단계 데이터 흐름 지원 + */ +export interface PopLayoutDataV2 { + version: "pop-2.0"; + + // 4개 모드별 레이아웃 (위치/크기만) + layouts: { + tablet_landscape: PopModeLayout; + tablet_portrait: PopModeLayout; + mobile_landscape: PopModeLayout; + mobile_portrait: PopModeLayout; + }; + + // 공유 섹션 정의 (ID → 정의) + sections: Record; + + // 공유 컴포넌트 정의 (ID → 정의) + components: Record; + + // 3단계 데이터 흐름 + dataFlow: PopDataFlow; + + // 전역 설정 + settings: PopGlobalSettings; + + // 메타데이터 + metadata?: PopLayoutMetadata; +} + +/** + * 모드별 레이아웃 (위치/크기만 저장) + */ +export interface PopModeLayout { + // 섹션별 위치 (섹션 ID → 위치) + sectionPositions: Record; + // 컴포넌트별 위치 (컴포넌트 ID → 위치) - 섹션 내부 그리드 기준 + componentPositions: Record; +} + +/** + * 공유 섹션 정의 (위치 제외) + */ +export interface PopSectionDefinition { + id: string; + label?: string; + // 이 섹션에 포함된 컴포넌트 ID 목록 + componentIds: string[]; + // 내부 그리드 설정 + innerGrid: PopInnerGrid; + // 데이터 소스 (섹션 레벨) + dataSource?: PopDataSource; + // 섹션 내 컴포넌트 간 연결 (Level 1) + connections?: PopConnection[]; + // 스타일 + style?: PopSectionStyle; +} + +/** + * 공유 컴포넌트 정의 (위치 제외) + */ +export interface PopComponentDefinition { + id: string; + type: PopComponentType; + label?: string; + // 데이터 바인딩 + dataBinding?: PopDataBinding; + // 스타일 프리셋 + style?: PopStylePreset; + // 컴포넌트별 설정 + config?: PopComponentConfig; +} + +/** + * 데이터 소스 설정 + */ +export interface PopDataSource { + type: "api" | "static" | "parent"; + endpoint?: string; + params?: Record; + staticData?: any[]; +} + +/** + * 컴포넌트/섹션 간 연결 + */ +export interface PopConnection { + from: string; // 소스 ID (컴포넌트 또는 섹션) + to: string; // 타겟 ID + trigger: PopTrigger; // 트리거 이벤트 + action: PopAction; // 수행할 액션 +} + +export type PopTrigger = + | "onChange" // 값 변경 시 + | "onSubmit" // 제출 시 + | "onClick" // 클릭 시 + | "onScan" // 스캔 완료 시 + | "onSelect"; // 선택 시 + +export type PopAction = + | { type: "setValue"; targetField: string } // 값 설정 + | { type: "filter"; filterField: string } // 필터링 + | { type: "refresh" } // 새로고침 + | { type: "navigate"; screenId: number } // 화면 이동 + | { type: "api"; endpoint: string; method: string }; // API 호출 + +/** + * 3단계 데이터 흐름 + */ +export interface PopDataFlow { + // Level 2: 섹션 간 연결 + sectionConnections: PopConnection[]; + + // Level 3: 화면 로드 시 파라미터 수신 + onScreenLoad?: { + paramMapping: Record; // URL 파라미터 → 컴포넌트 ID + }; + + // Level 3: 다음 화면으로 데이터 전달 + navigationOutput?: { + screenId: number; + paramMapping: Record; // 컴포넌트 ID → URL 파라미터명 + }; +} + +/** + * 전역 설정 + */ +export interface PopGlobalSettings { + // 최소 터치 타겟 크기 (px) + touchTargetMin: number; // 기본 48px, 산업용 60px + // 모드 (일반/산업용) + mode: "normal" | "industrial"; + // 캔버스 그리드 설정 + canvasGrid: PopCanvasGrid; +} + +// ======================================== +// 통합 타입 (v1 또는 v2) +// ======================================== + +/** + * POP 레이아웃 데이터 (v1 또는 v2) + */ +export type PopLayoutData = PopLayoutDataV1 | PopLayoutDataV2; + +/** + * 버전 체크 타입 가드 + */ +export function isV2Layout(data: PopLayoutData): data is PopLayoutDataV2 { + return data.version === "pop-2.0"; +} + +export function isV1Layout(data: PopLayoutData): data is PopLayoutDataV1 { + return data.version === "pop-1.0"; +} + /** * 캔버스 그리드 설정 */ @@ -43,34 +231,53 @@ export interface PopLayoutMetadata { /** * 그리드 위치/크기 + * - col/row: 1-based 시작 위치 + * - colSpan/rowSpan: 차지하는 칸 수 */ export interface GridPosition { - col: number; // 시작 열 (1-based) - row: number; // 시작 행 (1-based) - colSpan: number; // 열 개수 - rowSpan: number; // 행 개수 + col: number; // 시작 열 (1-based) + row: number; // 시작 행 (1-based) + colSpan: number; // 열 개수 + rowSpan: number; // 행 개수 +} + +// ======================================== +// v1 섹션/컴포넌트 타입 (기존 구조) +// ======================================== + +/** + * 섹션 데이터 v1 (기존 구조 - 위치 포함) + * @deprecated v2에서는 PopSectionDefinition 사용 + */ +export interface PopSectionDataV1 { + id: string; + label?: string; + grid: GridPosition; + mobileGrid?: GridPosition; + innerGrid: PopInnerGrid; + components: PopComponentDataV1[]; + style?: PopSectionStyle; } /** - * 섹션 데이터 - * - 캔버스 그리드 위에 배치 - * - 내부에 컴포넌트들을 가짐 + * 컴포넌트 데이터 v1 (기존 구조 - 위치 포함) + * @deprecated v2에서는 PopComponentDefinition 사용 */ -export interface PopSectionData { +export interface PopComponentDataV1 { id: string; - label?: string; - // 그리드 위치 (12열 캔버스 기준) + type: PopComponentType; grid: GridPosition; - // 모바일용 그리드 위치 (선택, 없으면 자동 조정) mobileGrid?: GridPosition; - // 내부 그리드 설정 - innerGrid: PopInnerGrid; - // 섹션 내 컴포넌트들 - components: PopComponentData[]; - // 스타일 - style?: PopSectionStyle; + label?: string; + dataBinding?: PopDataBinding; + style?: PopStylePreset; + config?: PopComponentConfig; } +// 하위 호환을 위한 alias +export type PopSectionData = PopSectionDataV1; +export type PopComponentData = PopComponentDataV1; + /** * 섹션 내부 그리드 설정 */ @@ -311,10 +518,15 @@ export const DEFAULT_INNER_GRID: PopInnerGrid = { gap: 4, }; +// ======================================== +// v1 생성 함수 (기존 - 하위 호환) +// ======================================== + /** - * 빈 레이아웃 생성 + * 빈 v1 레이아웃 생성 + * @deprecated createEmptyPopLayoutV2 사용 권장 */ -export const createEmptyPopLayout = (): PopLayoutData => ({ +export const createEmptyPopLayoutV1 = (): PopLayoutDataV1 => ({ version: "pop-1.0", layoutMode: "grid", deviceTarget: "both", @@ -322,13 +534,16 @@ export const createEmptyPopLayout = (): PopLayoutData => ({ sections: [], }); +// 하위 호환을 위한 alias +export const createEmptyPopLayout = createEmptyPopLayoutV1; + /** - * 새 섹션 생성 + * 새 섹션 생성 (v1) */ export const createPopSection = ( id: string, grid: GridPosition = { col: 1, row: 1, colSpan: 3, rowSpan: 4 } -): PopSectionData => ({ +): PopSectionDataV1 => ({ id, grid, innerGrid: { ...DEFAULT_INNER_GRID }, @@ -340,24 +555,398 @@ export const createPopSection = ( }); /** - * 새 컴포넌트 생성 + * 새 컴포넌트 생성 (v1) */ export const createPopComponent = ( id: string, type: PopComponentType, grid: GridPosition = { col: 1, row: 1, colSpan: 1, rowSpan: 1 }, label?: string -): PopComponentData => ({ +): PopComponentDataV1 => ({ id, type, grid, label, }); -// ===== 타입 가드 ===== +// ======================================== +// v2 생성 함수 +// ======================================== -export const isPopField = (comp: PopComponentData): boolean => +/** + * 빈 v2 레이아웃 생성 + */ +export const createEmptyPopLayoutV2 = (): PopLayoutDataV2 => ({ + version: "pop-2.0", + layouts: { + tablet_landscape: { sectionPositions: {}, componentPositions: {} }, + tablet_portrait: { sectionPositions: {}, componentPositions: {} }, + mobile_landscape: { sectionPositions: {}, componentPositions: {} }, + mobile_portrait: { sectionPositions: {}, componentPositions: {} }, + }, + sections: {}, + components: {}, + dataFlow: { + sectionConnections: [], + }, + settings: { + touchTargetMin: 48, + mode: "normal", + canvasGrid: { ...DEFAULT_CANVAS_GRID }, + }, +}); + +/** + * v2 섹션 정의 생성 + */ +export const createSectionDefinition = ( + id: string, + label?: string +): PopSectionDefinition => ({ + id, + label, + componentIds: [], + innerGrid: { ...DEFAULT_INNER_GRID }, + style: { + showBorder: true, + padding: "small", + }, +}); + +/** + * v2 컴포넌트 정의 생성 + */ +export const createComponentDefinition = ( + id: string, + type: PopComponentType, + label?: string +): PopComponentDefinition => ({ + id, + type, + label, +}); + +// ======================================== +// 마이그레이션 함수 (v1 → v2) +// ======================================== + +/** + * v1 레이아웃을 v2로 마이그레이션 + * - 기존 섹션/컴포넌트를 tablet_landscape 기준으로 4모드에 복제 + * - 정의와 위치를 분리 + */ +export const migrateV1ToV2 = (v1: PopLayoutDataV1): PopLayoutDataV2 => { + const v2 = createEmptyPopLayoutV2(); + + // 캔버스 그리드 설정 복사 + v2.settings.canvasGrid = { ...v1.canvasGrid }; + + // 메타데이터 복사 + if (v1.metadata) { + v2.metadata = { ...v1.metadata }; + } + + // 섹션별 마이그레이션 + for (const section of v1.sections) { + // 1. 섹션 정의 생성 + const sectionDef: PopSectionDefinition = { + id: section.id, + label: section.label, + componentIds: section.components.map(c => c.id), + innerGrid: { ...section.innerGrid }, + style: section.style ? { ...section.style } : undefined, + }; + v2.sections[section.id] = sectionDef; + + // 2. 섹션 위치 복사 (4모드 모두 동일하게) + const sectionPos: GridPosition = { ...section.grid }; + v2.layouts.tablet_landscape.sectionPositions[section.id] = { ...sectionPos }; + v2.layouts.tablet_portrait.sectionPositions[section.id] = { ...sectionPos }; + v2.layouts.mobile_landscape.sectionPositions[section.id] = { ...sectionPos }; + v2.layouts.mobile_portrait.sectionPositions[section.id] = { ...sectionPos }; + + // 3. 컴포넌트별 마이그레이션 + for (const comp of section.components) { + // 컴포넌트 정의 생성 + const compDef: PopComponentDefinition = { + id: comp.id, + type: comp.type, + label: comp.label, + dataBinding: comp.dataBinding ? { ...comp.dataBinding } : undefined, + style: comp.style ? { ...comp.style } : undefined, + config: comp.config, + }; + v2.components[comp.id] = compDef; + + // 컴포넌트 위치 복사 (4모드 모두 동일하게) + const compPos: GridPosition = { ...comp.grid }; + v2.layouts.tablet_landscape.componentPositions[comp.id] = { ...compPos }; + v2.layouts.tablet_portrait.componentPositions[comp.id] = { ...compPos }; + v2.layouts.mobile_landscape.componentPositions[comp.id] = { ...compPos }; + v2.layouts.mobile_portrait.componentPositions[comp.id] = { ...compPos }; + } + } + + return v2; +}; + +/** + * 레이아웃 데이터를 v2로 보장 (필요시 마이그레이션) + */ +export const ensureV2Layout = (data: PopLayoutData): PopLayoutDataV2 => { + if (isV2Layout(data)) { + return data; + } + if (isV1Layout(data)) { + return migrateV1ToV2(data); + } + // 알 수 없는 버전 - 빈 v2 반환 + console.warn("알 수 없는 레이아웃 버전, 빈 v2 레이아웃 생성"); + return createEmptyPopLayoutV2(); +}; + +// ======================================== +// v2 헬퍼 함수 +// ======================================== + +/** + * v2 레이아웃에 섹션 추가 (4모드 동기화) + */ +export const addSectionToV2Layout = ( + layout: PopLayoutDataV2, + sectionId: string, + position: GridPosition, + label?: string +): PopLayoutDataV2 => { + const newLayout = { ...layout }; + + // 섹션 정의 추가 + newLayout.sections = { + ...newLayout.sections, + [sectionId]: createSectionDefinition(sectionId, label), + }; + + // 4모드 모두에 위치 추가 + const modeKeys: PopLayoutModeKey[] = [ + "tablet_landscape", "tablet_portrait", + "mobile_landscape", "mobile_portrait" + ]; + + const newLayouts = { ...newLayout.layouts }; + for (const mode of modeKeys) { + newLayouts[mode] = { + ...newLayouts[mode], + sectionPositions: { + ...newLayouts[mode].sectionPositions, + [sectionId]: { ...position }, + }, + }; + } + newLayout.layouts = newLayouts; + + return newLayout; +}; + +/** + * v2 레이아웃에 컴포넌트 추가 (4모드 동기화) + */ +export const addComponentToV2Layout = ( + layout: PopLayoutDataV2, + sectionId: string, + componentId: string, + type: PopComponentType, + position: GridPosition, + label?: string +): PopLayoutDataV2 => { + const newLayout = { ...layout }; + + // 컴포넌트 정의 추가 + newLayout.components = { + ...newLayout.components, + [componentId]: createComponentDefinition(componentId, type, label), + }; + + // 섹션의 componentIds에 추가 + const section = newLayout.sections[sectionId]; + if (section) { + newLayout.sections = { + ...newLayout.sections, + [sectionId]: { + ...section, + componentIds: [...section.componentIds, componentId], + }, + }; + } + + // 4모드 모두에 위치 추가 + const modeKeys: PopLayoutModeKey[] = [ + "tablet_landscape", "tablet_portrait", + "mobile_landscape", "mobile_portrait" + ]; + + const newLayouts = { ...newLayout.layouts }; + for (const mode of modeKeys) { + newLayouts[mode] = { + ...newLayouts[mode], + componentPositions: { + ...newLayouts[mode].componentPositions, + [componentId]: { ...position }, + }, + }; + } + newLayout.layouts = newLayouts; + + return newLayout; +}; + +/** + * v2 레이아웃에서 섹션 삭제 (4모드 동기화) + */ +export const removeSectionFromV2Layout = ( + layout: PopLayoutDataV2, + sectionId: string +): PopLayoutDataV2 => { + const newLayout = { ...layout }; + + // 섹션에 포함된 컴포넌트 ID 가져오기 + const section = newLayout.sections[sectionId]; + const componentIds = section?.componentIds || []; + + // 섹션 정의 삭제 + const { [sectionId]: _, ...remainingSections } = newLayout.sections; + newLayout.sections = remainingSections; + + // 컴포넌트 정의 삭제 + let remainingComponents = { ...newLayout.components }; + for (const compId of componentIds) { + const { [compId]: __, ...rest } = remainingComponents; + remainingComponents = rest; + } + newLayout.components = remainingComponents; + + // 4모드 모두에서 위치 삭제 + const modeKeys: PopLayoutModeKey[] = [ + "tablet_landscape", "tablet_portrait", + "mobile_landscape", "mobile_portrait" + ]; + + const newLayouts = { ...newLayout.layouts }; + for (const mode of modeKeys) { + const { [sectionId]: ___, ...remainingSecPos } = newLayouts[mode].sectionPositions; + let remainingCompPos = { ...newLayouts[mode].componentPositions }; + for (const compId of componentIds) { + const { [compId]: ____, ...rest } = remainingCompPos; + remainingCompPos = rest; + } + newLayouts[mode] = { + sectionPositions: remainingSecPos, + componentPositions: remainingCompPos, + }; + } + newLayout.layouts = newLayouts; + + return newLayout; +}; + +/** + * v2 레이아웃에서 컴포넌트 삭제 (4모드 동기화) + */ +export const removeComponentFromV2Layout = ( + layout: PopLayoutDataV2, + sectionId: string, + componentId: string +): PopLayoutDataV2 => { + const newLayout = { ...layout }; + + // 컴포넌트 정의 삭제 + const { [componentId]: _, ...remainingComponents } = newLayout.components; + newLayout.components = remainingComponents; + + // 섹션의 componentIds에서 제거 + const section = newLayout.sections[sectionId]; + if (section) { + newLayout.sections = { + ...newLayout.sections, + [sectionId]: { + ...section, + componentIds: section.componentIds.filter(id => id !== componentId), + }, + }; + } + + // 4모드 모두에서 위치 삭제 + const modeKeys: PopLayoutModeKey[] = [ + "tablet_landscape", "tablet_portrait", + "mobile_landscape", "mobile_portrait" + ]; + + const newLayouts = { ...newLayout.layouts }; + for (const mode of modeKeys) { + const { [componentId]: __, ...remainingCompPos } = newLayouts[mode].componentPositions; + newLayouts[mode] = { + ...newLayouts[mode], + componentPositions: remainingCompPos, + }; + } + newLayout.layouts = newLayouts; + + return newLayout; +}; + +/** + * v2 레이아웃에서 특정 모드의 섹션 위치 업데이트 + */ +export const updateSectionPositionInMode = ( + layout: PopLayoutDataV2, + modeKey: PopLayoutModeKey, + sectionId: string, + position: GridPosition +): PopLayoutDataV2 => { + return { + ...layout, + layouts: { + ...layout.layouts, + [modeKey]: { + ...layout.layouts[modeKey], + sectionPositions: { + ...layout.layouts[modeKey].sectionPositions, + [sectionId]: position, + }, + }, + }, + }; +}; + +/** + * v2 레이아웃에서 특정 모드의 컴포넌트 위치 업데이트 + */ +export const updateComponentPositionInMode = ( + layout: PopLayoutDataV2, + modeKey: PopLayoutModeKey, + componentId: string, + position: GridPosition +): PopLayoutDataV2 => { + return { + ...layout, + layouts: { + ...layout.layouts, + [modeKey]: { + ...layout.layouts[modeKey], + componentPositions: { + ...layout.layouts[modeKey].componentPositions, + [componentId]: position, + }, + }, + }, + }; +}; + +// ======================================== +// 타입 가드 +// ======================================== + +export const isPopField = (comp: PopComponentDataV1 | PopComponentDefinition): boolean => comp.type === "pop-field"; -export const isPopButton = (comp: PopComponentData): boolean => +export const isPopButton = (comp: PopComponentDataV1 | PopComponentDefinition): boolean => comp.type === "pop-button"; diff --git a/frontend/components/pop/management/PopCategoryTree.tsx b/frontend/components/pop/management/PopCategoryTree.tsx index d7562fd0..c8140821 100644 --- a/frontend/components/pop/management/PopCategoryTree.tsx +++ b/frontend/components/pop/management/PopCategoryTree.tsx @@ -302,6 +302,7 @@ function TreeNode({ {screen.screenName} + #{screen.screenId} {/* 더보기 메뉴 (폴더와 동일한 스타일) */} @@ -880,6 +881,7 @@ export function PopCategoryTree({ > {screen.screenName} + #{screen.screenId} {/* 더보기 메뉴 */} diff --git a/frontend/components/pop/management/PopScreenPreview.tsx b/frontend/components/pop/management/PopScreenPreview.tsx index b6b95fce..66b605ab 100644 --- a/frontend/components/pop/management/PopScreenPreview.tsx +++ b/frontend/components/pop/management/PopScreenPreview.tsx @@ -20,9 +20,10 @@ interface PopScreenPreviewProps { } // 디바이스 프레임 크기 +// 모바일: 세로(portrait), 태블릿: 가로(landscape) 디폴트 const DEVICE_SIZES = { - mobile: { width: 375, height: 667 }, // iPhone SE 기준 - tablet: { width: 768, height: 1024 }, // iPad 기준 + mobile: { width: 375, height: 667 }, // iPhone SE 기준 (세로) + tablet: { width: 1024, height: 768 }, // iPad 기준 (가로) }; // ============================================================ @@ -46,7 +47,19 @@ export function PopScreenPreview({ screen, className }: PopScreenPreviewProps) { try { setLoading(true); const layout = await screenApi.getLayoutPop(screen.screenId); - setHasLayout(layout && layout.sections && layout.sections.length > 0); + + // v2 레이아웃: sections는 객체 (Record) + // v1 레이아웃: sections는 배열 + if (layout) { + const isV2 = layout.version === "pop-2.0"; + const hasSections = isV2 + ? layout.sections && Object.keys(layout.sections).length > 0 + : layout.sections && Array.isArray(layout.sections) && layout.sections.length > 0; + + setHasLayout(hasSections); + } else { + setHasLayout(false); + } } catch { setHasLayout(false); } finally { @@ -94,11 +107,13 @@ export function PopScreenPreview({ screen, className }: PopScreenPreviewProps) { {/* 디바이스 선택 */} setDeviceType(v as DeviceType)}> - + + 모바일 - + + 태블릿 @@ -152,45 +167,26 @@ export function PopScreenPreview({ screen, className }: PopScreenPreviewProps) {

) : ( - // 디바이스 프레임 + iframe + // 디바이스 프레임 + iframe (심플한 테두리)
- {/* 디바이스 노치 (모바일) */} - {deviceType === "mobile" && ( -
- )} - - {/* 디바이스 홈 버튼 (태블릿) */} - {deviceType === "tablet" && ( -
- )} - - {/* iframe 컨테이너 */} -
-