POP 디자이너 v5 그리드 시스템 통합 및 그리드 가이드 재설계

레거시 v1~v4 시스템 제거 (6,634줄 순감)
GridGuide SVG → PopRenderer CSS Grid 기반으로 전환
행/열 라벨 추가로 배치 위치 명확화
컴포넌트 타입 pop-sample로 단순화
문서 정리 (ARCHITECTURE, SPEC, CHANGELOG, ADR)
This commit is contained in:
SeongHyun Kim 2026-02-05 14:24:14 +09:00
parent 5f23c13490
commit 9ebc8c4219
39 changed files with 6200 additions and 7472 deletions

1041
POPUPDATE.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4903,24 +4903,16 @@ export class ScreenManagementService {
companyCode: string, companyCode: string,
userId?: string, userId?: string,
): Promise<void> { ): Promise<void> {
console.log(`=== POP 레이아웃 저장 시작 ===`); console.log(`=== POP 레이아웃 저장 (v5 그리드 시스템) ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`); console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
// 버전 감지 // v5 그리드 레이아웃만 지원
const isV3 = layoutData.version === "pop-3.0" || const componentCount = Object.keys(layoutData.components || {}).length;
(layoutData.layouts && layoutData.components && !layoutData.sections); console.log(`컴포넌트: ${componentCount}`);
const isV2 = layoutData.version === "pop-2.0" ||
(layoutData.layouts && layoutData.sections && layoutData.components);
if (isV3) { // v5 형식 검증
const componentCount = Object.keys(layoutData.components || {}).length; if (layoutData.version && layoutData.version !== "pop-5.0") {
console.log(`v3 레이아웃: ${componentCount}개 컴포넌트 (섹션 없음)`); console.warn(`레거시 버전 감지 (${layoutData.version}), v5로 변환 필요`);
} else 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})`);
} }
// 권한 확인 // 권한 확인
@ -4946,50 +4938,12 @@ export class ScreenManagementService {
console.log(`저장 대상 company_code: ${targetCompanyCode} (사용자: ${companyCode}, 화면: ${existingScreen.company_code})`); console.log(`저장 대상 company_code: ${targetCompanyCode} (사용자: ${companyCode}, 화면: ${existingScreen.company_code})`);
// 버전 정보 보장 // v5 그리드 레이아웃으로 저장 (단일 버전)
let dataToSave: any; const dataToSave = {
if (isV3) {
dataToSave = {
...layoutData, ...layoutData,
version: "pop-3.0", version: "pop-5.0",
}; };
console.log(`저장: gridConfig=${JSON.stringify(dataToSave.gridConfig || 'default')}`)
// canvasGrid.rows 검증 및 보정
if (dataToSave.settings?.canvasGrid) {
if (!dataToSave.settings.canvasGrid.rows) {
console.warn("canvasGrid.rows 없음, 기본값 24로 설정");
dataToSave.settings.canvasGrid.rows = 24;
}
// 구버전 rowHeight 필드 제거
if (dataToSave.settings.canvasGrid.rowHeight) {
console.warn("구버전 rowHeight 필드 제거");
delete dataToSave.settings.canvasGrid.rowHeight;
}
}
} else if (isV2) {
dataToSave = {
...layoutData,
version: "pop-2.0",
};
// canvasGrid.rows 검증 및 보정
if (dataToSave.settings?.canvasGrid) {
if (!dataToSave.settings.canvasGrid.rows) {
console.warn("canvasGrid.rows 없음, 기본값 24로 설정");
dataToSave.settings.canvasGrid.rows = 24;
}
if (dataToSave.settings.canvasGrid.rowHeight) {
console.warn("구버전 rowHeight 필드 제거");
delete dataToSave.settings.canvasGrid.rowHeight;
}
}
} else {
// v1 형식으로 저장 (하위 호환)
dataToSave = {
version: "pop-1.0",
...layoutData,
};
}
// UPSERT (있으면 업데이트, 없으면 삽입) // UPSERT (있으면 업데이트, 없으면 삽입)
await query( await query(

View File

@ -5,11 +5,9 @@ import { useParams, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react"; import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react";
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition, LayoutData } from "@/types/screen"; import { ScreenDefinition } from "@/types/screen";
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 { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
@ -17,23 +15,14 @@ import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHei
import { ScreenContextProvider } from "@/contexts/ScreenContext"; import { ScreenContextProvider } from "@/contexts/ScreenContext";
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext";
import { import {
PopLayoutDataV3, PopLayoutDataV5,
PopLayoutDataV4, GridMode,
PopLayoutModeKey, isV5Layout,
ensureV3Layout, createEmptyPopLayoutV5,
isV3Layout,
isV4Layout,
} from "@/components/pop/designer/types/pop-layout"; } from "@/components/pop/designer/types/pop-layout";
import PopRenderer from "@/components/pop/designer/renderers/PopRenderer";
import { import {
PopLayoutRenderer,
hasBaseLayout,
getEffectiveModeLayout,
} from "@/components/pop/designer/renderers";
import { PopFlexRenderer } from "@/components/pop/designer/renderers/PopFlexRenderer";
import {
useResponsiveMode,
useResponsiveModeWithOverride, useResponsiveModeWithOverride,
type DeviceType, type DeviceType,
} from "@/hooks/useDeviceOrientation"; } from "@/hooks/useDeviceOrientation";
@ -50,39 +39,16 @@ const DEVICE_SIZES: Record<DeviceType, Record<"landscape" | "portrait", { width:
}, },
}; };
// ======================================== // 모드 키 변환
// 헬퍼 함수 const getModeKey = (device: DeviceType, isLandscape: boolean): GridMode => {
// ========================================
const getModeKey = (device: DeviceType, isLandscape: boolean): PopLayoutModeKey => {
if (device === "tablet") { if (device === "tablet") {
return isLandscape ? "tablet_landscape" : "tablet_portrait"; return isLandscape ? "tablet_landscape" : "tablet_portrait";
} }
return isLandscape ? "mobile_landscape" : "mobile_portrait"; return isLandscape ? "mobile_landscape" : "mobile_portrait";
}; };
// v3.0 레이아웃인지 확인
const isPopLayoutV3 = (layout: any): layout is PopLayoutDataV3 => {
return layout && layout.version === "pop-3.0" && layout.layouts && layout.components;
};
// v4.0 레이아웃인지 확인
const isPopLayoutV4 = (layout: any): layout is PopLayoutDataV4 => {
return layout && layout.version === "pop-4.0" && layout.root && layout.components;
};
// v1/v2/v3/v4 레이아웃인지 확인
const isPopLayout = (layout: any): boolean => {
return layout && (
layout.version === "pop-1.0" ||
layout.version === "pop-2.0" ||
layout.version === "pop-3.0" ||
layout.version === "pop-4.0"
);
};
// ======================================== // ========================================
// 메인 컴포넌트 // 메인 컴포넌트 (v5 그리드 시스템 전용)
// ======================================== // ========================================
function PopScreenViewPage() { function PopScreenViewPage() {
@ -103,21 +69,15 @@ function PopScreenViewPage() {
// 현재 모드 정보 // 현재 모드 정보
const deviceType = mode.device; const deviceType = mode.device;
const isLandscape = mode.isLandscape; const isLandscape = mode.isLandscape;
const currentModeKey = mode.modeKey; const currentModeKey = getModeKey(deviceType, isLandscape);
const { user, userName, companyCode } = useAuth(); const { user } = useAuth();
const [screen, setScreen] = useState<ScreenDefinition | null>(null); const [screen, setScreen] = useState<ScreenDefinition | null>(null);
const [layout, setLayout] = useState<LayoutData | null>(null); const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
const [popLayoutV3, setPopLayoutV3] = useState<PopLayoutDataV3 | null>(null);
const [popLayoutV4, setPopLayoutV4] = useState<PopLayoutDataV4 | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState<Record<string, unknown>>({});
const [selectedRowsData, setSelectedRowsData] = useState<any[]>([]);
const [tableRefreshKey, setTableRefreshKey] = useState(0);
// 뷰포트 너비 (클라이언트 사이드에서만 계산, 최대 1366px) // 뷰포트 너비 (클라이언트 사이드에서만 계산, 최대 1366px)
const [viewportWidth, setViewportWidth] = useState(1024); // 기본값: 태블릿 가로 const [viewportWidth, setViewportWidth] = useState(1024); // 기본값: 태블릿 가로
@ -131,18 +91,6 @@ function PopScreenViewPage() {
return () => window.removeEventListener("resize", updateViewportWidth); return () => window.removeEventListener("resize", updateViewportWidth);
}, []); }, []);
// 컴포넌트 초기화
useEffect(() => {
const initComponents = async () => {
try {
await initializeComponents();
} catch (error) {
console.error("POP 화면 컴포넌트 초기화 실패:", error);
}
};
initComponents();
}, []);
// 화면 및 POP 레이아웃 로드 // 화면 및 POP 레이아웃 로드
useEffect(() => { useEffect(() => {
const loadScreen = async () => { const loadScreen = async () => {
@ -156,39 +104,22 @@ function PopScreenViewPage() {
try { try {
const popLayout = await screenApi.getLayoutPop(screenId); const popLayout = await screenApi.getLayoutPop(screenId);
if (popLayout && isPopLayoutV4(popLayout)) { if (popLayout && isV5Layout(popLayout)) {
// v4 레이아웃 // v5 레이아웃 로드
setPopLayoutV4(popLayout); setLayout(popLayout);
setPopLayoutV3(null);
const componentCount = Object.keys(popLayout.components).length; const componentCount = Object.keys(popLayout.components).length;
console.log(`[POP] v4 레이아웃 로드됨: ${componentCount}개 컴포넌트`); console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`);
} else if (popLayout && isPopLayout(popLayout)) { } else if (popLayout) {
// v1/v2/v3 → v3로 변환 // 다른 버전 레이아웃은 빈 v5로 처리
const v3Layout = ensureV3Layout(popLayout); console.log("[POP] 레거시 레이아웃 감지, 빈 레이아웃으로 시작합니다:", popLayout.version);
setPopLayoutV3(v3Layout); setLayout(createEmptyPopLayoutV5());
setPopLayoutV4(null);
const componentCount = Object.keys(v3Layout.components).length;
console.log(`[POP] v3 레이아웃 로드됨: ${componentCount}개 컴포넌트`);
if (!isV3Layout(popLayout)) {
console.log("[POP] v1/v2 → v3 자동 마이그레이션 완료");
}
} else if (popLayout && popLayout.components && Array.isArray(popLayout.components) && popLayout.components.length > 0) {
// 이전 형식 (레거시 components 구조)
console.log("[POP] 레거시 레이아웃 로드:", popLayout.components.length, "개 컴포넌트");
setLayout(popLayout as LayoutData);
} else { } else {
console.log("[POP] 레이아웃 없음"); console.log("[POP] 레이아웃 없음");
setPopLayoutV3(null); setLayout(createEmptyPopLayoutV5());
setPopLayoutV4(null);
setLayout(null);
} }
} catch (layoutError) { } catch (layoutError) {
console.warn("[POP] 레이아웃 로드 실패:", layoutError); console.warn("[POP] 레이아웃 로드 실패:", layoutError);
setPopLayoutV3(null); setLayout(createEmptyPopLayoutV5());
setPopLayoutV4(null);
setLayout(null);
} }
} catch (error) { } catch (error) {
console.error("[POP] 화면 로드 실패:", error); console.error("[POP] 화면 로드 실패:", error);
@ -205,6 +136,7 @@ function PopScreenViewPage() {
}, [screenId]); }, [screenId]);
const currentDevice = DEVICE_SIZES[deviceType][isLandscape ? "landscape" : "portrait"]; const currentDevice = DEVICE_SIZES[deviceType][isLandscape ? "landscape" : "portrait"];
const hasComponents = Object.keys(layout.components).length > 0;
if (loading) { if (loading) {
return ( return (
@ -336,70 +268,19 @@ function PopScreenViewPage() {
flexShrink: 0, flexShrink: 0,
} : undefined} } : undefined}
> >
{/* POP 레이아웃 v4.0 렌더링 */} {/* v5 그리드 렌더러 */}
{popLayoutV4 ? ( {hasComponents ? (
<div <div
className="mx-auto h-full" className="mx-auto h-full"
style={{ maxWidth: 1366 }} style={{ maxWidth: 1366 }}
> >
<PopFlexRenderer <PopRenderer
layout={popLayoutV4} layout={layout}
viewportWidth={isPreviewMode ? currentDevice.width : viewportWidth} viewportWidth={isPreviewMode ? currentDevice.width : viewportWidth}
currentMode={currentModeKey}
isDesignMode={false} isDesignMode={false}
/> />
</div> </div>
) : popLayoutV3 ? (
/* POP 레이아웃 v3.0 렌더링 */
<PopLayoutV3Renderer
layout={popLayoutV3}
modeKey={currentModeKey}
/>
) : layout && layout.components && layout.components.length > 0 ? (
// 레거시 형식 (components 구조) - 호환성 유지
<ScreenMultiLangProvider components={layout.components} companyCode={companyCode}>
<div className="relative w-full min-h-full p-4">
{layout.components
.filter((component) => !component.parentId)
.map((component) => (
<div
key={component.id}
style={{
position: component.position ? "absolute" : "relative",
left: component.position?.x || 0,
top: component.position?.y || 0,
width: component.size?.width || "100%",
height: component.size?.height || "auto",
zIndex: component.position?.z || 1,
}}
>
<DynamicComponentRenderer
component={component}
isDesignMode={false}
isInteractive={true}
formData={formData}
onDataflowComplete={() => { }}
screenId={screenId}
tableName={screen?.tableName}
userId={user?.userId}
userName={userName}
companyCode={companyCode}
selectedRowsData={selectedRowsData}
onSelectedRowsChange={(_, selectedData) => {
setSelectedRowsData(selectedData);
}}
refreshKey={tableRefreshKey}
onRefresh={() => {
setTableRefreshKey((prev) => prev + 1);
setSelectedRowsData([]);
}}
onFormDataChange={(fieldName, value) => {
setFormData((prev) => ({ ...prev, [fieldName]: value }));
}}
/>
</div>
))}
</div>
</ScreenMultiLangProvider>
) : ( ) : (
// 빈 화면 // 빈 화면
<div className="flex flex-col items-center justify-center min-h-[400px] p-8 text-center"> <div className="flex flex-col items-center justify-center min-h-[400px] p-8 text-center">
@ -423,55 +304,6 @@ function PopScreenViewPage() {
); );
} }
// ========================================
// POP 레이아웃 v3.0 렌더러
// ========================================
interface PopLayoutV3RendererProps {
layout: PopLayoutDataV3;
modeKey: PopLayoutModeKey;
}
function PopLayoutV3Renderer({ layout, modeKey }: PopLayoutV3RendererProps) {
// 태블릿 가로 모드가 기준으로 설정되어 있는지 확인
if (!hasBaseLayout(layout)) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] p-8 text-center">
<div className="w-16 h-16 rounded-full bg-yellow-100 flex items-center justify-center mb-4">
<span className="text-2xl">!</span>
</div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">
</h3>
<p className="text-sm text-gray-500 max-w-xs">
POP 릿 .
</p>
</div>
);
}
// 현재 모드에 맞는 레이아웃 가져오기
const { modeLayout, isConverted, sourceModeKey } = getEffectiveModeLayout(layout, modeKey);
return (
<div className="w-full h-full flex flex-col">
{isConverted && (
<div className="mx-2 mt-2 px-2 py-1 bg-yellow-50 border border-yellow-200 rounded text-xs text-yellow-700 shrink-0">
{sourceModeKey}
</div>
)}
<PopLayoutRenderer
layout={layout}
modeKey={modeKey}
customModeLayout={isConverted ? modeLayout : undefined}
isDesignMode={false}
className="flex-1"
style={{ height: "100%" }}
/>
</div>
);
}
// Provider 래퍼 // Provider 래퍼
export default function PopScreenViewPageWrapper() { export default function PopScreenViewPageWrapper() {
return ( return (

View File

@ -1,150 +0,0 @@
"use client";
import { useState, useCallback } from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import {
PopLayoutDataV4,
createEmptyPopLayoutV4,
addComponentToV4Layout,
removeComponentFromV4Layout,
updateComponentInV4Layout,
updateContainerV4,
findContainerV4,
PopComponentType,
PopComponentDefinitionV4,
PopContainerV4,
} from "@/components/pop/designer/types/pop-layout";
import { PopCanvasV4 } from "@/components/pop/designer/PopCanvasV4";
import { PopPanel } from "@/components/pop/designer/panels/PopPanel";
import { ComponentEditorPanelV4 } from "@/components/pop/designer/panels/ComponentEditorPanelV4";
// ========================================
// v4 테스트 페이지
//
// 목적: v4 렌더러, 캔버스, 속성 패널 테스트
// 경로: /pop/test-v4
// ========================================
export default function TestV4Page() {
// 레이아웃 상태
const [layout, setLayout] = useState<PopLayoutDataV4>(() => {
// 초기 테스트 데이터
const initial = createEmptyPopLayoutV4();
return initial;
});
// 선택 상태
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
const [selectedContainerId, setSelectedContainerId] = useState<string | null>(null);
// 컴포넌트 ID 카운터
const [idCounter, setIdCounter] = useState(1);
// 선택된 컴포넌트/컨테이너 가져오기
const selectedComponent = selectedComponentId
? layout.components[selectedComponentId]
: null;
const selectedContainer = selectedContainerId
? findContainerV4(layout.root, selectedContainerId)
: null;
// 컴포넌트 드롭
const handleDropComponent = useCallback(
(type: PopComponentType, containerId: string) => {
const componentId = `comp_${idCounter}`;
setIdCounter((prev) => prev + 1);
setLayout((prev) =>
addComponentToV4Layout(prev, componentId, type, containerId, `${type} ${idCounter}`)
);
setSelectedComponentId(componentId);
setSelectedContainerId(null);
},
[idCounter]
);
// 컴포넌트 삭제
const handleDeleteComponent = useCallback((componentId: string) => {
setLayout((prev) => removeComponentFromV4Layout(prev, componentId));
setSelectedComponentId(null);
}, []);
// 컴포넌트 업데이트
const handleUpdateComponent = useCallback(
(componentId: string, updates: Partial<PopComponentDefinitionV4>) => {
setLayout((prev) => updateComponentInV4Layout(prev, componentId, updates));
},
[]
);
// 컨테이너 업데이트
const handleUpdateContainer = useCallback(
(containerId: string, updates: Partial<PopContainerV4>) => {
setLayout((prev) => ({
...prev,
root: updateContainerV4(prev.root, containerId, updates),
}));
},
[]
);
// 선택
const handleSelectComponent = useCallback((id: string | null) => {
setSelectedComponentId(id);
if (id) setSelectedContainerId(null);
}, []);
const handleSelectContainer = useCallback((id: string | null) => {
setSelectedContainerId(id);
if (id) setSelectedComponentId(null);
}, []);
return (
<DndProvider backend={HTML5Backend}>
<div className="flex h-screen w-screen overflow-hidden bg-gray-100">
{/* 왼쪽: 컴포넌트 팔레트 */}
<div className="w-64 shrink-0 border-r bg-white">
<div className="border-b px-4 py-3">
<h2 className="text-sm font-semibold">v4 </h2>
<p className="text-xs text-muted-foreground"> </p>
</div>
<PopPanel />
</div>
{/* 중앙: 캔버스 */}
<div className="flex-1 overflow-hidden">
<PopCanvasV4
layout={layout}
selectedComponentId={selectedComponentId}
selectedContainerId={selectedContainerId}
onSelectComponent={handleSelectComponent}
onSelectContainer={handleSelectContainer}
onDropComponent={handleDropComponent}
onUpdateComponent={handleUpdateComponent}
onUpdateContainer={handleUpdateContainer}
onDeleteComponent={handleDeleteComponent}
/>
</div>
{/* 오른쪽: 속성 패널 */}
<div className="w-72 shrink-0 border-l bg-white">
<ComponentEditorPanelV4
component={selectedComponent}
container={selectedContainer}
onUpdateComponent={
selectedComponentId
? (updates) => handleUpdateComponent(selectedComponentId, updates)
: undefined
}
onUpdateContainer={
selectedContainerId
? (updates) => handleUpdateContainer(selectedContainerId, updates)
: undefined
}
/>
</div>
</div>
</DndProvider>
);
}

View File

@ -1,93 +1,130 @@
"use client"; "use client";
import { useCallback, useRef, useState, useEffect } from "react"; import { useCallback, useRef, useState, useEffect, useMemo } from "react";
import { useDrop } from "react-dnd"; import { useDrop } from "react-dnd";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
PopLayoutDataV3, PopLayoutDataV5,
PopLayoutModeKey, PopComponentDefinitionV5,
PopComponentType, PopComponentType,
GridPosition, PopGridPosition,
MODE_RESOLUTIONS, GridMode,
GRID_BREAKPOINTS,
DEFAULT_COMPONENT_GRID_SIZE,
} from "./types/pop-layout"; } from "./types/pop-layout";
import { DND_ITEM_TYPES, DragItemComponent } from "./panels/PopPanel"; import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet } from "lucide-react";
import { ZoomIn, ZoomOut, Maximize2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import PopRenderer from "./renderers/PopRenderer";
import { mouseToGridPosition, findNextEmptyPosition } from "./utils/gridUtils";
// DnD 타입 상수 (인라인)
const DND_ITEM_TYPES = {
COMPONENT: "component",
} as const;
interface DragItemComponent {
type: typeof DND_ITEM_TYPES.COMPONENT;
componentType: PopComponentType;
}
// ======================================== // ========================================
// 타입 정의 // 프리셋 해상도 (4개 모드)
// ======================================== // ========================================
type DeviceType = "mobile" | "tablet"; const VIEWPORT_PRESETS = [
{ id: "mobile_portrait", label: "모바일 세로", shortLabel: "모바일↕ (4칸)", width: 375, height: 667, icon: Smartphone },
{ id: "mobile_landscape", label: "모바일 가로", shortLabel: "모바일↔ (6칸)", width: 667, height: 375, icon: Smartphone },
{ id: "tablet_portrait", label: "태블릿 세로", shortLabel: "태블릿↕ (8칸)", width: 768, height: 1024, icon: Tablet },
{ id: "tablet_landscape", label: "태블릿 가로", shortLabel: "태블릿↔ (12칸)", width: 1024, height: 768, icon: Tablet },
] as const;
// 모드별 라벨 type ViewportPreset = GridMode;
const MODE_LABELS: Record<PopLayoutModeKey, string> = {
tablet_landscape: "태블릿 가로",
tablet_portrait: "태블릿 세로",
mobile_landscape: "모바일 가로",
mobile_portrait: "모바일 세로",
};
// 컴포넌트 타입별 라벨 // 기본 프리셋 (태블릿 가로)
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = { const DEFAULT_PRESET: ViewportPreset = "tablet_landscape";
"pop-field": "필드",
"pop-button": "버튼",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
"pop-numpad": "숫자패드",
};
// ======================================== // ========================================
// Props // Props
// ======================================== // ========================================
interface PopCanvasProps { interface PopCanvasProps {
layout: PopLayoutDataV3; layout: PopLayoutDataV5;
activeDevice: DeviceType;
activeModeKey: PopLayoutModeKey;
onModeKeyChange: (modeKey: PopLayoutModeKey) => void;
selectedComponentId: string | null; selectedComponentId: string | null;
currentMode: GridMode;
onModeChange: (mode: GridMode) => void;
onSelectComponent: (id: string | null) => void; onSelectComponent: (id: string | null) => void;
onUpdateComponentPosition: (componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void; onDropComponent: (type: PopComponentType, position: PopGridPosition) => void;
onDropComponent: (type: PopComponentType, gridPosition: GridPosition) => void; onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinitionV5>) => void;
onDeleteComponent: (componentId: string) => void; onDeleteComponent: (componentId: string) => void;
onMoveComponent?: (componentId: string, newPosition: PopGridPosition) => void;
onResizeComponent?: (componentId: string, newPosition: PopGridPosition) => void;
} }
// ======================================== // ========================================
// 메인 컴포넌트 // PopCanvas: 그리드 캔버스
// ======================================== // ========================================
export function PopCanvas({
layout,
activeDevice,
activeModeKey,
onModeKeyChange,
selectedComponentId,
onSelectComponent,
onUpdateComponentPosition,
onDropComponent,
onDeleteComponent,
}: PopCanvasProps) {
const { settings, components, layouts } = layout;
const canvasGrid = settings.canvasGrid;
// 줌 상태 (0.3 ~ 1.5 범위) export default function PopCanvas({
const [canvasScale, setCanvasScale] = useState(0.6); layout,
selectedComponentId,
currentMode,
onModeChange,
onSelectComponent,
onDropComponent,
onUpdateComponent,
onDeleteComponent,
onMoveComponent,
onResizeComponent,
}: PopCanvasProps) {
// 줌 상태
const [canvasScale, setCanvasScale] = useState(0.8);
// 커스텀 뷰포트 크기
const [customWidth, setCustomWidth] = useState(1024);
const [customHeight, setCustomHeight] = useState(768);
// 그리드 가이드 표시 여부
const [showGridGuide, setShowGridGuide] = useState(true);
// 패닝 상태 // 패닝 상태
const [isPanning, setIsPanning] = useState(false); const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 }); const [panStart, setPanStart] = useState({ x: 0, y: 0 });
const [isSpacePressed, setIsSpacePressed] = useState(false); const [isSpacePressed, setIsSpacePressed] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
// 드래그 상태
const [isDraggingComponent, setIsDraggingComponent] = useState(false);
const [draggedComponentId, setDraggedComponentId] = useState<string | null>(null);
const [dragStartPos, setDragStartPos] = useState<{ x: number; y: number } | null>(null);
const [dragPreviewPos, setDragPreviewPos] = useState<PopGridPosition | null>(null);
// 현재 뷰포트 해상도
const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!;
const breakpoint = GRID_BREAKPOINTS[currentMode];
// 그리드 라벨 계산
const gridLabels = useMemo(() => {
const columnLabels = Array.from({ length: breakpoint.columns }, (_, i) => i + 1);
const rowLabels = Array.from({ length: 20 }, (_, i) => i + 1);
return { columnLabels, rowLabels };
}, [breakpoint.columns]);
// 줌 컨트롤 // 줌 컨트롤
const handleZoomIn = () => setCanvasScale((prev) => Math.min(1.5, prev + 0.1)); const handleZoomIn = () => setCanvasScale((prev) => Math.min(1.5, prev + 0.1));
const handleZoomOut = () => setCanvasScale((prev) => Math.max(0.3, prev - 0.1)); const handleZoomOut = () => setCanvasScale((prev) => Math.max(0.3, prev - 0.1));
const handleZoomFit = () => setCanvasScale(1.0); const handleZoomFit = () => setCanvasScale(1.0);
// 모드 변경
const handleViewportChange = (mode: GridMode) => {
onModeChange(mode);
const presetData = VIEWPORT_PRESETS.find((p) => p.id === mode)!;
setCustomWidth(presetData.width);
setCustomHeight(presetData.height);
};
// 패닝 // 패닝
const handlePanStart = (e: React.MouseEvent) => { const handlePanStart = (e: React.MouseEvent) => {
const isMiddleButton = e.button === 1; const isMiddleButton = e.button === 1;
const isScrollAreaClick = (e.target as HTMLElement).classList.contains("canvas-scroll-area"); if (isMiddleButton || isSpacePressed) {
if (isMiddleButton || isSpacePressed || isScrollAreaClick) {
setIsPanning(true); setIsPanning(true);
setPanStart({ x: e.clientX, y: e.clientY }); setPanStart({ x: e.clientX, y: e.clientY });
e.preventDefault(); e.preventDefault();
@ -105,12 +142,14 @@ export function PopCanvas({
const handlePanEnd = () => setIsPanning(false); const handlePanEnd = () => setIsPanning(false);
// 마우스 휠 줌 // Ctrl + 휠로 줌 조정
const handleWheel = useCallback((e: React.WheelEvent) => { const handleWheel = (e: React.WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault(); e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1; const delta = e.deltaY > 0 ? -0.1 : 0.1;
setCanvasScale((prev) => Math.max(0.3, Math.min(1.5, prev + delta))); setCanvasScale((prev) => Math.max(0.3, Math.min(1.5, prev + delta)));
}, []); }
};
// Space 키 감지 // Space 키 감지
useEffect(() => { useEffect(() => {
@ -128,42 +167,139 @@ export function PopCanvas({
}; };
}, [isSpacePressed]); }, [isSpacePressed]);
// 초기 로드 시 캔버스 중앙 스크롤 // 컴포넌트 드롭 (팔레트에서)
useEffect(() => { const [{ isOver, canDrop }, drop] = useDrop(
if (containerRef.current) { () => ({
const container = containerRef.current; accept: DND_ITEM_TYPES.COMPONENT,
const timer = setTimeout(() => { drop: (item: DragItemComponent, monitor) => {
const scrollX = (container.scrollWidth - container.clientWidth) / 2; if (!canvasRef.current) return;
const scrollY = (container.scrollHeight - container.clientHeight) / 2;
container.scrollTo(scrollX, scrollY);
}, 100);
return () => clearTimeout(timer);
}
}, [activeDevice]);
// 현재 디바이스의 가로/세로 모드 키 const offset = monitor.getClientOffset();
const landscapeModeKey: PopLayoutModeKey = activeDevice === "tablet" if (!offset) return;
? "tablet_landscape"
: "mobile_landscape"; const canvasRect = canvasRef.current.getBoundingClientRect();
const portraitModeKey: PopLayoutModeKey = activeDevice === "tablet"
? "tablet_portrait" // 마우스 위치 → 그리드 좌표 변환
: "mobile_portrait"; const gridPos = mouseToGridPosition(
offset.x,
offset.y,
canvasRect,
breakpoint.columns,
breakpoint.rowHeight,
breakpoint.gap,
breakpoint.padding
);
// 컴포넌트 기본 크기
const defaultSize = DEFAULT_COMPONENT_GRID_SIZE[item.componentType];
// 다음 빈 위치 찾기
const existingPositions = Object.values(layout.components).map(c => c.position);
const position = findNextEmptyPosition(
existingPositions,
defaultSize.colSpan,
defaultSize.rowSpan,
breakpoint.columns
);
// 컴포넌트 추가
onDropComponent(item.componentType, position);
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
}),
[onDropComponent, breakpoint, layout.components]
);
drop(canvasRef);
// 빈 상태 체크
const isEmpty = Object.keys(layout.components).length === 0;
return ( return (
<div className="relative flex h-full flex-col bg-gray-50"> <div className="flex h-full flex-col bg-gray-50">
{/* 줌 컨트롤 바 */} {/* 상단 컨트롤 */}
<div className="flex shrink-0 items-center justify-end gap-2 border-b bg-white px-4 py-2"> <div className="flex items-center gap-2 border-b bg-white px-4 py-2">
{/* 모드 프리셋 버튼 */}
<div className="flex gap-1">
{VIEWPORT_PRESETS.map((preset) => {
const Icon = preset.icon;
const isActive = currentMode === preset.id;
const isDefault = preset.id === DEFAULT_PRESET;
return (
<Button
key={preset.id}
variant={isActive ? "default" : "outline"}
size="sm"
onClick={() => handleViewportChange(preset.id as GridMode)}
className={cn(
"h-8 gap-1 text-xs",
isActive && "shadow-sm"
)}
>
<Icon className="h-3 w-3" />
{preset.shortLabel}
{isDefault && " (기본)"}
</Button>
);
})}
</div>
<div className="h-4 w-px bg-gray-300" />
{/* 해상도 표시 */}
<div className="text-xs text-muted-foreground">
{customWidth} × {customHeight}
</div>
<div className="flex-1" />
{/* 줌 컨트롤 */}
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
: {Math.round(canvasScale * 100)}% {Math.round(canvasScale * 100)}%
</span> </span>
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomOut} title="줌 아웃"> <Button
<ZoomOut className="h-4 w-4" /> variant="ghost"
size="icon"
onClick={handleZoomOut}
disabled={canvasScale <= 0.3}
className="h-7 w-7"
>
<ZoomOut className="h-3 w-3" />
</Button> </Button>
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomIn} title="줌 인"> <Button
<ZoomIn className="h-4 w-4" /> variant="ghost"
size="icon"
onClick={handleZoomFit}
className="h-7 w-7"
>
<Maximize2 className="h-3 w-3" />
</Button> </Button>
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomFit} title="맞춤 (100%)"> <Button
<Maximize2 className="h-4 w-4" /> variant="ghost"
size="icon"
onClick={handleZoomIn}
disabled={canvasScale >= 1.5}
className="h-7 w-7"
>
<ZoomIn className="h-3 w-3" />
</Button>
</div>
<div className="h-4 w-px bg-gray-300" />
{/* 그리드 가이드 토글 */}
<Button
variant={showGridGuide ? "default" : "outline"}
size="sm"
onClick={() => setShowGridGuide(!showGridGuide)}
className="h-8 text-xs"
>
{showGridGuide ? "ON" : "OFF"}
</Button> </Button>
</div> </div>
@ -171,9 +307,9 @@ export function PopCanvas({
<div <div
ref={containerRef} ref={containerRef}
className={cn( className={cn(
"relative flex-1 overflow-auto", "canvas-scroll-area relative flex-1 overflow-auto bg-gray-100",
isPanning && "cursor-grabbing", isSpacePressed && "cursor-grab",
isSpacePressed && "cursor-grab" isPanning && "cursor-grabbing"
)} )}
onMouseDown={handlePanStart} onMouseDown={handlePanStart}
onMouseMove={handlePanMove} onMouseMove={handlePanMove}
@ -182,381 +318,114 @@ export function PopCanvas({
onWheel={handleWheel} onWheel={handleWheel}
> >
<div <div
className="canvas-scroll-area flex items-center justify-center gap-16" className="relative mx-auto my-8 origin-top"
style={{ padding: "500px", minWidth: "fit-content", minHeight: "fit-content" }}
>
{/* 가로 모드 */}
<DeviceFrame
modeKey={landscapeModeKey}
isActive={landscapeModeKey === activeModeKey}
scale={canvasScale}
canvasGrid={canvasGrid}
layout={layout}
selectedComponentId={selectedComponentId}
onModeKeyChange={onModeKeyChange}
onSelectComponent={onSelectComponent}
onUpdateComponentPosition={onUpdateComponentPosition}
onDropComponent={onDropComponent}
onDeleteComponent={onDeleteComponent}
/>
{/* 세로 모드 */}
<DeviceFrame
modeKey={portraitModeKey}
isActive={portraitModeKey === activeModeKey}
scale={canvasScale}
canvasGrid={canvasGrid}
layout={layout}
selectedComponentId={selectedComponentId}
onModeKeyChange={onModeKeyChange}
onSelectComponent={onSelectComponent}
onUpdateComponentPosition={onUpdateComponentPosition}
onDropComponent={onDropComponent}
onDeleteComponent={onDeleteComponent}
/>
</div>
</div>
</div>
);
}
// ========================================
// CSS Grid 기반 디바이스 프레임 (v3: 컴포넌트 직접 배치)
// ========================================
interface DeviceFrameProps {
modeKey: PopLayoutModeKey;
isActive: boolean;
scale: number;
canvasGrid: { columns: number; rows: number; gap: number };
layout: PopLayoutDataV3;
selectedComponentId: string | null;
onModeKeyChange: (modeKey: PopLayoutModeKey) => void;
onSelectComponent: (id: string | null) => void;
onUpdateComponentPosition: (componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void;
onDropComponent: (type: PopComponentType, gridPosition: GridPosition) => void;
onDeleteComponent: (componentId: string) => void;
}
function DeviceFrame({
modeKey,
isActive,
scale,
canvasGrid,
layout,
selectedComponentId,
onModeKeyChange,
onSelectComponent,
onUpdateComponentPosition,
onDropComponent,
onDeleteComponent,
}: DeviceFrameProps) {
const gridRef = useRef<HTMLDivElement>(null);
const dropRef = useRef<HTMLDivElement>(null);
const { components, layouts } = layout;
const resolution = MODE_RESOLUTIONS[modeKey];
const modeLayout = layouts[modeKey];
const componentPositions = modeLayout.componentPositions;
const componentIds = Object.keys(componentPositions);
const cols = canvasGrid.columns;
const rows = canvasGrid.rows || 24;
const gap = canvasGrid.gap;
// 드래그 상태
const [dragState, setDragState] = useState<{
componentId: string;
startPos: GridPosition;
currentPos: GridPosition;
isDragging: boolean;
} | null>(null);
// 리사이즈 상태
const [resizeState, setResizeState] = useState<{
componentId: string;
startPos: GridPosition;
currentPos: GridPosition;
handle: "se" | "sw" | "ne" | "nw" | "e" | "w" | "n" | "s";
isResizing: boolean;
} | null>(null);
// 라벨
const sizeLabel = `${resolution.width}x${resolution.height}`;
const modeLabel = `${MODE_LABELS[modeKey]} (${sizeLabel})`;
// 마우스 → 그리드 좌표 변환
const getGridPosition = useCallback((clientX: number, clientY: number): { col: number; row: number } => {
if (!gridRef.current) return { col: 1, row: 1 };
const rect = gridRef.current.getBoundingClientRect();
const x = (clientX - rect.left) / scale;
const y = (clientY - rect.top) / scale;
const cellWidth = (resolution.width - gap * (cols + 1)) / cols;
const cellHeight = (resolution.height - gap * (rows + 1)) / rows;
const col = Math.max(1, Math.min(cols, Math.floor((x - gap) / (cellWidth + gap)) + 1));
const row = Math.max(1, Math.min(rows, Math.floor((y - gap) / (cellHeight + gap)) + 1));
return { col, row };
}, [scale, resolution, cols, rows, gap]);
// 드래그 시작
const handleDragStart = useCallback((e: React.MouseEvent, componentId: string) => {
if (!isActive) return;
e.preventDefault();
e.stopPropagation();
const pos = componentPositions[componentId];
setDragState({
componentId,
startPos: { ...pos },
currentPos: { ...pos },
isDragging: true,
});
}, [isActive, componentPositions]);
// 마우스 이동
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (dragState?.isDragging && gridRef.current) {
const { col, row } = getGridPosition(e.clientX, e.clientY);
const newCol = Math.max(1, Math.min(cols - dragState.startPos.colSpan + 1, col));
const newRow = Math.max(1, Math.min(rows - dragState.startPos.rowSpan + 1, row));
setDragState(prev => prev ? {
...prev,
currentPos: { ...prev.startPos, col: newCol, row: newRow }
} : null);
}
if (resizeState?.isResizing && gridRef.current) {
const { col, row } = getGridPosition(e.clientX, e.clientY);
const startPos = resizeState.startPos;
let newPos = { ...startPos };
switch (resizeState.handle) {
case "se":
newPos.colSpan = Math.max(2, col - startPos.col + 1);
newPos.rowSpan = Math.max(2, row - startPos.row + 1);
break;
case "e":
newPos.colSpan = Math.max(2, col - startPos.col + 1);
break;
case "s":
newPos.rowSpan = Math.max(2, row - startPos.row + 1);
break;
case "sw":
const newColSW = Math.min(col, startPos.col + startPos.colSpan - 2);
newPos.col = newColSW;
newPos.colSpan = startPos.col + startPos.colSpan - newColSW;
newPos.rowSpan = Math.max(2, row - startPos.row + 1);
break;
case "w":
const newColW = Math.min(col, startPos.col + startPos.colSpan - 2);
newPos.col = newColW;
newPos.colSpan = startPos.col + startPos.colSpan - newColW;
break;
case "ne":
const newRowNE = Math.min(row, startPos.row + startPos.rowSpan - 2);
newPos.row = newRowNE;
newPos.rowSpan = startPos.row + startPos.rowSpan - newRowNE;
newPos.colSpan = Math.max(2, col - startPos.col + 1);
break;
case "n":
const newRowN = Math.min(row, startPos.row + startPos.rowSpan - 2);
newPos.row = newRowN;
newPos.rowSpan = startPos.row + startPos.rowSpan - newRowN;
break;
case "nw":
const newColNW = Math.min(col, startPos.col + startPos.colSpan - 2);
const newRowNW = Math.min(row, startPos.row + startPos.rowSpan - 2);
newPos.col = newColNW;
newPos.row = newRowNW;
newPos.colSpan = startPos.col + startPos.colSpan - newColNW;
newPos.rowSpan = startPos.row + startPos.rowSpan - newRowNW;
break;
}
newPos.col = Math.max(1, newPos.col);
newPos.row = Math.max(1, newPos.row);
newPos.colSpan = Math.min(cols - newPos.col + 1, newPos.colSpan);
newPos.rowSpan = Math.min(rows - newPos.row + 1, newPos.rowSpan);
setResizeState(prev => prev ? { ...prev, currentPos: newPos } : null);
}
}, [dragState, resizeState, getGridPosition, cols, rows]);
// 드래그/리사이즈 종료
const handleMouseUp = useCallback(() => {
if (dragState?.isDragging) {
onUpdateComponentPosition(dragState.componentId, dragState.currentPos, modeKey);
setDragState(null);
}
if (resizeState?.isResizing) {
onUpdateComponentPosition(resizeState.componentId, resizeState.currentPos, modeKey);
setResizeState(null);
}
}, [dragState, resizeState, onUpdateComponentPosition, modeKey]);
// 리사이즈 시작
const handleResizeStart = useCallback((e: React.MouseEvent, componentId: string, handle: string) => {
if (!isActive) return;
e.preventDefault();
e.stopPropagation();
const pos = componentPositions[componentId];
setResizeState({
componentId,
startPos: { ...pos },
currentPos: { ...pos },
handle: handle as any,
isResizing: true,
});
}, [isActive, componentPositions]);
// 컴포넌트 드롭
const [{ isOver, canDrop }, drop] = useDrop(
() => ({
accept: DND_ITEM_TYPES.COMPONENT,
drop: (item: DragItemComponent, monitor) => {
if (!isActive) return;
const clientOffset = monitor.getClientOffset();
if (!clientOffset || !gridRef.current) return;
const { col, row } = getGridPosition(clientOffset.x, clientOffset.y);
onDropComponent(item.componentType, { col, row, colSpan: 4, rowSpan: 3 });
},
canDrop: () => isActive,
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
}),
[isActive, getGridPosition, onDropComponent]
);
drop(dropRef);
// 현재 표시할 위치
const getDisplayPosition = (componentId: string): GridPosition => {
if (dragState?.componentId === componentId && dragState.isDragging) {
return dragState.currentPos;
}
if (resizeState?.componentId === componentId && resizeState.isResizing) {
return resizeState.currentPos;
}
return componentPositions[componentId];
};
return (
<div className="relative shrink-0">
{/* 모드 라벨 */}
<div
className={cn(
"absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap text-xs font-medium",
isActive ? "text-primary" : "text-muted-foreground"
)}
>
{modeLabel}
</div>
{/* 디바이스 프레임 */}
<div
ref={dropRef}
className={cn(
"relative cursor-pointer overflow-hidden rounded-xl bg-white shadow-lg transition-all",
isActive ? "ring-2 ring-primary ring-offset-2" : "ring-1 ring-gray-200 hover:ring-gray-300",
isOver && canDrop && "ring-2 ring-primary bg-primary/5"
)}
style={{ style={{
width: resolution.width * scale, width: `${customWidth + 32}px`, // 라벨 공간 추가
height: resolution.height * scale, minHeight: `${customHeight + 32}px`,
}} transform: `scale(${canvasScale})`,
onClick={(e) => {
if (e.target === e.currentTarget) {
if (!isActive) onModeKeyChange(modeKey);
else onSelectComponent(null);
}
}}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{/* CSS Grid (뷰어와 동일) */}
<div
ref={gridRef}
className="origin-top-left"
style={{
transform: `scale(${scale})`,
width: resolution.width,
height: resolution.height,
display: "grid",
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 1fr)`,
gap: `${gap}px`,
padding: `${gap}px`,
}} }}
> >
{componentIds.length > 0 ? ( {/* 그리드 라벨 영역 */}
componentIds.map((componentId) => { {showGridGuide && (
const compDef = components[componentId];
if (!compDef) return null;
const pos = getDisplayPosition(componentId);
const isSelected = selectedComponentId === componentId;
const isDragging = dragState?.componentId === componentId && dragState.isDragging;
const isResizing = resizeState?.componentId === componentId && resizeState.isResizing;
return (
<div
key={componentId}
className={cn(
"group relative flex cursor-move items-center justify-center overflow-hidden rounded-lg border-2 bg-white transition-all",
isSelected
? "border-primary ring-2 ring-primary/30"
: "border-gray-200 hover:border-gray-300",
(isDragging || isResizing) && "opacity-80 shadow-xl z-50"
)}
style={{
gridColumn: `${pos.col} / span ${pos.colSpan}`,
gridRow: `${pos.row} / span ${pos.rowSpan}`,
}}
onClick={(e) => {
e.stopPropagation();
if (!isActive) onModeKeyChange(modeKey);
onSelectComponent(componentId);
}}
onMouseDown={(e) => handleDragStart(e, componentId)}
>
{/* 컴포넌트 라벨 */}
<span className="text-xs text-gray-500 select-none">
{compDef.label || COMPONENT_TYPE_LABELS[compDef.type]}
</span>
{/* 리사이즈 핸들 */}
{isActive && isSelected && (
<> <>
<div className="absolute -right-1 -bottom-1 h-3 w-3 cursor-se-resize rounded-full bg-primary" onMouseDown={(e) => handleResizeStart(e, componentId, "se")} /> {/* 열 라벨 (상단) */}
<div className="absolute -left-1 -bottom-1 h-3 w-3 cursor-sw-resize rounded-full bg-primary" onMouseDown={(e) => handleResizeStart(e, componentId, "sw")} /> <div
<div className="absolute -right-1 -top-1 h-3 w-3 cursor-ne-resize rounded-full bg-primary" onMouseDown={(e) => handleResizeStart(e, componentId, "ne")} /> className="flex absolute top-0 left-8"
<div className="absolute -left-1 -top-1 h-3 w-3 cursor-nw-resize rounded-full bg-primary" onMouseDown={(e) => handleResizeStart(e, componentId, "nw")} /> style={{
<div className="absolute right-0 top-1/2 h-6 w-1.5 -translate-y-1/2 cursor-e-resize rounded bg-primary/50" onMouseDown={(e) => handleResizeStart(e, componentId, "e")} /> gap: `${breakpoint.gap}px`,
<div className="absolute left-0 top-1/2 h-6 w-1.5 -translate-y-1/2 cursor-w-resize rounded bg-primary/50" onMouseDown={(e) => handleResizeStart(e, componentId, "w")} /> paddingLeft: `${breakpoint.padding}px`,
<div className="absolute bottom-0 left-1/2 h-1.5 w-6 -translate-x-1/2 cursor-s-resize rounded bg-primary/50" onMouseDown={(e) => handleResizeStart(e, componentId, "s")} /> }}
<div className="absolute top-0 left-1/2 h-1.5 w-6 -translate-x-1/2 cursor-n-resize rounded bg-primary/50" onMouseDown={(e) => handleResizeStart(e, componentId, "n")} /> >
{gridLabels.columnLabels.map((num) => (
<div
key={`col-${num}`}
className="flex items-center justify-center text-xs font-semibold text-blue-500"
style={{
width: `calc((${customWidth}px - ${breakpoint.padding * 2}px - ${breakpoint.gap * (breakpoint.columns - 1)}px) / ${breakpoint.columns})`,
height: "24px",
}}
>
{num}
</div>
))}
</div>
{/* 행 라벨 (좌측) */}
<div
className="flex flex-col absolute top-8 left-0"
style={{
gap: `${breakpoint.gap}px`,
paddingTop: `${breakpoint.padding}px`,
}}
>
{gridLabels.rowLabels.map((num) => (
<div
key={`row-${num}`}
className="flex items-center justify-center text-xs font-semibold text-blue-500"
style={{
width: "24px",
height: `${breakpoint.rowHeight}px`,
}}
>
{num}
</div>
))}
</div>
</> </>
)} )}
</div>
); {/* 디바이스 스크린 */}
})
) : (
<div <div
ref={canvasRef}
className={cn( className={cn(
"col-span-full row-span-full flex items-center justify-center text-sm", "relative rounded-lg border-2 bg-white shadow-xl overflow-hidden",
isOver && canDrop ? "text-primary" : "text-gray-400" canDrop && isOver && "ring-4 ring-primary/20"
)} )}
style={{
width: `${customWidth}px`,
minHeight: `${customHeight}px`,
marginLeft: "32px",
marginTop: "32px",
}}
> >
{isOver && canDrop {isEmpty ? (
? "여기에 컴포넌트를 놓으세요" // 빈 상태
: isActive <div className="flex h-full items-center justify-center p-8">
? "왼쪽 패널에서 컴포넌트를 드래그하세요" <div className="text-center">
: "클릭하여 편집"} <div className="mb-2 text-sm font-medium text-gray-500">
</div> </div>
<div className="text-xs text-gray-400">
{breakpoint.label} - {breakpoint.columns}
</div>
</div>
</div>
) : (
// 그리드 렌더러
<PopRenderer
layout={layout}
viewportWidth={customWidth}
currentMode={currentMode}
isDesignMode={true}
showGridGuide={showGridGuide}
selectedComponentId={selectedComponentId}
onComponentClick={onSelectComponent}
onBackgroundClick={() => onSelectComponent(null)}
/>
)} )}
</div> </div>
</div> </div>
</div> </div>
{/* 하단 정보 */}
<div className="flex items-center justify-between border-t bg-white px-4 py-2">
<div className="text-xs text-muted-foreground">
{breakpoint.label} - {breakpoint.columns} ( : {breakpoint.rowHeight}px)
</div>
<div className="text-xs text-muted-foreground">
Space + 드래그: 패닝 | Ctrl + :
</div>
</div>
</div>
); );
} }

View File

@ -1,391 +0,0 @@
"use client";
import { useCallback, useRef, useState, useEffect } from "react";
import { useDrop } from "react-dnd";
import { cn } from "@/lib/utils";
import {
PopLayoutDataV4,
PopContainerV4,
PopComponentDefinitionV4,
PopComponentType,
PopSizeConstraintV4,
} from "./types/pop-layout";
import { DND_ITEM_TYPES, DragItemComponent } from "./panels/PopPanel";
import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, RotateCcw, Lock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PopFlexRenderer } from "./renderers/PopFlexRenderer";
// ========================================
// 프리셋 해상도 (4개 모드)
// ========================================
const VIEWPORT_PRESETS = [
{ id: "mobile_portrait", label: "모바일 세로", shortLabel: "모바일↕", width: 375, height: 667, icon: Smartphone, isLandscape: false },
{ id: "mobile_landscape", label: "모바일 가로", shortLabel: "모바일↔", width: 667, height: 375, icon: Smartphone, isLandscape: true },
{ id: "tablet_portrait", label: "태블릿 세로", shortLabel: "태블릿↕", width: 768, height: 1024, icon: Tablet, isLandscape: false },
{ id: "tablet_landscape", label: "태블릿 가로", shortLabel: "태블릿↔", width: 1024, height: 768, icon: Tablet, isLandscape: true },
] as const;
type ViewportPreset = (typeof VIEWPORT_PRESETS)[number]["id"];
// 기본 프리셋 (태블릿 가로)
const DEFAULT_PRESET: ViewportPreset = "tablet_landscape";
// ========================================
// Props
// ========================================
interface PopCanvasV4Props {
layout: PopLayoutDataV4;
selectedComponentId: string | null;
selectedContainerId: string | null;
currentMode: ViewportPreset; // 현재 모드
tempLayout?: PopContainerV4 | null; // 임시 레이아웃 (고정 전 미리보기)
onModeChange: (mode: ViewportPreset) => void; // 모드 변경
onSelectComponent: (id: string | null) => void;
onSelectContainer: (id: string | null) => void;
onDropComponent: (type: PopComponentType, containerId: string) => void;
onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinitionV4>) => void;
onUpdateContainer: (containerId: string, updates: Partial<PopContainerV4>) => void;
onDeleteComponent: (componentId: string) => void;
onResizeComponent?: (componentId: string, size: Partial<PopSizeConstraintV4>) => void;
onReorderComponent?: (containerId: string, fromIndex: number, toIndex: number) => void;
onLockLayout?: () => void; // 배치 고정
onResetOverride?: (mode: ViewportPreset) => void; // 오버라이드 초기화
}
// ========================================
// v4 캔버스
//
// 핵심: 단일 캔버스 + 뷰포트 프리뷰
// - 가로/세로 모드 따로 없음
// - 다양한 뷰포트 크기로 미리보기
// ========================================
export function PopCanvasV4({
layout,
selectedComponentId,
selectedContainerId,
currentMode,
tempLayout,
onModeChange,
onSelectComponent,
onSelectContainer,
onDropComponent,
onUpdateComponent,
onUpdateContainer,
onDeleteComponent,
onResizeComponent,
onReorderComponent,
onLockLayout,
onResetOverride,
}: PopCanvasV4Props) {
// 줌 상태
const [canvasScale, setCanvasScale] = useState(0.8);
// 커스텀 뷰포트 크기 (슬라이더)
const [customWidth, setCustomWidth] = useState(1024);
const [customHeight, setCustomHeight] = useState(768);
// 패닝 상태
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
const [isSpacePressed, setIsSpacePressed] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const dropRef = useRef<HTMLDivElement>(null);
// 현재 뷰포트 해상도
const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!;
const viewportWidth = customWidth;
const viewportHeight = customHeight;
// 줌 컨트롤
const handleZoomIn = () => setCanvasScale((prev) => Math.min(1.5, prev + 0.1));
const handleZoomOut = () => setCanvasScale((prev) => Math.max(0.3, prev - 0.1));
const handleZoomFit = () => setCanvasScale(1.0);
// 뷰포트 프리셋 변경
const handleViewportChange = (preset: ViewportPreset) => {
onModeChange(preset); // 부모에게 알림
const presetData = VIEWPORT_PRESETS.find((p) => p.id === preset)!;
setCustomWidth(presetData.width);
setCustomHeight(presetData.height);
};
// 슬라이더로 너비 변경 시 높이도 비율에 맞게 조정
const handleWidthChange = (newWidth: number) => {
setCustomWidth(newWidth);
// 현재 프리셋의 가로세로 비율 유지
const ratio = currentPreset.height / currentPreset.width;
setCustomHeight(Math.round(newWidth * ratio));
};
// 패닝
const handlePanStart = (e: React.MouseEvent) => {
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);
// 마우스 휠 줌
const handleWheel = useCallback((e: React.WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setCanvasScale((prev) => Math.max(0.3, Math.min(1.5, prev + delta)));
}
}, []);
// Space 키 감지 (패닝용)
// 참고: Delete/Backspace 키는 PopDesigner에서 처리 (히스토리 지원)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Space" && !isSpacePressed) setIsSpacePressed(true);
};
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]);
// 컴포넌트 드롭
const [{ isOver, canDrop }, drop] = useDrop(
() => ({
accept: DND_ITEM_TYPES.COMPONENT,
drop: (item: DragItemComponent) => {
// 루트 컨테이너에 추가
onDropComponent(item.componentType, "root");
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
}),
[onDropComponent]
);
drop(dropRef);
// 오버라이드 상태 확인
const hasOverride = (mode: ViewportPreset): boolean => {
if (mode === DEFAULT_PRESET) return false; // 기본 모드는 오버라이드 없음
const override = layout.overrides?.[mode as keyof typeof layout.overrides];
if (!override) return false;
// 컴포넌트 또는 컨테이너 오버라이드가 있으면 true
const hasComponentOverrides = override.components && Object.keys(override.components).length > 0;
const hasContainerOverrides = override.containers && Object.keys(override.containers).length > 0;
return !!(hasComponentOverrides || hasContainerOverrides);
};
return (
<div className="relative flex h-full flex-col bg-gray-100">
{/* 툴바 */}
<div className="flex shrink-0 items-center justify-between border-b bg-white px-4 py-2">
{/* 뷰포트 프리셋 (4개 모드) */}
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground mr-2">:</span>
{VIEWPORT_PRESETS.map((preset) => {
const Icon = preset.icon;
const isActive = currentMode === preset.id;
const isDefault = preset.id === DEFAULT_PRESET;
const isEdited = hasOverride(preset.id);
return (
<Button
key={preset.id}
variant={isActive ? "default" : isEdited ? "secondary" : "outline"}
size="sm"
className={cn(
"h-8 gap-1 text-xs",
isDefault && !isActive && "border-primary/50",
isEdited && !isActive && "border-yellow-500 bg-yellow-50 hover:bg-yellow-100"
)}
onClick={() => handleViewportChange(preset.id)}
title={preset.label}
>
<Icon className={cn("h-4 w-4", preset.isLandscape && "rotate-90")} />
<span className="hidden lg:inline">{preset.shortLabel}</span>
{isDefault && (
<span className="text-[10px] text-muted-foreground ml-1">()</span>
)}
{isEdited && (
<span className="text-[10px] text-yellow-700 ml-1 font-medium">()</span>
)}
</Button>
);
})}
</div>
{/* 고정 버튼 (기본 모드가 아닐 때 표시) */}
{currentMode !== DEFAULT_PRESET && onLockLayout && (
<Button
variant="outline"
size="sm"
className="h-8 gap-1 text-xs"
onClick={onLockLayout}
title="현재 배치를 이 모드 전용으로 고정합니다"
>
<Lock className="h-3 w-3" />
<span></span>
</Button>
)}
{/* 오버라이드 초기화 버튼 (편집된 모드에만 표시) */}
{hasOverride(currentMode) && onResetOverride && (
<Button
variant="outline"
size="sm"
className="h-8 gap-1 text-xs text-yellow-700 border-yellow-500 hover:bg-yellow-50"
onClick={() => onResetOverride(currentMode)}
title="이 모드의 편집 내용을 삭제하고 기본 규칙으로 되돌립니다"
>
<RotateCcw className="h-3 w-3" />
<span> </span>
</Button>
)}
{/* 줌 컨트롤 */}
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{Math.round(canvasScale * 100)}%
</span>
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomOut}>
<ZoomOut className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomIn}>
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomFit}>
<Maximize2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* 뷰포트 크기 슬라이더 */}
<div className="flex shrink-0 items-center gap-4 border-b bg-gray-50 px-4 py-2">
<span className="text-xs text-muted-foreground">:</span>
<input
type="range"
min={320}
max={1200}
value={customWidth}
onChange={(e) => handleWidthChange(Number(e.target.value))}
className="flex-1 h-1 bg-gray-300 rounded-lg appearance-none cursor-pointer"
/>
<span className="text-xs font-mono w-24 text-right">
{customWidth} x {viewportHeight}
</span>
</div>
{/* 캔버스 영역 */}
<div
ref={containerRef}
className={cn(
"relative flex-1 overflow-auto",
isPanning && "cursor-grabbing",
isSpacePressed && "cursor-grab"
)}
onMouseDown={handlePanStart}
onMouseMove={handlePanMove}
onMouseUp={handlePanEnd}
onMouseLeave={handlePanEnd}
onWheel={handleWheel}
>
<div
className="canvas-scroll-area flex items-center justify-center"
style={{ padding: "100px", minWidth: "fit-content", minHeight: "fit-content" }}
>
{/* 디바이스 프레임 */}
<div
ref={dropRef}
className={cn(
"relative rounded-xl bg-white shadow-lg transition-all",
"ring-2 ring-primary ring-offset-2",
isOver && canDrop && "ring-4 ring-green-500 bg-green-50"
)}
style={{
width: viewportWidth * canvasScale,
height: viewportHeight * canvasScale,
overflow: "auto", // 컴포넌트가 넘치면 스크롤 가능
}}
>
{/* 뷰포트 라벨 */}
<div className="absolute -top-6 left-0 text-xs font-medium text-muted-foreground">
{currentPreset.label} ({viewportWidth}x{viewportHeight})
</div>
{/* Flexbox 렌더러 - 최소 높이는 뷰포트 높이, 컨텐츠에 따라 늘어남 */}
<div
className="origin-top-left"
style={{
transform: `scale(${canvasScale})`,
width: viewportWidth,
minHeight: viewportHeight, // height → minHeight로 변경
}}
>
<PopFlexRenderer
layout={layout}
viewportWidth={viewportWidth}
currentMode={currentMode}
tempLayout={tempLayout}
isDesignMode={true}
selectedComponentId={selectedComponentId}
onComponentClick={onSelectComponent}
onContainerClick={onSelectContainer}
onBackgroundClick={() => {
onSelectComponent(null);
onSelectContainer(null);
}}
onComponentResize={onResizeComponent}
onReorderComponent={onReorderComponent}
/>
</div>
{/* 드롭 안내 (빈 상태) */}
{layout.root.children.length === 0 && (
<div
className={cn(
"absolute inset-0 flex items-center justify-center",
isOver && canDrop ? "text-green-600" : "text-gray-400"
)}
>
<div className="text-center">
<p className="text-sm font-medium">
{isOver && canDrop
? "여기에 놓으세요"
: "컴포넌트를 드래그하세요"}
</p>
<p className="text-xs mt-1">
</p>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}
export default PopCanvasV4;

View File

@ -3,9 +3,8 @@
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect } from "react";
import { DndProvider } from "react-dnd"; import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend"; import { HTML5Backend } from "react-dnd-html5-backend";
import { ArrowLeft, Save, Smartphone, Tablet, Undo2, Redo2 } from "lucide-react"; import { ArrowLeft, Save, Undo2, Redo2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { import {
ResizableHandle, ResizableHandle,
ResizablePanel, ResizablePanel,
@ -13,44 +12,22 @@ import {
} from "@/components/ui/resizable"; } from "@/components/ui/resizable";
import { toast } from "sonner"; import { toast } from "sonner";
import { PopCanvas } from "./PopCanvas"; import PopCanvas from "./PopCanvas";
import { PopCanvasV4 } from "./PopCanvasV4"; import ComponentEditorPanel from "./panels/ComponentEditorPanel";
import { PopPanel } from "./panels/PopPanel"; import ComponentPalette from "./panels/ComponentPalette";
import { ComponentPaletteV4 } from "./panels/ComponentPaletteV4";
import { ComponentEditorPanelV4 } from "./panels/ComponentEditorPanelV4";
import { import {
PopLayoutDataV3, PopLayoutDataV5,
PopLayoutDataV4,
PopLayoutModeKey,
PopComponentType, PopComponentType,
GridPosition, PopComponentDefinitionV5,
PopComponentDefinition, PopGridPosition,
PopComponentDefinitionV4, GridMode,
PopContainerV4, createEmptyPopLayoutV5,
PopSizeConstraintV4, isV5Layout,
createEmptyPopLayoutV3, addComponentToV5Layout,
createEmptyPopLayoutV4,
ensureV3Layout,
addComponentToV3Layout,
removeComponentFromV3Layout,
updateComponentPositionInModeV3,
addComponentToV4Layout,
removeComponentFromV4Layout,
updateComponentInV4Layout,
updateContainerV4,
findContainerV4,
isV3Layout,
isV4Layout,
} from "./types/pop-layout"; } from "./types/pop-layout";
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition } from "@/types/screen"; import { ScreenDefinition } from "@/types/screen";
// ========================================
// 레이아웃 모드 타입
// ========================================
type LayoutMode = "v3" | "v4";
type DeviceType = "mobile" | "tablet";
// ======================================== // ========================================
// Props // Props
// ======================================== // ========================================
@ -61,9 +38,7 @@ interface PopDesignerProps {
} }
// ======================================== // ========================================
// 메인 컴포넌트 (v3/v4 통합) // 메인 컴포넌트 (v5 그리드 시스템 전용)
// - 새 화면: v4로 시작
// - 기존 v3 화면: v3로 로드 (하위 호환)
// ======================================== // ========================================
export default function PopDesigner({ export default function PopDesigner({
selectedScreen, selectedScreen,
@ -71,140 +46,77 @@ export default function PopDesigner({
onScreenUpdate, onScreenUpdate,
}: PopDesignerProps) { }: PopDesignerProps) {
// ======================================== // ========================================
// 레이아웃 모드 (데이터에 따라 자동 결정) // 레이아웃 상태
// ======================================== // ========================================
const [layoutMode, setLayoutMode] = useState<LayoutMode>("v4"); const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
// ======================================== // 히스토리
// 레이아웃 상태 (데스크탑 모드와 동일한 방식) const [history, setHistory] = useState<PopLayoutDataV5[]>([]);
// ======================================== const [historyIndex, setHistoryIndex] = useState(-1);
const [layoutV4, setLayoutV4] = useState<PopLayoutDataV4>(createEmptyPopLayoutV4());
const [layoutV3, setLayoutV3] = useState<PopLayoutDataV3>(createEmptyPopLayoutV3());
// 히스토리 (v4용)
const [historyV4, setHistoryV4] = useState<PopLayoutDataV4[]>([]);
const [historyIndexV4, setHistoryIndexV4] = useState(-1);
// 히스토리 (v3용)
const [historyV3, setHistoryV3] = useState<PopLayoutDataV3[]>([]);
const [historyIndexV3, setHistoryIndexV3] = useState(-1);
const [idCounter, setIdCounter] = useState(1);
// UI 상태
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false); const [hasChanges, setHasChanges] = useState(false);
const [idCounter, setIdCounter] = useState(1);
// ========================================
// 히스토리 저장 함수
// ========================================
const saveToHistoryV4 = useCallback((newLayout: PopLayoutDataV4) => {
setHistoryV4((prev) => {
const newHistory = prev.slice(0, historyIndexV4 + 1);
newHistory.push(JSON.parse(JSON.stringify(newLayout))); // 깊은 복사
return newHistory.slice(-50); // 최대 50개
});
setHistoryIndexV4((prev) => Math.min(prev + 1, 49));
}, [historyIndexV4]);
const saveToHistoryV3 = useCallback((newLayout: PopLayoutDataV3) => {
setHistoryV3((prev) => {
const newHistory = prev.slice(0, historyIndexV3 + 1);
newHistory.push(JSON.parse(JSON.stringify(newLayout)));
return newHistory.slice(-50);
});
setHistoryIndexV3((prev) => Math.min(prev + 1, 49));
}, [historyIndexV3]);
// ========================================
// Undo/Redo 함수
// ========================================
const undoV4 = useCallback(() => {
if (historyIndexV4 > 0) {
const newIndex = historyIndexV4 - 1;
const previousLayout = historyV4[newIndex];
if (previousLayout) {
setLayoutV4(JSON.parse(JSON.stringify(previousLayout)));
setHistoryIndexV4(newIndex);
console.log("[Undo V4] 복원됨, index:", newIndex);
}
}
}, [historyIndexV4, historyV4]);
const redoV4 = useCallback(() => {
if (historyIndexV4 < historyV4.length - 1) {
const newIndex = historyIndexV4 + 1;
const nextLayout = historyV4[newIndex];
if (nextLayout) {
setLayoutV4(JSON.parse(JSON.stringify(nextLayout)));
setHistoryIndexV4(newIndex);
console.log("[Redo V4] 복원됨, index:", newIndex);
}
}
}, [historyIndexV4, historyV4]);
const undoV3 = useCallback(() => {
if (historyIndexV3 > 0) {
const newIndex = historyIndexV3 - 1;
const previousLayout = historyV3[newIndex];
if (previousLayout) {
setLayoutV3(JSON.parse(JSON.stringify(previousLayout)));
setHistoryIndexV3(newIndex);
}
}
}, [historyIndexV3, historyV3]);
const redoV3 = useCallback(() => {
if (historyIndexV3 < historyV3.length - 1) {
const newIndex = historyIndexV3 + 1;
const nextLayout = historyV3[newIndex];
if (nextLayout) {
setLayoutV3(JSON.parse(JSON.stringify(nextLayout)));
setHistoryIndexV3(newIndex);
}
}
}, [historyIndexV3, historyV3]);
// 현재 모드의 Undo/Redo
const canUndo = layoutMode === "v4" ? historyIndexV4 > 0 : historyIndexV3 > 0;
const canRedo = layoutMode === "v4"
? historyIndexV4 < historyV4.length - 1
: historyIndexV3 < historyV3.length - 1;
const handleUndo = layoutMode === "v4" ? undoV4 : undoV3;
const handleRedo = layoutMode === "v4" ? redoV4 : redoV3;
// ========================================
// v3용 디바이스/모드 상태
// ========================================
const [activeDevice, setActiveDevice] = useState<DeviceType>("tablet");
const [activeModeKey, setActiveModeKey] = useState<PopLayoutModeKey>("tablet_landscape");
// ========================================
// v4용 뷰포트 모드 상태
// ========================================
type ViewportMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
const [currentViewportMode, setCurrentViewportMode] = useState<ViewportMode>("tablet_landscape");
// v4: 임시 레이아웃 (고정 전 배치) - 다른 모드에서만 사용
const [tempLayout, setTempLayout] = useState<PopContainerV4 | null>(null);
// ========================================
// 선택 상태 // 선택 상태
// ========================================
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null); const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
const [selectedContainerId, setSelectedContainerId] = useState<string | null>(null);
// 선택된 컴포넌트/컨테이너 // 그리드 모드 (4개 프리셋)
const selectedComponentV3: PopComponentDefinition | null = selectedComponentId const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
? layoutV3.components[selectedComponentId] || null
: null; // 선택된 컴포넌트
const selectedComponentV4: PopComponentDefinitionV4 | null = selectedComponentId const selectedComponent: PopComponentDefinitionV5 | null = selectedComponentId
? layoutV4.components[selectedComponentId] || null ? layout.components[selectedComponentId] || null
: null;
const selectedContainer: PopContainerV4 | null = selectedContainerId
? findContainerV4(layoutV4.root, selectedContainerId)
: null; : null;
// ========================================
// 히스토리 관리
// ========================================
const saveToHistory = useCallback((newLayout: PopLayoutDataV5) => {
setHistory((prev) => {
const newHistory = prev.slice(0, historyIndex + 1);
newHistory.push(JSON.parse(JSON.stringify(newLayout)));
// 최대 50개 유지
if (newHistory.length > 50) {
newHistory.shift();
return newHistory;
}
return newHistory;
});
setHistoryIndex((prev) => Math.min(prev + 1, 49));
}, [historyIndex]);
const undo = useCallback(() => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
const previousLayout = history[newIndex];
if (previousLayout) {
setLayout(JSON.parse(JSON.stringify(previousLayout)));
setHistoryIndex(newIndex);
setHasChanges(true);
toast.success("실행 취소됨");
}
}
}, [historyIndex, history]);
const redo = useCallback(() => {
if (historyIndex < history.length - 1) {
const newIndex = historyIndex + 1;
const nextLayout = history[newIndex];
if (nextLayout) {
setLayout(JSON.parse(JSON.stringify(nextLayout)));
setHistoryIndex(newIndex);
setHasChanges(true);
toast.success("다시 실행됨");
}
}
}, [historyIndex, history]);
const canUndo = historyIndex > 0;
const canRedo = historyIndex < history.length - 1;
// ======================================== // ========================================
// 레이아웃 로드 // 레이아웃 로드
// ======================================== // ========================================
@ -216,45 +128,27 @@ export default function PopDesigner({
try { try {
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId); const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
// 유효한 레이아웃인지 확인: if (loadedLayout && isV5Layout(loadedLayout) && Object.keys(loadedLayout.components).length > 0) {
// 1. version 필드 필수 // v5 레이아웃 로드
// 2. 컴포넌트가 있어야 함 (빈 레이아웃은 새 화면 취급) setLayout(loadedLayout);
const hasValidLayout = loadedLayout && loadedLayout.version; setHistory([loadedLayout]);
const hasComponents = loadedLayout?.components && Object.keys(loadedLayout.components).length > 0; setHistoryIndex(0);
console.log(`POP 레이아웃 로드: ${Object.keys(loadedLayout.components).length}개 컴포넌트`);
if (hasValidLayout && hasComponents) {
if (isV4Layout(loadedLayout)) {
// v4 레이아웃
setLayoutV4(loadedLayout);
setHistoryV4([loadedLayout]);
setHistoryIndexV4(0);
setLayoutMode("v4");
console.log(`POP v4 레이아웃 로드: ${Object.keys(loadedLayout.components).length}개 컴포넌트`);
} else { } else {
// v1/v2/v3 → v3로 변환 // 새 화면 또는 빈 레이아웃
const v3Layout = ensureV3Layout(loadedLayout); const emptyLayout = createEmptyPopLayoutV5();
setLayoutV3(v3Layout); setLayout(emptyLayout);
setHistoryV3([v3Layout]); setHistory([emptyLayout]);
setHistoryIndexV3(0); setHistoryIndex(0);
setLayoutMode("v3"); console.log("새 POP 화면 생성 (v5 그리드)");
console.log(`POP v3 레이아웃 로드: ${Object.keys(v3Layout.components).length}개 컴포넌트`);
}
} else {
// 새 화면 또는 빈 레이아웃 → v4로 시작
const emptyLayout = createEmptyPopLayoutV4();
setLayoutV4(emptyLayout);
setHistoryV4([emptyLayout]);
setHistoryIndexV4(0);
setLayoutMode("v4");
} }
} catch (error) { } catch (error) {
console.error("레이아웃 로드 실패:", error); console.error("레이아웃 로드 실패:", error);
toast.error("레이아웃을 불러오는데 실패했습니다"); toast.error("레이아웃을 불러오는데 실패했습니다");
const emptyLayout = createEmptyPopLayoutV4(); const emptyLayout = createEmptyPopLayoutV5();
setLayoutV4(emptyLayout); setLayout(emptyLayout);
setHistoryV4([emptyLayout]); setHistory([emptyLayout]);
setHistoryIndexV4(0); setHistoryIndex(0);
setLayoutMode("v4");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -271,8 +165,7 @@ export default function PopDesigner({
setIsSaving(true); setIsSaving(true);
try { try {
const layoutToSave = layoutMode === "v3" ? layoutV3 : layoutV4; await screenApi.saveLayoutPop(selectedScreen.screenId, layout);
await screenApi.saveLayoutPop(selectedScreen.screenId, layoutToSave);
toast.success("저장되었습니다"); toast.success("저장되었습니다");
setHasChanges(false); setHasChanges(false);
} catch (error) { } catch (error) {
@ -281,271 +174,69 @@ export default function PopDesigner({
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, [selectedScreen?.screenId, layoutMode, layoutV3, layoutV4]); }, [selectedScreen?.screenId, layout]);
// ======================================== // ========================================
// v3: 컴포넌트 핸들러 // 컴포넌트 핸들러
// ======================================== // ========================================
const handleDropComponentV3 = useCallback( const handleDropComponent = useCallback(
(type: PopComponentType, gridPosition: GridPosition) => { (type: PopComponentType, position: PopGridPosition) => {
const newId = `${type}-${Date.now()}`;
const newLayout = addComponentToV3Layout(layoutV3, newId, type, gridPosition);
setLayoutV3(newLayout);
saveToHistoryV3(newLayout);
setSelectedComponentId(newId);
setHasChanges(true);
},
[layoutV3, saveToHistoryV3]
);
const handleUpdateComponentDefinitionV3 = useCallback(
(componentId: string, updates: Partial<PopComponentDefinition>) => {
const newLayout = {
...layoutV3,
components: {
...layoutV3.components,
[componentId]: { ...layoutV3.components[componentId], ...updates },
},
};
setLayoutV3(newLayout);
saveToHistoryV3(newLayout);
setHasChanges(true);
},
[layoutV3, saveToHistoryV3]
);
const handleUpdateComponentPositionV3 = useCallback(
(componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => {
const targetMode = modeKey || activeModeKey;
const newLayout = updateComponentPositionInModeV3(layoutV3, targetMode, componentId, position);
setLayoutV3(newLayout);
saveToHistoryV3(newLayout);
setHasChanges(true);
},
[layoutV3, activeModeKey, saveToHistoryV3]
);
const handleDeleteComponentV3 = useCallback((componentId: string) => {
const newLayout = removeComponentFromV3Layout(layoutV3, componentId);
setLayoutV3(newLayout);
saveToHistoryV3(newLayout);
setSelectedComponentId(null);
setHasChanges(true);
}, [layoutV3, saveToHistoryV3]);
// ========================================
// v4: 컴포넌트 핸들러
// ========================================
const handleDropComponentV4 = useCallback(
(type: PopComponentType, containerId: string) => {
const componentId = `comp_${idCounter}`; const componentId = `comp_${idCounter}`;
setIdCounter((prev) => prev + 1); setIdCounter((prev) => prev + 1);
const newLayout = addComponentToV4Layout(layoutV4, componentId, type, containerId, `${type} ${idCounter}`); const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`);
setLayoutV4(newLayout); setLayout(newLayout);
saveToHistoryV4(newLayout); saveToHistory(newLayout);
setSelectedComponentId(componentId); setSelectedComponentId(componentId);
setSelectedContainerId(null);
setHasChanges(true);
console.log("[V4] 컴포넌트 추가, 히스토리 저장됨");
},
[idCounter, layoutV4, saveToHistoryV4]
);
const handleUpdateComponentV4 = useCallback(
(componentId: string, updates: Partial<PopComponentDefinitionV4>) => {
const newLayout = updateComponentInV4Layout(layoutV4, componentId, updates);
setLayoutV4(newLayout);
saveToHistoryV4(newLayout);
setHasChanges(true); setHasChanges(true);
}, },
[layoutV4, saveToHistoryV4] [idCounter, layout, saveToHistory]
); );
const handleUpdateContainerV4 = useCallback( const handleUpdateComponent = useCallback(
(containerId: string, updates: Partial<PopContainerV4>) => { (componentId: string, updates: Partial<PopComponentDefinitionV5>) => {
if (currentViewportMode === "tablet_landscape") { const existingComponent = layout.components[componentId];
// 기본 모드 (태블릿 가로) → root 직접 수정 ✅
const newLayout = {
...layoutV4,
root: updateContainerV4(layoutV4.root, containerId, updates),
};
setLayoutV4(newLayout);
saveToHistoryV4(newLayout);
setHasChanges(true);
console.log("[기본 모드] root 컨테이너 수정");
} else {
// 다른 모드 → 속성 패널에서 수정 차단됨 (UI에서 비활성화)
toast.warning("기본 모드(태블릿 가로)에서만 속성을 변경할 수 있습니다");
console.log("[다른 모드] 속성 수정 차단");
}
},
[layoutV4, currentViewportMode, saveToHistoryV4]
);
const handleDeleteComponentV4 = useCallback((componentId: string) => {
const newLayout = removeComponentFromV4Layout(layoutV4, componentId);
setLayoutV4(newLayout);
saveToHistoryV4(newLayout);
setSelectedComponentId(null);
setHasChanges(true);
console.log("[V4] 컴포넌트 삭제, 히스토리 저장됨");
}, [layoutV4, saveToHistoryV4]);
// v4: 현재 모드 배치 고정 (오버라이드 저장) 🔥
const handleLockLayoutV4 = useCallback(() => {
if (currentViewportMode === "tablet_landscape") {
toast.info("기본 모드는 고정할 필요가 없습니다");
return;
}
if (!tempLayout) {
toast.info("변경사항이 없습니다");
return;
}
// 임시 레이아웃을 오버라이드에 저장 ✅
const newLayout = {
...layoutV4,
overrides: {
...layoutV4.overrides,
[currentViewportMode]: {
...layoutV4.overrides?.[currentViewportMode as keyof typeof layoutV4.overrides],
containers: {
root: {
direction: tempLayout.direction,
wrap: tempLayout.wrap,
gap: tempLayout.gap,
alignItems: tempLayout.alignItems,
justifyContent: tempLayout.justifyContent,
padding: tempLayout.padding,
children: tempLayout.children, // 순서 고정
}
}
}
}
};
setLayoutV4(newLayout);
saveToHistoryV4(newLayout);
setTempLayout(null); // 임시 레이아웃 초기화
setHasChanges(true);
toast.success(`${currentViewportMode} 모드 배치가 고정되었습니다`);
console.log(`[V4] ${currentViewportMode} 배치 고정됨 (tempLayout → overrides)`);
}, [layoutV4, currentViewportMode, tempLayout, saveToHistoryV4]);
// v4: 오버라이드 초기화 (자동 계산으로 되돌리기)
const handleResetOverrideV4 = useCallback((mode: ViewportMode) => {
if (mode === "tablet_landscape") {
toast.info("기본 모드는 초기화할 수 없습니다");
return;
}
const newOverrides = { ...layoutV4.overrides };
delete newOverrides[mode as keyof typeof newOverrides];
const newLayout = {
...layoutV4,
overrides: Object.keys(newOverrides).length > 0 ? newOverrides : undefined
};
setLayoutV4(newLayout);
saveToHistoryV4(newLayout);
setHasChanges(true);
toast.success(`${mode} 모드 오버라이드가 초기화되었습니다`);
console.log(`[V4] ${mode} 오버라이드 초기화됨`);
}, [layoutV4, saveToHistoryV4]);
// v4: 컴포넌트 크기 조정 (드래그) - 리사이즈 중에는 히스토리 저장 안 함
// 리사이즈 완료 시 별도로 저장해야 함 (TODO: 드래그 종료 시 저장)
const handleResizeComponentV4 = useCallback(
(componentId: string, sizeUpdates: Partial<PopSizeConstraintV4>) => {
const existingComponent = layoutV4.components[componentId];
if (!existingComponent) return; if (!existingComponent) return;
const newLayout = { const newLayout = {
...layoutV4, ...layout,
components: { components: {
...layoutV4.components, ...layout.components,
[componentId]: { [componentId]: {
...existingComponent, ...existingComponent,
size: { ...updates,
...existingComponent.size,
...sizeUpdates,
},
}, },
}, },
}; };
setLayoutV4(newLayout); setLayout(newLayout);
// 리사이즈 중에는 히스토리 저장 안 함 (너무 많아짐) saveToHistory(newLayout);
// saveToHistoryV4(newLayout);
setHasChanges(true); setHasChanges(true);
}, },
[layoutV4] [layout, saveToHistory]
); );
// v4: 컴포넌트 순서 변경 (드래그 앤 드롭) const handleDeleteComponent = useCallback(
const handleReorderComponentV4 = useCallback( (componentId: string) => {
(containerId: string, fromIndex: number, toIndex: number) => { const newComponents = { ...layout.components };
// 컨테이너 찾기 (재귀) delete newComponents[componentId];
const reorderInContainer = (container: PopContainerV4): PopContainerV4 => {
if (container.id === containerId) {
const newChildren = [...container.children];
const [movedItem] = newChildren.splice(fromIndex, 1);
newChildren.splice(toIndex, 0, movedItem);
return { ...container, children: newChildren };
}
// 자식 컨테이너에서도 찾기
return {
...container,
children: container.children.map(child => {
if (typeof child === "object") {
return reorderInContainer(child);
}
return child;
}),
};
};
if (currentViewportMode === "tablet_landscape") {
// 기본 모드 → root 직접 수정 ✅
const newLayout = { const newLayout = {
...layoutV4, ...layout,
root: reorderInContainer(layoutV4.root), components: newComponents,
}; };
setLayoutV4(newLayout); setLayout(newLayout);
saveToHistoryV4(newLayout); saveToHistory(newLayout);
setSelectedComponentId(null);
setHasChanges(true); setHasChanges(true);
console.log("[기본 모드] 컴포넌트 순서 변경 (root 저장)");
} else {
// 다른 모드 → 임시 레이아웃에만 저장 (화면에만 표시, layoutV4는 안 건드림) 🔥
const reorderedRoot = reorderInContainer(layoutV4.root);
setTempLayout(reorderedRoot);
console.log(`[${currentViewportMode}] 컴포넌트 순서 변경 (임시, 고정 필요)`);
toast.info("배치 변경됨. '고정' 버튼을 클릭하여 저장하세요", { duration: 2000 });
}
}, },
[layoutV4, currentViewportMode, saveToHistoryV4] [layout, saveToHistory]
); );
// ========================================
// v3: 디바이스/모드 전환
// ========================================
const handleDeviceChange = useCallback((device: DeviceType) => {
setActiveDevice(device);
setActiveModeKey(device === "tablet" ? "tablet_landscape" : "mobile_landscape");
}, []);
const handleModeKeyChange = useCallback((modeKey: PopLayoutModeKey) => {
setActiveModeKey(modeKey);
}, []);
// ======================================== // ========================================
// 뒤로가기 // 뒤로가기
// ======================================== // ========================================
const handleBack = useCallback(() => { const handleBack = useCallback(() => {
if (hasChanges) { if (hasChanges) {
if (confirm("저장하지 않은 변경사항이 있습니다. 나가시겠습니까?")) { if (confirm("저장하지 않은 변경사항이 있습니다. 정말 나가시겠습니까?")) {
onBackToList(); onBackToList();
} }
} else { } else {
@ -554,7 +245,7 @@ export default function PopDesigner({
}, [hasChanges, onBackToList]); }, [hasChanges, onBackToList]);
// ======================================== // ========================================
// 단축키 처리 (Delete, Undo, Redo) // 단축키 처리
// ======================================== // ========================================
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@ -570,53 +261,35 @@ export default function PopDesigner({
if (e.key === "Delete" || e.key === "Backspace") { if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault(); e.preventDefault();
if (selectedComponentId) { if (selectedComponentId) {
layoutMode === "v3" ? handleDeleteComponentV3(selectedComponentId) : handleDeleteComponentV4(selectedComponentId); handleDeleteComponent(selectedComponentId);
} }
} }
// Ctrl+Z / Cmd+Z: Undo (Shift 안 눌림) // Ctrl+Z: Undo
if (isCtrlOrCmd && key === "z" && !e.shiftKey) { if (isCtrlOrCmd && key === "z" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
console.log("Undo 시도:", { canUndo, layoutMode }); if (canUndo) undo();
if (canUndo) {
handleUndo();
setHasChanges(true);
toast.success("실행 취소됨");
} else {
toast.info("실행 취소할 내용이 없습니다");
}
return; return;
} }
// Ctrl+Shift+Z / Cmd+Shift+Z: Redo // Ctrl+Shift+Z or Ctrl+Y: Redo
if (isCtrlOrCmd && key === "z" && e.shiftKey) { if ((isCtrlOrCmd && key === "z" && e.shiftKey) || (isCtrlOrCmd && key === "y")) {
e.preventDefault(); e.preventDefault();
console.log("Redo 시도:", { canRedo, layoutMode }); if (canRedo) redo();
if (canRedo) {
handleRedo();
setHasChanges(true);
toast.success("다시 실행됨");
} else {
toast.info("다시 실행할 내용이 없습니다");
}
return; return;
} }
// Ctrl+Y / Cmd+Y: Redo (대체) // Ctrl+S: 저장
if (isCtrlOrCmd && key === "y") { if (isCtrlOrCmd && key === "s") {
e.preventDefault(); e.preventDefault();
if (canRedo) { handleSave();
handleRedo();
setHasChanges(true);
toast.success("다시 실행됨");
}
return; return;
} }
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedComponentId, layoutMode, handleDeleteComponentV3, handleDeleteComponentV4, canUndo, canRedo, handleUndo, handleRedo]); }, [selectedComponentId, handleDeleteComponent, canUndo, canRedo, undo, redo, handleSave]);
// ======================================== // ========================================
// 로딩 // 로딩
@ -634,41 +307,25 @@ export default function PopDesigner({
// ======================================== // ========================================
return ( return (
<DndProvider backend={HTML5Backend}> <DndProvider backend={HTML5Backend}>
<div className="flex h-screen flex-col bg-background"> <div className="flex h-screen flex-col">
{/* 툴바 */} {/* 헤더 */}
<div className="flex h-12 items-center justify-between border-b px-4"> <div className="flex items-center justify-between border-b bg-white px-4 py-2">
{/* 왼쪽: 뒤로가기 + 화면명 */} {/* 왼쪽: 뒤로가기 + 화면명 */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={handleBack}> <Button
<ArrowLeft className="mr-1 h-4 w-4" /> variant="ghost"
size="icon"
onClick={handleBack}
className="h-8 w-8"
>
<ArrowLeft className="h-4 w-4" />
</Button> </Button>
<span className="text-sm font-medium"> <div>
{selectedScreen?.screenName || "POP 화면"} <h2 className="text-sm font-medium">{selectedScreen?.screenName}</h2>
</span> <p className="text-xs text-muted-foreground">
{hasChanges && <span className="text-xs text-orange-500">*</span>} (v5)
</p>
</div> </div>
{/* 중앙: 레이아웃 버전 + v3 디바이스 전환 */}
<div className="flex items-center gap-4">
<span className="text-muted-foreground text-xs">
{layoutMode === "v4" ? "자동 레이아웃 (v4)" : "4모드 레이아웃 (v3)"}
</span>
{layoutMode === "v3" && (
<Tabs value={activeDevice} onValueChange={(v) => handleDeviceChange(v as DeviceType)}>
<TabsList className="h-8">
<TabsTrigger value="tablet" className="h-7 px-3 text-xs">
<Tablet className="mr-1 h-3.5 w-3.5" />
릿
</TabsTrigger>
<TabsTrigger value="mobile" className="h-7 px-3 text-xs">
<Smartphone className="mr-1 h-3.5 w-3.5" />
</TabsTrigger>
</TabsList>
</Tabs>
)}
</div> </div>
{/* 오른쪽: Undo/Redo + 저장 */} {/* 오른쪽: Undo/Redo + 저장 */}
@ -679,11 +336,7 @@ export default function PopDesigner({
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-8 w-8"
onClick={() => { onClick={undo}
handleUndo();
setHasChanges(true);
toast.success("실행 취소됨");
}}
disabled={!canUndo} disabled={!canUndo}
title="실행 취소 (Ctrl+Z)" title="실행 취소 (Ctrl+Z)"
> >
@ -693,11 +346,7 @@ export default function PopDesigner({
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-8 w-8"
onClick={() => { onClick={redo}
handleRedo();
setHasChanges(true);
toast.success("다시 실행됨");
}}
disabled={!canRedo} disabled={!canRedo}
title="다시 실행 (Ctrl+Shift+Z)" title="다시 실행 (Ctrl+Shift+Z)"
> >
@ -715,84 +364,41 @@ export default function PopDesigner({
{/* 메인 영역 */} {/* 메인 영역 */}
<ResizablePanelGroup direction="horizontal" className="flex-1"> <ResizablePanelGroup direction="horizontal" className="flex-1">
{/* 왼쪽: 컴포넌트 패널 */} {/* 왼쪽: 컴포넌트 팔레트 */}
<ResizablePanel defaultSize={20} minSize={15} maxSize={30} className="border-r"> <ResizablePanel defaultSize={15} minSize={12} maxSize={20}>
{layoutMode === "v3" ? ( <ComponentPalette />
<PopPanel
layout={layoutV3}
activeModeKey={activeModeKey}
selectedComponentId={selectedComponentId}
selectedComponent={selectedComponentV3}
onUpdateComponentDefinition={handleUpdateComponentDefinitionV3}
onDeleteComponent={handleDeleteComponentV3}
activeDevice={activeDevice}
/>
) : (
<ComponentPaletteV4 />
)}
</ResizablePanel> </ResizablePanel>
<ResizableHandle withHandle /> <ResizableHandle withHandle />
{/* 중앙: 캔버스 */} {/* 중앙: 캔버스 */}
<ResizablePanel defaultSize={layoutMode === "v3" ? 80 : 60}> <ResizablePanel defaultSize={65}>
{layoutMode === "v3" ? (
<PopCanvas <PopCanvas
layout={layoutV3} layout={layout}
activeDevice={activeDevice}
activeModeKey={activeModeKey}
onModeKeyChange={handleModeKeyChange}
selectedComponentId={selectedComponentId} selectedComponentId={selectedComponentId}
currentMode={currentMode}
onModeChange={setCurrentMode}
onSelectComponent={setSelectedComponentId} onSelectComponent={setSelectedComponentId}
onUpdateComponentPosition={handleUpdateComponentPositionV3} onDropComponent={handleDropComponent}
onDropComponent={handleDropComponentV3} onUpdateComponent={handleUpdateComponent}
onDeleteComponent={handleDeleteComponentV3} onDeleteComponent={handleDeleteComponent}
/> />
) : (
<PopCanvasV4
layout={layoutV4}
selectedComponentId={selectedComponentId}
selectedContainerId={selectedContainerId}
currentMode={currentViewportMode}
tempLayout={tempLayout}
onModeChange={setCurrentViewportMode}
onSelectComponent={setSelectedComponentId}
onSelectContainer={setSelectedContainerId}
onDropComponent={handleDropComponentV4}
onUpdateComponent={handleUpdateComponentV4}
onUpdateContainer={handleUpdateContainerV4}
onDeleteComponent={handleDeleteComponentV4}
onResizeComponent={handleResizeComponentV4}
onReorderComponent={handleReorderComponentV4}
onLockLayout={handleLockLayoutV4}
onResetOverride={handleResetOverrideV4}
/>
)}
</ResizablePanel> </ResizablePanel>
{/* 오른쪽: 속성 패널 (v4만) */}
{layoutMode === "v4" && (
<>
<ResizableHandle withHandle /> <ResizableHandle withHandle />
{/* 오른쪽: 속성 패널 */}
<ResizablePanel defaultSize={20} minSize={15} maxSize={30}> <ResizablePanel defaultSize={20} minSize={15} maxSize={30}>
<ComponentEditorPanelV4 <ComponentEditorPanel
component={selectedComponentV4} component={selectedComponent}
container={selectedContainer} currentMode={currentMode}
currentViewportMode={currentViewportMode}
onUpdateComponent={ onUpdateComponent={
selectedComponentId selectedComponentId
? (updates) => handleUpdateComponentV4(selectedComponentId, updates) ? (updates) => handleUpdateComponent(selectedComponentId, updates)
: undefined
}
onUpdateContainer={
selectedContainerId
? (updates) => handleUpdateContainerV4(selectedContainerId, updates)
: undefined : undefined
} }
/> />
</ResizablePanel> </ResizablePanel>
</>
)}
</ResizablePanelGroup> </ResizablePanelGroup>
</div> </div>
</DndProvider> </DndProvider>

View File

@ -1,4 +1,4 @@
// POP 디자이너 컴포넌트 export // POP 디자이너 컴포넌트 export (v5 그리드 시스템)
// 타입 // 타입
export * from "./types"; export * from "./types";
@ -7,18 +7,25 @@ export * from "./types";
export { default as PopDesigner } from "./PopDesigner"; export { default as PopDesigner } from "./PopDesigner";
// 캔버스 // 캔버스
export { PopCanvas } from "./PopCanvas"; export { default as PopCanvas } from "./PopCanvas";
// 패널 // 패널
export { PopPanel } from "./panels/PopPanel"; export { default as ComponentEditorPanel } from "./panels/ComponentEditorPanel";
// 타입 재export (편의) // 렌더러
export { default as PopRenderer } from "./renderers/PopRenderer";
// 유틸리티
export * from "./utils/gridUtils";
// 핵심 타입 재export (편의)
export type { export type {
PopLayoutData, PopLayoutDataV5,
PopSectionData, PopComponentDefinitionV5,
PopComponentData,
PopComponentType, PopComponentType,
GridPosition, PopGridPosition,
PopCanvasGrid, GridMode,
PopInnerGrid, PopGridConfig,
PopDataBinding,
PopDataFlow,
} from "./types/pop-layout"; } from "./types/pop-layout";

View File

@ -2,50 +2,73 @@
import React from "react"; import React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PopComponentDefinition, PopComponentConfig } from "../types/pop-layout"; import {
import { Settings, Database, Link2 } from "lucide-react"; PopComponentDefinitionV5,
PopGridPosition,
GridMode,
GRID_BREAKPOINTS,
PopComponentType,
} from "../types/pop-layout";
import {
Settings,
Database,
Eye,
Grid3x3,
MoveHorizontal,
MoveVertical,
} from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
// ======================================== // ========================================
// Props 정의 // Props
// ======================================== // ========================================
interface ComponentEditorPanelProps { interface ComponentEditorPanelProps {
/** 선택된 컴포넌트 (없으면 null) */ /** 선택된 컴포넌트 */
component: PopComponentDefinition | null; component: PopComponentDefinitionV5 | null;
/** 컴포넌트 설정 변경 시 호출 */ /** 현재 모드 */
onConfigChange?: (config: Partial<PopComponentConfig>) => void; currentMode: GridMode;
/** 컴포넌트 라벨 변경 시 호출 */ /** 컴포넌트 업데이트 */
onLabelChange?: (label: string) => void; onUpdateComponent?: (updates: Partial<PopComponentDefinitionV5>) => void;
/** 추가 className */ /** 추가 className */
className?: string; className?: string;
} }
// ======================================== // ========================================
// 컴포넌트 편집 패널 // 컴포넌트 타입별 라벨
// // ========================================
// 역할: const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
// - 선택된 컴포넌트의 설정을 편집 "pop-field": "필드",
// - 3개 탭: 기본 설정 / 데이터 바인딩 / 데이터 연결 "pop-button": "버튼",
// "pop-list": "리스트",
// TODO: "pop-indicator": "인디케이터",
// - 타입별 상세 설정 UI 구현 "pop-scanner": "스캐너",
// - 데이터 바인딩 UI 구현 "pop-numpad": "숫자패드",
// - 데이터 플로우 UI 구현 "pop-spacer": "스페이서",
"pop-break": "줄바꿈",
};
// ========================================
// 컴포넌트 편집 패널 (v5 그리드 시스템)
// ======================================== // ========================================
export function ComponentEditorPanel({ export default function ComponentEditorPanel({
component, component,
onConfigChange, currentMode,
onLabelChange, onUpdateComponent,
className, className,
}: ComponentEditorPanelProps) { }: ComponentEditorPanelProps) {
// 컴포넌트가 선택되지 않은 경우 const breakpoint = GRID_BREAKPOINTS[currentMode];
// 선택된 컴포넌트 없음
if (!component) { if (!component) {
return ( return (
<div className={cn("flex h-full flex-col", className)}> <div className={cn("flex h-full flex-col", className)}>
<div className="border-b px-4 py-3"> <div className="border-b px-4 py-3">
<h3 className="text-sm font-medium"> </h3> <h3 className="text-sm font-medium"></h3>
</div> </div>
<div className="flex flex-1 items-center justify-center p-4 text-sm text-muted-foreground"> <div className="flex flex-1 items-center justify-center p-4 text-sm text-muted-foreground">
@ -54,50 +77,75 @@ export function ComponentEditorPanel({
); );
} }
// 기본 모드 여부
const isDefaultMode = currentMode === "tablet_landscape";
return ( return (
<div className={cn("flex h-full flex-col", className)}> <div className={cn("flex h-full flex-col bg-white", className)}>
{/* 헤더 */} {/* 헤더 */}
<div className="border-b px-4 py-3"> <div className="border-b px-4 py-3">
<h3 className="text-sm font-medium"> <h3 className="text-sm font-medium">
{component.label || getComponentTypeLabel(component.type)} {component.label || COMPONENT_TYPE_LABELS[component.type]}
</h3> </h3>
<p className="text-xs text-muted-foreground">{component.type}</p> <p className="text-xs text-muted-foreground">{component.type}</p>
{!isDefaultMode && (
<p className="text-xs text-amber-600 mt-1">
(릿 )
</p>
)}
</div> </div>
{/* 탭 컨텐츠 */} {/* 탭 */}
<Tabs defaultValue="settings" className="flex-1"> <Tabs defaultValue="position" className="flex flex-1 flex-col">
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2"> <TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2">
<TabsTrigger value="position" className="gap-1 text-xs">
<Grid3x3 className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="settings" className="gap-1 text-xs"> <TabsTrigger value="settings" className="gap-1 text-xs">
<Settings className="h-3 w-3" /> <Settings className="h-3 w-3" />
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="visibility" className="gap-1 text-xs">
<Eye className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="data" className="gap-1 text-xs"> <TabsTrigger value="data" className="gap-1 text-xs">
<Database className="h-3 w-3" /> <Database className="h-3 w-3" />
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="flow" className="gap-1 text-xs">
<Link2 className="h-3 w-3" />
</TabsTrigger>
</TabsList> </TabsList>
{/* 기본 설정 탭 */} {/* 위치 탭 */}
<TabsContent value="settings" className="flex-1 overflow-auto p-4"> <TabsContent value="position" className="flex-1 overflow-auto p-4">
<ComponentSettingsForm <PositionForm
component={component} component={component}
onConfigChange={onConfigChange} currentMode={currentMode}
onLabelChange={onLabelChange} isDefaultMode={isDefaultMode}
columns={breakpoint.columns}
onUpdate={onUpdateComponent}
/> />
</TabsContent> </TabsContent>
{/* 데이터 바인딩 탭 (뼈대) */} {/* 설정 탭 */}
<TabsContent value="data" className="flex-1 overflow-auto p-4"> <TabsContent value="settings" className="flex-1 overflow-auto p-4">
<DataBindingPlaceholder /> <ComponentSettingsForm
component={component}
onUpdate={onUpdateComponent}
/>
</TabsContent> </TabsContent>
{/* 데이터 연결 탭 (뼈대) */} {/* 표시 탭 */}
<TabsContent value="flow" className="flex-1 overflow-auto p-4"> <TabsContent value="visibility" className="flex-1 overflow-auto p-4">
<DataFlowPlaceholder /> <VisibilityForm
component={component}
onUpdate={onUpdateComponent}
/>
</TabsContent>
{/* 데이터 탭 */}
<TabsContent value="data" className="flex-1 overflow-auto p-4">
<DataBindingPlaceholder />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
@ -105,41 +153,186 @@ export function ComponentEditorPanel({
} }
// ======================================== // ========================================
// 컴포넌트 설정 폼 // 위치 편집 폼
// ========================================
interface PositionFormProps {
component: PopComponentDefinitionV5;
currentMode: GridMode;
isDefaultMode: boolean;
columns: number;
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
}
function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate }: PositionFormProps) {
const { position } = component;
const handlePositionChange = (field: keyof PopGridPosition, value: number) => {
// 범위 체크
let clampedValue = Math.max(1, value);
if (field === "col" || field === "colSpan") {
clampedValue = Math.min(columns, clampedValue);
}
if (field === "colSpan" && position.col + clampedValue - 1 > columns) {
clampedValue = columns - position.col + 1;
}
onUpdate?.({
position: {
...position,
[field]: clampedValue,
},
});
};
return (
<div className="space-y-6">
{/* 그리드 정보 */}
<div className="rounded-lg bg-gray-50 p-3">
<p className="text-xs font-medium text-gray-700 mb-1">
: {GRID_BREAKPOINTS[currentMode].label}
</p>
<p className="text-xs text-muted-foreground">
{columns} ×
</p>
</div>
{/* 열 위치 */}
<div className="space-y-2">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveHorizontal className="h-3 w-3" />
(Col)
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={columns}
value={position.col}
onChange={(e) => handlePositionChange("col", parseInt(e.target.value) || 1)}
disabled={!isDefaultMode}
className="h-8 w-20 text-xs"
/>
<span className="text-xs text-muted-foreground">
(1~{columns})
</span>
</div>
</div>
{/* 행 위치 */}
<div className="space-y-2">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveVertical className="h-3 w-3" />
(Row)
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
value={position.row}
onChange={(e) => handlePositionChange("row", parseInt(e.target.value) || 1)}
disabled={!isDefaultMode}
className="h-8 w-20 text-xs"
/>
<span className="text-xs text-muted-foreground">
(1~)
</span>
</div>
</div>
<div className="h-px bg-gray-200" />
{/* 열 크기 */}
<div className="space-y-2">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveHorizontal className="h-3 w-3" />
(ColSpan)
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={columns}
value={position.colSpan}
onChange={(e) => handlePositionChange("colSpan", parseInt(e.target.value) || 1)}
disabled={!isDefaultMode}
className="h-8 w-20 text-xs"
/>
<span className="text-xs text-muted-foreground">
(1~{columns})
</span>
</div>
<p className="text-xs text-muted-foreground">
{Math.round((position.colSpan / columns) * 100)}%
</p>
</div>
{/* 행 크기 */}
<div className="space-y-2">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveVertical className="h-3 w-3" />
(RowSpan)
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
value={position.rowSpan}
onChange={(e) => handlePositionChange("rowSpan", parseInt(e.target.value) || 1)}
disabled={!isDefaultMode}
className="h-8 w-20 text-xs"
/>
<span className="text-xs text-muted-foreground">
</span>
</div>
<p className="text-xs text-muted-foreground">
: {position.rowSpan * GRID_BREAKPOINTS[currentMode].rowHeight}px
</p>
</div>
{/* 비활성화 안내 */}
{!isDefaultMode && (
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3">
<p className="text-xs text-amber-800">
(릿 ) .
.
</p>
</div>
)}
</div>
);
}
// ========================================
// 설정 폼
// ======================================== // ========================================
interface ComponentSettingsFormProps { interface ComponentSettingsFormProps {
component: PopComponentDefinition; component: PopComponentDefinitionV5;
onConfigChange?: (config: Partial<PopComponentConfig>) => void; onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
onLabelChange?: (label: string) => void;
} }
function ComponentSettingsForm({ function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormProps) {
component,
onConfigChange,
onLabelChange,
}: ComponentSettingsFormProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 라벨 입력 */} {/* 라벨 */}
<div className="space-y-1.5"> <div className="space-y-2">
<label className="text-xs font-medium"></label> <Label className="text-xs font-medium"></Label>
<input <Input
type="text" type="text"
className="h-8 w-full rounded border border-input bg-background px-2 text-sm"
value={component.label || ""} value={component.label || ""}
onChange={(e) => onLabelChange?.(e.target.value)} onChange={(e) => onUpdate?.({ label: e.target.value })}
placeholder="컴포넌트 라벨" placeholder="컴포넌트 이름"
className="h-8 text-xs"
/> />
</div> </div>
{/* 타입별 설정 (TODO: 상세 구현) */} {/* 컴포넌트 타입별 설정 (추후 구현) */}
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4"> <div className="rounded-lg bg-gray-50 p-3">
<p className="text-center text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{getComponentTypeLabel(component.type)} {component.type} Phase 4
</p>
<p className="mt-1 text-center text-xs text-muted-foreground">
( )
</p> </p>
</div> </div>
</div> </div>
@ -147,69 +340,82 @@ function ComponentSettingsForm({
} }
// ======================================== // ========================================
// 데이터 바인딩 플레이스홀더 (뼈대) // 표시/숨김 폼
// ========================================
interface VisibilityFormProps {
component: PopComponentDefinitionV5;
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
}
function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
const modes: Array<{ key: GridMode; label: string }> = [
{ key: "tablet_landscape", label: "태블릿 가로 (12칸)" },
{ key: "tablet_portrait", label: "태블릿 세로 (8칸)" },
{ key: "mobile_landscape", label: "모바일 가로 (6칸)" },
{ key: "mobile_portrait", label: "모바일 세로 (4칸)" },
];
const handleVisibilityChange = (mode: GridMode, visible: boolean) => {
onUpdate?.({
visibility: {
...component.visibility,
[mode]: visible,
},
});
};
return (
<div className="space-y-4">
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
{modes.map((mode) => {
const isVisible = component.visibility?.[mode.key] !== false;
return (
<div key={mode.key} className="flex items-center gap-2">
<Checkbox
id={`visibility-${mode.key}`}
checked={isVisible}
onCheckedChange={(checked) =>
handleVisibilityChange(mode.key, checked === true)
}
/>
<label
htmlFor={`visibility-${mode.key}`}
className="text-xs cursor-pointer"
>
{mode.label}
</label>
</div>
);
})}
</div>
<div className="rounded-lg bg-blue-50 border border-blue-200 p-3">
<p className="text-xs text-blue-800">
</p>
</div>
</div>
);
}
// ========================================
// 데이터 바인딩 플레이스홀더
// ======================================== // ========================================
function DataBindingPlaceholder() { function DataBindingPlaceholder() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4"> <div className="rounded-lg bg-gray-50 p-4 text-center">
<div className="flex flex-col items-center gap-2"> <Database className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
<Database className="h-8 w-8 text-gray-400" /> <p className="text-sm font-medium text-gray-700"> </p>
<p className="text-center text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground mt-1">
Phase 4
</p> </p>
<p className="text-center text-xs text-muted-foreground">
</p>
<p className="mt-2 text-center text-xs text-gray-400">
( )
</p>
</div>
</div> </div>
</div> </div>
); );
} }
// ========================================
// 데이터 플로우 플레이스홀더 (뼈대)
// ========================================
function DataFlowPlaceholder() {
return (
<div className="space-y-4">
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
<div className="flex flex-col items-center gap-2">
<Link2 className="h-8 w-8 text-gray-400" />
<p className="text-center text-xs text-muted-foreground">
</p>
<p className="text-center text-xs text-muted-foreground">
/ /
</p>
<p className="mt-2 text-center text-xs text-gray-400">
( )
</p>
</div>
</div>
</div>
);
}
// ========================================
// 헬퍼 함수
// ========================================
function getComponentTypeLabel(type: string): string {
const labels: Record<string, string> = {
"pop-field": "필드",
"pop-button": "버튼",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
"pop-numpad": "넘패드",
};
return labels[type] || type;
}
export default ComponentEditorPanel;

View File

@ -1,721 +0,0 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
import {
PopComponentDefinitionV4,
PopSizeConstraintV4,
PopContainerV4,
PopComponentType,
} from "../types/pop-layout";
import {
Settings,
Database,
Link2,
MoveHorizontal,
MoveVertical,
Square,
Maximize2,
AlignCenter,
Eye,
} from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
// ========================================
// Props 정의
// ========================================
interface ComponentEditorPanelV4Props {
/** 선택된 컴포넌트 */
component: PopComponentDefinitionV4 | null;
/** 선택된 컨테이너 */
container: PopContainerV4 | null;
/** 현재 뷰포트 모드 */
currentViewportMode?: "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
/** 컴포넌트 업데이트 */
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV4>) => void;
/** 컨테이너 업데이트 */
onUpdateContainer?: (updates: Partial<PopContainerV4>) => void;
/** 추가 className */
className?: string;
}
// ========================================
// 컴포넌트 타입별 라벨
// ========================================
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"pop-field": "필드",
"pop-button": "버튼",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
"pop-numpad": "숫자패드",
"pop-spacer": "스페이서",
"pop-break": "줄바꿈",
};
// ========================================
// v4 컴포넌트 편집 패널
//
// 핵심:
// - 크기 제약 편집 (fixed/fill/hug)
// - 반응형 숨김 설정
// - 개별 정렬 설정
// ========================================
export function ComponentEditorPanelV4({
component,
container,
currentViewportMode = "tablet_landscape",
onUpdateComponent,
onUpdateContainer,
className,
}: ComponentEditorPanelV4Props) {
// 아무것도 선택되지 않은 경우
if (!component && !container) {
return (
<div className={cn("flex h-full flex-col", className)}>
<div className="border-b px-4 py-3">
<h3 className="text-sm font-medium"></h3>
</div>
<div className="flex flex-1 items-center justify-center p-4 text-sm text-muted-foreground">
</div>
</div>
);
}
// 컨테이너가 선택된 경우
if (container) {
const isNonDefaultMode = currentViewportMode !== "tablet_landscape";
return (
<div className={cn("flex h-full flex-col", className)}>
<div className="border-b px-4 py-3">
<h3 className="text-sm font-medium"> </h3>
<p className="text-xs text-muted-foreground">{container.id}</p>
{isNonDefaultMode && (
<p className="text-xs text-amber-600 mt-1">
'고정'
</p>
)}
</div>
<div className="flex-1 overflow-auto p-4">
<ContainerSettingsForm
container={container}
currentViewportMode={currentViewportMode}
onUpdate={onUpdateContainer}
/>
</div>
</div>
);
}
// 컴포넌트가 선택된 경우
return (
<div className={cn("flex h-full flex-col", className)}>
{/* 헤더 */}
<div className="border-b px-4 py-3">
<h3 className="text-sm font-medium">
{component!.label || COMPONENT_TYPE_LABELS[component!.type]}
</h3>
<p className="text-xs text-muted-foreground">{component!.type}</p>
</div>
{/* 탭 컨텐츠 */}
<Tabs defaultValue="size" className="flex-1">
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2">
<TabsTrigger value="size" className="gap-1 text-xs">
<Maximize2 className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="settings" className="gap-1 text-xs">
<Settings className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="visibility" className="gap-1 text-xs">
<Eye className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="data" className="gap-1 text-xs">
<Database className="h-3 w-3" />
</TabsTrigger>
</TabsList>
{/* 크기 제약 탭 */}
<TabsContent value="size" className="flex-1 overflow-auto p-4">
<SizeConstraintForm
component={component!}
onUpdate={onUpdateComponent}
/>
</TabsContent>
{/* 기본 설정 탭 */}
<TabsContent value="settings" className="flex-1 overflow-auto p-4">
<ComponentSettingsForm
component={component!}
onUpdate={onUpdateComponent}
/>
</TabsContent>
{/* 모드별 표시 탭 */}
<TabsContent value="visibility" className="flex-1 overflow-auto p-4">
<VisibilityForm
component={component!}
onUpdate={onUpdateComponent}
/>
</TabsContent>
{/* 데이터 바인딩 탭 */}
<TabsContent value="data" className="flex-1 overflow-auto p-4">
<DataBindingPlaceholder />
</TabsContent>
</Tabs>
</div>
);
}
// ========================================
// 크기 제약 폼
// ========================================
interface SizeConstraintFormProps {
component: PopComponentDefinitionV4;
onUpdate?: (updates: Partial<PopComponentDefinitionV4>) => void;
}
function SizeConstraintForm({ component, onUpdate }: SizeConstraintFormProps) {
const { size } = component;
const handleSizeChange = (
field: keyof PopSizeConstraintV4,
value: string | number | undefined
) => {
onUpdate?.({
size: {
...size,
[field]: value,
},
});
};
return (
<div className="space-y-6">
{/* 너비 설정 */}
<div className="space-y-3">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveHorizontal className="h-3 w-3" />
</Label>
<div className="flex gap-1">
<SizeButton
active={size.width === "fixed"}
onClick={() => handleSizeChange("width", "fixed")}
label="고정"
description="px"
/>
<SizeButton
active={size.width === "fill"}
onClick={() => handleSizeChange("width", "fill")}
label="채움"
description="flex"
/>
<SizeButton
active={size.width === "hug"}
onClick={() => handleSizeChange("width", "hug")}
label="맞춤"
description="auto"
/>
</div>
{/* 고정 너비 입력 */}
{size.width === "fixed" && (
<div className="flex items-center gap-2">
<Input
type="number"
className="h-8 w-24 text-xs"
value={size.fixedWidth || ""}
onChange={(e) =>
handleSizeChange(
"fixedWidth",
e.target.value ? Number(e.target.value) : undefined
)
}
placeholder="너비"
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
)}
{/* 채움일 때 최소/최대 */}
{size.width === "fill" && (
<div className="flex items-center gap-2">
<Input
type="number"
className="h-8 w-20 text-xs"
value={size.minWidth || ""}
onChange={(e) =>
handleSizeChange(
"minWidth",
e.target.value ? Number(e.target.value) : undefined
)
}
placeholder="최소"
/>
<span className="text-xs text-muted-foreground">~</span>
<Input
type="number"
className="h-8 w-20 text-xs"
value={size.maxWidth || ""}
onChange={(e) =>
handleSizeChange(
"maxWidth",
e.target.value ? Number(e.target.value) : undefined
)
}
placeholder="최대"
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
)}
</div>
{/* 높이 설정 */}
<div className="space-y-3">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveVertical className="h-3 w-3" />
</Label>
<div className="flex gap-1">
<SizeButton
active={size.height === "fixed"}
onClick={() => handleSizeChange("height", "fixed")}
label="고정"
description="px"
/>
<SizeButton
active={size.height === "fill"}
onClick={() => handleSizeChange("height", "fill")}
label="채움"
description="flex"
/>
<SizeButton
active={size.height === "hug"}
onClick={() => handleSizeChange("height", "hug")}
label="맞춤"
description="auto"
/>
</div>
{/* 고정 높이 입력 */}
{size.height === "fixed" && (
<div className="flex items-center gap-2">
<Input
type="number"
className="h-8 w-24 text-xs"
value={size.fixedHeight || ""}
onChange={(e) =>
handleSizeChange(
"fixedHeight",
e.target.value ? Number(e.target.value) : undefined
)
}
placeholder="높이"
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
)}
{/* 채움일 때 최소 */}
{size.height === "fill" && (
<div className="flex items-center gap-2">
<Input
type="number"
className="h-8 w-24 text-xs"
value={size.minHeight || ""}
onChange={(e) =>
handleSizeChange(
"minHeight",
e.target.value ? Number(e.target.value) : undefined
)
}
placeholder="최소 높이"
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
)}
</div>
{/* 개별 정렬 */}
<div className="space-y-3">
<Label className="text-xs font-medium flex items-center gap-1">
<AlignCenter className="h-3 w-3" />
</Label>
<Select
value={component.alignSelf || "none"}
onValueChange={(value) =>
onUpdate?.({
alignSelf: value === "none" ? undefined : (value as any),
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컨테이너 설정 따름" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
<SelectItem value="start"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="end"></SelectItem>
<SelectItem value="stretch"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}
// ========================================
// 크기 버튼 컴포넌트
// ========================================
interface SizeButtonProps {
active: boolean;
onClick: () => void;
label: string;
description: string;
}
function SizeButton({ active, onClick, label, description }: SizeButtonProps) {
return (
<button
className={cn(
"flex-1 flex flex-col items-center gap-0.5 rounded-md border p-2 text-xs transition-colors",
active
? "border-primary bg-primary/10 text-primary"
: "border-gray-200 bg-white text-gray-600 hover:bg-gray-50"
)}
onClick={onClick}
>
<span className="font-medium">{label}</span>
<span className="text-[10px] text-muted-foreground">{description}</span>
</button>
);
}
// ========================================
// 컨테이너 설정 폼
// ========================================
interface ContainerSettingsFormProps {
container: PopContainerV4;
currentViewportMode?: "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
onUpdate?: (updates: Partial<PopContainerV4>) => void;
}
function ContainerSettingsForm({
container,
currentViewportMode = "tablet_landscape",
onUpdate,
}: ContainerSettingsFormProps) {
const isNonDefaultMode = currentViewportMode !== "tablet_landscape";
return (
<div className="space-y-6">
{/* 방향 */}
<div className="space-y-3">
<Label className="text-xs font-medium"></Label>
<div className="flex gap-1">
<Button
variant={container.direction === "horizontal" ? "default" : "outline"}
size="sm"
className="flex-1 h-8 text-xs"
onClick={() => onUpdate?.({ direction: "horizontal" })}
disabled={isNonDefaultMode}
>
</Button>
<Button
variant={container.direction === "vertical" ? "default" : "outline"}
size="sm"
className="flex-1 h-8 text-xs"
onClick={() => onUpdate?.({ direction: "vertical" })}
disabled={isNonDefaultMode}
>
</Button>
</div>
{isNonDefaultMode && (
<p className="text-[10px] text-amber-600">
'고정'
</p>
)}
</div>
{/* 줄바꿈 */}
<div className="space-y-3">
<Label className="text-xs font-medium"></Label>
<div className="flex gap-1">
<Button
variant={container.wrap ? "default" : "outline"}
size="sm"
className="flex-1 h-8 text-xs"
onClick={() => onUpdate?.({ wrap: true })}
disabled={isNonDefaultMode}
>
</Button>
<Button
variant={!container.wrap ? "default" : "outline"}
size="sm"
className="flex-1 h-8 text-xs"
onClick={() => onUpdate?.({ wrap: false })}
disabled={isNonDefaultMode}
>
</Button>
</div>
</div>
{/* 간격 */}
<div className="space-y-3">
<Label className="text-xs font-medium"> (gap)</Label>
<div className="flex items-center gap-2">
<Input
type="number"
className="h-8 w-24 text-xs"
value={container.gap}
onChange={(e) => onUpdate?.({ gap: Number(e.target.value) || 0 })}
disabled={isNonDefaultMode}
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
</div>
{/* 패딩 */}
<div className="space-y-3">
<Label className="text-xs font-medium"></Label>
<div className="flex items-center gap-2">
<Input
type="number"
className="h-8 w-24 text-xs"
value={container.padding || 0}
onChange={(e) =>
onUpdate?.({ padding: Number(e.target.value) || undefined })
}
disabled={isNonDefaultMode}
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
</div>
{/* 정렬 */}
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<Select
value={container.alignItems}
onValueChange={(value) => onUpdate?.({ alignItems: value as any })}
disabled={isNonDefaultMode}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="start"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="end"></SelectItem>
<SelectItem value="stretch"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<Select
value={container.justifyContent}
onValueChange={(value) => onUpdate?.({ justifyContent: value as any })}
disabled={isNonDefaultMode}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="start"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="end"></SelectItem>
<SelectItem value="space-between"> </SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}
// ========================================
// 컴포넌트 설정 폼
// ========================================
interface ComponentSettingsFormProps {
component: PopComponentDefinitionV4;
onUpdate?: (updates: Partial<PopComponentDefinitionV4>) => void;
}
function ComponentSettingsForm({
component,
onUpdate,
}: ComponentSettingsFormProps) {
return (
<div className="space-y-4">
{/* 라벨 입력 */}
<div className="space-y-1.5">
<Label className="text-xs font-medium"></Label>
<Input
type="text"
className="h-8 text-xs"
value={component.label || ""}
onChange={(e) => onUpdate?.({ label: e.target.value })}
placeholder="컴포넌트 라벨"
/>
</div>
{/* 타입별 설정 (TODO: 상세 구현) */}
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
<p className="text-center text-xs text-muted-foreground">
{COMPONENT_TYPE_LABELS[component.type]}
</p>
<p className="mt-1 text-center text-xs text-muted-foreground">
( )
</p>
</div>
</div>
);
}
// ========================================
// 모드별 표시/숨김 폼
// ========================================
interface VisibilityFormProps {
component: PopComponentDefinitionV4;
onUpdate?: (updates: Partial<PopComponentDefinitionV4>) => void;
}
function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
const modes = [
{ key: "tablet_landscape" as const, label: "태블릿 가로 (1024×768)" },
{ key: "tablet_portrait" as const, label: "태블릿 세로 (768×1024)" },
{ key: "mobile_landscape" as const, label: "모바일 가로 (667×375)" },
{ key: "mobile_portrait" as const, label: "모바일 세로 (375×667)" },
];
return (
<div className="space-y-4">
<div className="space-y-3">
<Label className="text-xs font-medium flex items-center gap-1">
<Eye className="h-3 w-3" />
</Label>
<p className="text-xs text-muted-foreground">
</p>
<div className="space-y-2 rounded-lg border p-3">
{modes.map(({ key, label }) => {
const isChecked = component.visibility?.[key] !== false;
return (
<div key={key} className="flex items-center gap-2">
<input
type="checkbox"
id={`visibility-${key}`}
checked={isChecked}
onChange={(e) => {
onUpdate?.({
visibility: {
...component.visibility,
[key]: e.target.checked,
},
});
}}
className="h-4 w-4 rounded border-gray-300"
/>
<label
htmlFor={`visibility-${key}`}
className="text-xs cursor-pointer flex-1"
>
{label}
</label>
{!isChecked && (
<span className="text-xs text-muted-foreground">()</span>
)}
</div>
);
})}
</div>
</div>
{/* 반응형 숨김 (픽셀 기반) */}
<div className="space-y-3">
<Label className="text-xs font-medium"> ( )</Label>
<div className="flex items-center gap-2">
<Input
type="number"
className="h-8 w-24 text-xs"
value={component.hideBelow || ""}
onChange={(e) =>
onUpdate?.({
hideBelow: e.target.value ? Number(e.target.value) : undefined,
})
}
placeholder="없음"
/>
<span className="text-xs text-muted-foreground">px </span>
</div>
<p className="text-xs text-muted-foreground">
: 500 500px
</p>
</div>
</div>
);
}
// ========================================
// 데이터 바인딩 플레이스홀더
// ========================================
function DataBindingPlaceholder() {
return (
<div className="space-y-4">
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
<div className="flex flex-col items-center gap-2">
<Database className="h-8 w-8 text-gray-400" />
<p className="text-center text-xs text-muted-foreground">
</p>
<p className="text-center text-xs text-muted-foreground">
- -
</p>
<p className="mt-2 text-center text-xs text-gray-400">
( )
</p>
</div>
</div>
</div>
);
}
export default ComponentEditorPanelV4;

View File

@ -0,0 +1,96 @@
"use client";
import { useDrag } from "react-dnd";
import { cn } from "@/lib/utils";
import { PopComponentType } from "../types/pop-layout";
import { Square } from "lucide-react";
// DnD 타입 상수
const DND_ITEM_TYPES = {
COMPONENT: "component",
} as const;
// 컴포넌트 정의
interface PaletteItem {
type: PopComponentType;
label: string;
icon: React.ElementType;
description: string;
}
const PALETTE_ITEMS: PaletteItem[] = [
{
type: "pop-sample",
label: "샘플 박스",
icon: Square,
description: "크기 조정 테스트용",
},
];
// 드래그 가능한 컴포넌트 아이템
function DraggablePaletteItem({ item }: { item: PaletteItem }) {
const [{ isDragging }, drag] = useDrag(
() => ({
type: DND_ITEM_TYPES.COMPONENT,
item: { type: DND_ITEM_TYPES.COMPONENT, componentType: item.type },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}),
[item.type]
);
const Icon = item.icon;
return (
<div
ref={drag}
className={cn(
"flex cursor-grab items-center gap-3 rounded-md border bg-white p-3",
"transition-all hover:border-primary hover:shadow-sm",
isDragging && "opacity-50 cursor-grabbing"
)}
>
<div className="flex h-9 w-9 items-center justify-center rounded bg-muted">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{item.label}</div>
<div className="text-xs text-muted-foreground truncate">
{item.description}
</div>
</div>
</div>
);
}
// 컴포넌트 팔레트 패널
export default function ComponentPalette() {
return (
<div className="flex h-full flex-col bg-gray-50">
{/* 헤더 */}
<div className="border-b bg-white px-4 py-3">
<h3 className="text-sm font-semibold"></h3>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 컴포넌트 목록 */}
<div className="flex-1 overflow-y-auto p-3">
<div className="space-y-2">
{PALETTE_ITEMS.map((item) => (
<DraggablePaletteItem key={item.type} item={item} />
))}
</div>
</div>
{/* 하단 안내 */}
<div className="border-t bg-white px-4 py-3">
<p className="text-xs text-muted-foreground">
Tip: 캔버스의
</p>
</div>
</div>
);
}

View File

@ -1,159 +0,0 @@
"use client";
import { useDrag } from "react-dnd";
import {
Type,
MousePointer,
List,
Activity,
ScanLine,
Calculator,
GripVertical,
Space,
WrapText,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { PopComponentType } from "../types/pop-layout";
import { DND_ITEM_TYPES, DragItemComponent } from "./PopPanel";
// ========================================
// 컴포넌트 팔레트 정의
// ========================================
const COMPONENT_PALETTE: {
type: PopComponentType;
label: string;
icon: React.ElementType;
description: string;
}[] = [
{
type: "pop-field",
label: "필드",
icon: Type,
description: "텍스트, 숫자 등 데이터 입력",
},
{
type: "pop-button",
label: "버튼",
icon: MousePointer,
description: "저장, 삭제 등 액션 실행",
},
{
type: "pop-list",
label: "리스트",
icon: List,
description: "데이터 목록 (카드 템플릿 지원)",
},
{
type: "pop-indicator",
label: "인디케이터",
icon: Activity,
description: "KPI, 상태 표시",
},
{
type: "pop-scanner",
label: "스캐너",
icon: ScanLine,
description: "바코드/QR 스캔",
},
{
type: "pop-numpad",
label: "숫자패드",
icon: Calculator,
description: "숫자 입력 전용",
},
{
type: "pop-spacer",
label: "스페이서",
icon: Space,
description: "빈 공간 (정렬용)",
},
{
type: "pop-break",
label: "줄바꿈",
icon: WrapText,
description: "강제 줄바꿈 (flex-basis: 100%)",
},
];
// ========================================
// v4 컴포넌트 팔레트
// ========================================
export function ComponentPaletteV4() {
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b p-3">
<div className="text-sm font-medium text-muted-foreground">
: v4 ( )
</div>
<div className="text-xs text-muted-foreground mt-1">
</div>
</div>
{/* 컴포넌트 목록 */}
<div className="flex-1 overflow-auto p-3">
<div className="text-xs font-medium text-muted-foreground mb-2">
</div>
<div className="space-y-1">
{COMPONENT_PALETTE.map((item) => (
<DraggableComponentV4
key={item.type}
type={item.type}
label={item.label}
icon={item.icon}
description={item.description}
/>
))}
</div>
<div className="mt-4 text-xs text-muted-foreground">
</div>
</div>
</div>
);
}
// ========================================
// 드래그 가능한 컴포넌트 아이템
// ========================================
interface DraggableComponentV4Props {
type: PopComponentType;
label: string;
icon: React.ElementType;
description: string;
}
function DraggableComponentV4({ type, label, icon: Icon, description }: DraggableComponentV4Props) {
const [{ isDragging }, drag] = useDrag(
() => ({
type: DND_ITEM_TYPES.COMPONENT,
item: { type: DND_ITEM_TYPES.COMPONENT, componentType: type } as DragItemComponent,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}),
[type]
);
return (
<div
ref={drag}
className={cn(
"flex items-center gap-2 rounded-lg border p-2 cursor-grab transition-all",
"hover:bg-accent hover:border-primary/30",
isDragging && "opacity-50 cursor-grabbing"
)}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
<Icon className="h-4 w-4 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{label}</div>
<div className="text-xs text-muted-foreground truncate">{description}</div>
</div>
</div>
);
}
export default ComponentPaletteV4;

View File

@ -1,368 +0,0 @@
"use client";
import { useState } from "react";
import { useDrag } from "react-dnd";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Plus,
Settings,
Type,
MousePointer,
List,
Activity,
ScanLine,
Calculator,
Trash2,
ChevronDown,
GripVertical,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
PopLayoutDataV3,
PopLayoutModeKey,
PopComponentDefinition,
PopComponentType,
MODE_RESOLUTIONS,
} from "../types/pop-layout";
// ========================================
// 드래그 아이템 타입
// ========================================
export const DND_ITEM_TYPES = {
COMPONENT: "component",
} as const;
export interface DragItemComponent {
type: typeof DND_ITEM_TYPES.COMPONENT;
componentType: PopComponentType;
}
// ========================================
// 컴포넌트 팔레트 정의
// ========================================
const COMPONENT_PALETTE: {
type: PopComponentType;
label: string;
icon: React.ElementType;
description: string;
}[] = [
{
type: "pop-field",
label: "필드",
icon: Type,
description: "텍스트, 숫자 등 데이터 입력",
},
{
type: "pop-button",
label: "버튼",
icon: MousePointer,
description: "저장, 삭제 등 액션 실행",
},
{
type: "pop-list",
label: "리스트",
icon: List,
description: "데이터 목록 (카드 템플릿 지원)",
},
{
type: "pop-indicator",
label: "인디케이터",
icon: Activity,
description: "KPI, 상태 표시",
},
{
type: "pop-scanner",
label: "스캐너",
icon: ScanLine,
description: "바코드/QR 스캔",
},
{
type: "pop-numpad",
label: "숫자패드",
icon: Calculator,
description: "숫자 입력 전용",
},
];
// ========================================
// Props (v3: 섹션 없음)
// ========================================
interface PopPanelProps {
layout: PopLayoutDataV3;
activeModeKey: PopLayoutModeKey;
selectedComponentId: string | null;
selectedComponent: PopComponentDefinition | null;
onUpdateComponentDefinition: (id: string, updates: Partial<PopComponentDefinition>) => void;
onDeleteComponent: (id: string) => void;
activeDevice: "mobile" | "tablet";
}
// ========================================
// 메인 컴포넌트
// ========================================
export function PopPanel({
layout,
activeModeKey,
selectedComponentId,
selectedComponent,
onUpdateComponentDefinition,
onDeleteComponent,
activeDevice,
}: PopPanelProps) {
const [activeTab, setActiveTab] = useState<string>("components");
// 현재 모드의 컴포넌트 위치
const currentModeLayout = layout.layouts[activeModeKey];
const selectedComponentPosition = selectedComponentId
? currentModeLayout.componentPositions[selectedComponentId]
: null;
return (
<div className="flex h-full flex-col">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex h-full flex-col"
>
<TabsList className="mx-2 mt-2 grid w-auto grid-cols-2">
<TabsTrigger value="components" className="text-xs">
<Plus className="mr-1 h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger value="edit" className="text-xs">
<Settings className="mr-1 h-3.5 w-3.5" />
</TabsTrigger>
</TabsList>
{/* 컴포넌트 탭 */}
<TabsContent value="components" className="flex-1 overflow-auto p-2">
<div className="space-y-4">
{/* 현재 모드 표시 */}
<div className="rounded-lg bg-muted p-2">
<p className="text-xs font-medium text-muted-foreground">
: {getModeLabel(activeModeKey)}
</p>
<p className="text-[10px] text-muted-foreground">
{MODE_RESOLUTIONS[activeModeKey].width} x {MODE_RESOLUTIONS[activeModeKey].height}
</p>
</div>
{/* 컴포넌트 팔레트 */}
<div>
<h4 className="mb-2 text-xs font-medium text-muted-foreground">
</h4>
<div className="space-y-1">
{COMPONENT_PALETTE.map((item) => (
<DraggableComponentItem
key={item.type}
type={item.type}
label={item.label}
icon={item.icon}
description={item.description}
/>
))}
</div>
<p className="mt-2 text-xs text-muted-foreground">
</p>
</div>
</div>
</TabsContent>
{/* 편집 탭 */}
<TabsContent value="edit" className="flex-1 overflow-auto p-2">
{selectedComponent && selectedComponentPosition ? (
<ComponentEditorV3
component={selectedComponent}
position={selectedComponentPosition}
activeModeKey={activeModeKey}
onUpdateDefinition={(updates) =>
onUpdateComponentDefinition(selectedComponent.id, updates)
}
onDelete={() => onDeleteComponent(selectedComponent.id)}
/>
) : (
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}
// ========================================
// 모드 라벨 헬퍼
// ========================================
function getModeLabel(modeKey: PopLayoutModeKey): string {
const labels: Record<PopLayoutModeKey, string> = {
tablet_landscape: "태블릿 가로",
tablet_portrait: "태블릿 세로",
mobile_landscape: "모바일 가로",
mobile_portrait: "모바일 세로",
};
return labels[modeKey];
}
// ========================================
// 드래그 가능한 컴포넌트 아이템
// ========================================
interface DraggableComponentItemProps {
type: PopComponentType;
label: string;
icon: React.ElementType;
description: string;
}
function DraggableComponentItem({
type,
label,
icon: Icon,
description,
}: DraggableComponentItemProps) {
const [{ isDragging }, drag] = useDrag(() => ({
type: DND_ITEM_TYPES.COMPONENT,
item: { type: DND_ITEM_TYPES.COMPONENT, componentType: type } as DragItemComponent,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}));
return (
<div
ref={drag}
className={cn(
"flex cursor-grab items-start gap-3 rounded-lg border p-3 transition-all",
"hover:bg-accent hover:text-accent-foreground",
isDragging && "opacity-50 ring-2 ring-primary"
)}
>
<GripVertical className="mt-0.5 h-4 w-4 text-gray-400" />
<Icon className="mt-0.5 h-4 w-4 shrink-0" />
<div className="flex-1 space-y-1">
<p className="text-sm font-medium leading-none">{label}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
);
}
// ========================================
// v3 컴포넌트 편집기
// ========================================
interface ComponentEditorV3Props {
component: PopComponentDefinition;
position: { col: number; row: number; colSpan: number; rowSpan: number };
activeModeKey: PopLayoutModeKey;
onUpdateDefinition: (updates: Partial<PopComponentDefinition>) => void;
onDelete: () => void;
}
function ComponentEditorV3({
component,
position,
activeModeKey,
onUpdateDefinition,
onDelete,
}: ComponentEditorV3Props) {
const [isPositionOpen, setIsPositionOpen] = useState(true);
// 컴포넌트 타입 라벨
const typeLabels: Record<PopComponentType, string> = {
"pop-field": "필드",
"pop-button": "버튼",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
"pop-numpad": "숫자패드",
};
return (
<div className="space-y-4">
{/* 컴포넌트 기본 정보 */}
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium">{typeLabels[component.type]}</span>
<p className="text-[10px] text-muted-foreground">{component.id}</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:bg-destructive/10"
onClick={onDelete}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* 라벨 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
<Input
value={component.label || ""}
onChange={(e) => onUpdateDefinition({ label: e.target.value })}
placeholder="컴포넌트 이름"
className="h-8 text-xs"
/>
</div>
{/* 현재 모드 위치 (읽기 전용) */}
<Collapsible open={isPositionOpen} onOpenChange={setIsPositionOpen}>
<CollapsibleTrigger className="flex w-full items-center justify-between py-2 text-sm font-medium">
<ChevronDown
className={cn(
"h-4 w-4 transition-transform",
isPositionOpen && "rotate-180"
)}
/>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-2">
<div className="rounded-lg bg-muted p-3">
<p className="mb-2 text-xs font-medium">{getModeLabel(activeModeKey)}</p>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className="font-medium">{position.col}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className="font-medium">{position.row}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className="font-medium">{position.colSpan}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className="font-medium">{position.rowSpan}</span>
</div>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
/ .
(/) .
</p>
</CollapsibleContent>
</Collapsible>
{/* TODO: 컴포넌트별 설정 (config) */}
<div className="rounded-lg border border-dashed p-3">
<p className="text-xs text-muted-foreground text-center">
</p>
</div>
</div>
);
}

View File

@ -1,4 +1,3 @@
// POP 디자이너 패널 export // POP 디자이너 패널 export (v5 그리드 시스템)
export { PopPanel } from "./PopPanel"; export { default as ComponentEditorPanel } from "./ComponentEditorPanel";
export { ComponentEditorPanel } from "./ComponentEditorPanel"; export { default as ComponentPalette } from "./ComponentPalette";
export { ComponentEditorPanelV4 } from "./ComponentEditorPanelV4";

View File

@ -1,237 +0,0 @@
"use client";
import React, { forwardRef } from "react";
import { cn } from "@/lib/utils";
import {
PopComponentDefinition,
PopComponentType,
GridPosition,
} from "../types/pop-layout";
// ========================================
// Props 정의
// ========================================
interface ComponentRendererProps {
/** 컴포넌트 정의 (타입, 라벨, 설정 등) */
component: PopComponentDefinition;
/** 컴포넌트의 그리드 위치 (섹션 내부 그리드 기준) */
position: GridPosition;
/** 디자인 모드 여부 (true: 편집 가능, false: 뷰어 모드) */
isDesignMode?: boolean;
/** 선택된 상태인지 */
isSelected?: boolean;
/** 컴포넌트 클릭 시 호출 */
onClick?: () => void;
/** 추가 className */
className?: string;
}
// ========================================
// 컴포넌트 렌더러
//
// 역할:
// - 관리자가 설정한 GridPosition(col, row, colSpan, rowSpan)을
// 그대로 CSS Grid에 반영
// - 디자이너/뷰어 모두에서 동일한 렌더링 보장
// - 디자인 모드에서는 선택 상태 표시
// ========================================
export const ComponentRenderer = forwardRef<HTMLDivElement, ComponentRendererProps>(
function ComponentRenderer(
{
component,
position,
isDesignMode = false,
isSelected = false,
onClick,
className,
},
ref
) {
const { type, label, config } = component;
return (
<div
ref={ref}
className={cn(
// 기본 스타일
"relative flex flex-col overflow-hidden rounded border bg-white transition-all",
// 디자인 모드 스타일
isDesignMode && "cursor-pointer",
// 선택 상태 스타일
isSelected
? "border-primary ring-2 ring-primary/30 z-10"
: "border-gray-200 hover:border-gray-300",
className
)}
style={{
// 관리자가 설정한 GridPosition을 그대로 반영
gridColumn: `${position.col} / span ${position.colSpan}`,
gridRow: `${position.row} / span ${position.rowSpan}`,
}}
onClick={(e) => {
e.stopPropagation();
onClick?.();
}}
>
{/* 컴포넌트 타입별 미리보기 렌더링 */}
<ComponentPreview type={type} label={label} config={config} />
</div>
);
}
);
// ========================================
// 컴포넌트 타입별 미리보기
// ========================================
interface ComponentPreviewProps {
type: PopComponentType;
label?: string;
config?: any;
}
function ComponentPreview({ type, label, config }: ComponentPreviewProps) {
switch (type) {
case "pop-field":
return <FieldPreview label={label} config={config} />;
case "pop-button":
return <ButtonPreview label={label} config={config} />;
case "pop-list":
return <ListPreview label={label} config={config} />;
case "pop-indicator":
return <IndicatorPreview label={label} config={config} />;
case "pop-scanner":
return <ScannerPreview label={label} config={config} />;
case "pop-numpad":
return <NumpadPreview label={label} config={config} />;
default:
return (
<div className="flex h-full items-center justify-center p-2 text-xs text-gray-400">
{label || type}
</div>
);
}
}
// ========================================
// 개별 컴포넌트 미리보기
// ========================================
function FieldPreview({ label, config }: { label?: string; config?: any }) {
const fieldType = config?.fieldType || "text";
const placeholder = config?.placeholder || "입력하세요";
const required = config?.required || false;
return (
<div className="flex h-full w-full flex-col gap-1 p-2">
{/* 라벨 */}
<span className="text-xs font-medium text-gray-600">
{label || "필드"}
{required && <span className="ml-1 text-destructive">*</span>}
</span>
{/* 입력 필드 미리보기 */}
<div className="flex h-8 w-full items-center rounded border border-gray-200 bg-gray-50 px-2 text-xs text-gray-400">
{placeholder}
</div>
</div>
);
}
function ButtonPreview({ label, config }: { label?: string; config?: any }) {
const buttonType = config?.buttonType || "action";
const variant = buttonType === "submit" ? "bg-primary text-white" : "bg-gray-100 text-gray-700";
return (
<div className="flex h-full w-full items-center justify-center p-2">
<div
className={cn(
"flex h-10 w-full items-center justify-center rounded font-medium",
variant
)}
>
{label || "버튼"}
</div>
</div>
);
}
function ListPreview({ label, config }: { label?: string; config?: any }) {
const itemCount = config?.itemsPerPage || 5;
return (
<div className="flex h-full w-full flex-col gap-1 p-2">
{/* 라벨 */}
<span className="text-xs font-medium text-gray-600">{label || "리스트"}</span>
{/* 리스트 아이템 미리보기 */}
<div className="flex flex-1 flex-col gap-0.5 overflow-hidden">
{Array.from({ length: Math.min(3, itemCount) }).map((_, i) => (
<div key={i} className="h-4 rounded bg-gray-100" />
))}
{itemCount > 3 && (
<div className="text-center text-[10px] text-gray-400">
+{itemCount - 3} more
</div>
)}
</div>
</div>
);
}
function IndicatorPreview({ label, config }: { label?: string; config?: any }) {
const indicatorType = config?.indicatorType || "kpi";
const unit = config?.unit || "";
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
{/* 라벨 */}
<span className="text-[10px] text-gray-500">{label || "KPI"}</span>
{/* 값 미리보기 */}
<span className="text-xl font-bold text-primary">
0{unit && <span className="text-sm font-normal text-gray-500">{unit}</span>}
</span>
</div>
);
}
function ScannerPreview({ label, config }: { label?: string; config?: any }) {
const scannerType = config?.scannerType || "camera";
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
{/* QR 아이콘 */}
<div className="flex h-10 w-10 items-center justify-center rounded border-2 border-dashed border-gray-300">
<span className="text-xs text-gray-400">QR</span>
</div>
{/* 라벨 */}
<span className="text-[10px] text-gray-500">{label || "스캐너"}</span>
</div>
);
}
function NumpadPreview({ label, config }: { label?: string; config?: any }) {
const keys = [1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"];
return (
<div className="flex h-full w-full flex-col gap-1 p-2">
{/* 라벨 */}
{label && (
<span className="text-[10px] text-gray-500">{label}</span>
)}
{/* 넘패드 미리보기 */}
<div className="grid flex-1 grid-cols-3 gap-0.5">
{keys.map((key) => (
<div
key={key}
className="flex items-center justify-center rounded bg-gray-100 text-[8px]"
>
{key}
</div>
))}
</div>
</div>
);
}
export default ComponentRenderer;

View File

@ -1,912 +0,0 @@
"use client";
import React, { useMemo, useState, useCallback, useRef } from "react";
import { useDrag, useDrop } from "react-dnd";
import { cn } from "@/lib/utils";
import {
PopLayoutDataV4,
PopContainerV4,
PopComponentDefinitionV4,
PopResponsiveRuleV4,
PopSizeConstraintV4,
PopComponentType,
} from "../types/pop-layout";
// 드래그 아이템 타입
const DND_COMPONENT_REORDER = "POP_COMPONENT_REORDER";
interface DragItem {
type: string;
componentId: string;
containerId: string;
index: number;
}
// ========================================
// Props 정의
// ========================================
interface PopFlexRendererProps {
/** v4 레이아웃 데이터 */
layout: PopLayoutDataV4;
/** 현재 뷰포트 너비 (반응형 규칙 적용용) */
viewportWidth: number;
/** 현재 뷰포트 모드 (오버라이드 병합용) */
currentMode?: "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
/** 임시 레이아웃 (고정 전 미리보기) */
tempLayout?: PopContainerV4 | null;
/** 디자인 모드 여부 */
isDesignMode?: boolean;
/** 선택된 컴포넌트 ID */
selectedComponentId?: string | null;
/** 컴포넌트 클릭 */
onComponentClick?: (componentId: string) => void;
/** 컨테이너 클릭 */
onContainerClick?: (containerId: string) => void;
/** 배경 클릭 */
onBackgroundClick?: () => void;
/** 컴포넌트 크기 변경 */
onComponentResize?: (componentId: string, size: Partial<PopSizeConstraintV4>) => void;
/** 컴포넌트 순서 변경 */
onReorderComponent?: (containerId: string, fromIndex: number, toIndex: number) => void;
/** 추가 className */
className?: string;
}
// ========================================
// 컴포넌트 타입별 라벨
// ========================================
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"pop-field": "필드",
"pop-button": "버튼",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
"pop-numpad": "숫자패드",
"pop-spacer": "스페이서",
"pop-break": "줄바꿈",
};
// ========================================
// v4 Flexbox 렌더러
//
// 핵심 역할:
// - v4 레이아웃을 Flexbox CSS로 렌더링
// - 제약조건(fill/fixed/hug) 기반 크기 계산
// - 반응형 규칙(breakpoint) 자동 적용
// ========================================
export function PopFlexRenderer({
layout,
viewportWidth,
currentMode = "tablet_landscape",
tempLayout,
isDesignMode = false,
selectedComponentId,
onComponentClick,
onContainerClick,
onBackgroundClick,
onComponentResize,
onReorderComponent,
className,
}: PopFlexRendererProps) {
const { root, components, settings, overrides } = layout;
// 오버라이드 병합 로직 (컨테이너) 🔥
const getMergedRoot = (): PopContainerV4 => {
// 1. 임시 레이아웃이 있으면 최우선 (고정 전 미리보기)
if (tempLayout) {
return tempLayout;
}
// 2. 기본 모드면 root 그대로 반환
if (currentMode === "tablet_landscape") {
return root;
}
// 3. 다른 모드면 오버라이드 병합
const override = overrides?.[currentMode]?.containers?.root;
if (override) {
return {
...root,
...override, // 오버라이드 속성으로 덮어쓰기
};
}
// 4. 오버라이드 없으면 기본값
return root;
};
// visibility 체크 함수 🆕
const isComponentVisible = (component: PopComponentDefinitionV4): boolean => {
if (!component.visibility) return true; // 기본값: 표시
const modeVisibility = component.visibility[currentMode as keyof typeof component.visibility];
return modeVisibility !== false; // undefined도 true로 취급
};
// 컴포넌트 오버라이드 병합 🆕
const getMergedComponent = (baseComponent: PopComponentDefinitionV4): PopComponentDefinitionV4 => {
if (currentMode === "tablet_landscape") return baseComponent;
const componentOverride = overrides?.[currentMode]?.components?.[baseComponent.id];
if (!componentOverride) return baseComponent;
// 깊은 병합 (config, size)
return {
...baseComponent,
...componentOverride,
size: { ...baseComponent.size, ...componentOverride.size },
config: { ...baseComponent.config, ...componentOverride.config },
};
};
const effectiveRoot = getMergedRoot();
// 빈 상태는 PopCanvasV4에서 표시하므로 여기서는 투명 배경만 렌더링
if (effectiveRoot.children.length === 0) {
return (
<div
className={cn("h-full w-full", className)}
onClick={onBackgroundClick}
/>
);
}
return (
<div
className={cn("relative min-h-full w-full bg-white", className)}
onClick={(e) => {
if (e.target === e.currentTarget) {
onBackgroundClick?.();
}
}}
>
{/* 루트 컨테이너 렌더링 (병합된 레이아웃 사용) */}
<ContainerRenderer
container={effectiveRoot}
components={components}
viewportWidth={viewportWidth}
settings={settings}
currentMode={currentMode}
overrides={overrides}
isDesignMode={isDesignMode}
selectedComponentId={selectedComponentId}
onComponentClick={onComponentClick}
onContainerClick={onContainerClick}
onComponentResize={onComponentResize}
onReorderComponent={onReorderComponent}
/>
</div>
);
}
// ========================================
// 컨테이너 렌더러 (재귀)
// ========================================
interface ContainerRendererProps {
container: PopContainerV4;
components: Record<string, PopComponentDefinitionV4>;
viewportWidth: number;
settings: PopLayoutDataV4["settings"];
currentMode: string;
overrides: PopLayoutDataV4["overrides"];
isDesignMode?: boolean;
selectedComponentId?: string | null;
onComponentClick?: (componentId: string) => void;
onContainerClick?: (containerId: string) => void;
onComponentResize?: (componentId: string, size: Partial<PopSizeConstraintV4>) => void;
onReorderComponent?: (containerId: string, fromIndex: number, toIndex: number) => void;
depth?: number;
}
function ContainerRenderer({
container,
components,
viewportWidth,
settings,
currentMode,
overrides,
isDesignMode = false,
selectedComponentId,
onComponentClick,
onContainerClick,
onComponentResize,
onReorderComponent,
depth = 0,
}: ContainerRendererProps) {
// visibility 체크 함수
const isComponentVisible = (component: PopComponentDefinitionV4): boolean => {
if (!component.visibility) return true; // 기본값: 표시
const modeVisibility = component.visibility[currentMode as keyof typeof component.visibility];
return modeVisibility !== false; // undefined도 true로 취급
};
// 컴포넌트 오버라이드 병합
const getMergedComponent = (baseComponent: PopComponentDefinitionV4): PopComponentDefinitionV4 => {
if (currentMode === "tablet_landscape") return baseComponent;
const componentOverride = overrides?.[currentMode as keyof typeof overrides]?.components?.[baseComponent.id];
if (!componentOverride) return baseComponent;
// 깊은 병합 (config, size)
return {
...baseComponent,
...componentOverride,
size: { ...baseComponent.size, ...componentOverride.size },
config: { ...baseComponent.config, ...componentOverride.config },
};
};
// 반응형 규칙 적용
const effectiveContainer = useMemo(() => {
return applyResponsiveRules(container, viewportWidth);
}, [container, viewportWidth]);
// 비율 스케일 계산 (디자인 모드에서는 1, 뷰어에서는 실제 비율 적용)
const scale = isDesignMode ? 1 : viewportWidth / BASE_VIEWPORT_WIDTH;
// Flexbox 스타일 계산 (useMemo는 조건문 전에 호출해야 함)
const containerStyle = useMemo((): React.CSSProperties => {
const { direction, wrap, gap, alignItems, justifyContent, padding } = effectiveContainer;
// gap과 padding도 스케일 적용
const scaledGap = gap * scale;
const scaledPadding = padding ? padding * scale : undefined;
return {
display: "flex",
flexDirection: direction === "horizontal" ? "row" : "column",
flexWrap: wrap ? "wrap" : "nowrap",
gap: `${scaledGap}px`,
alignItems: mapAlignment(alignItems),
justifyContent: mapJustify(justifyContent),
padding: scaledPadding ? `${scaledPadding}px` : undefined,
width: "100%",
minHeight: depth === 0 ? "100%" : undefined,
};
}, [effectiveContainer, depth, scale]);
// 숨김 처리
if (effectiveContainer.hidden) {
return null;
}
return (
<div
className={cn(
"relative",
isDesignMode && depth > 0 && "border border-dashed border-gray-300 rounded"
)}
style={containerStyle}
onClick={(e) => {
if (e.target === e.currentTarget) {
onContainerClick?.(container.id);
}
}}
>
{effectiveContainer.children.map((child, index) => {
// 중첩 컨테이너인 경우
if (typeof child === "object") {
return (
<ContainerRenderer
key={child.id}
container={child}
components={components}
viewportWidth={viewportWidth}
settings={settings}
currentMode={currentMode}
overrides={overrides}
isDesignMode={isDesignMode}
selectedComponentId={selectedComponentId}
onComponentClick={onComponentClick}
onContainerClick={onContainerClick}
onComponentResize={onComponentResize}
onReorderComponent={onReorderComponent}
depth={depth + 1}
/>
);
}
// 컴포넌트 ID인 경우
const componentId = child;
const baseComponent = components[componentId];
if (!baseComponent) return null;
// visibility 체크 (모드별 숨김)
if (!isComponentVisible(baseComponent)) {
return null;
}
// 반응형 숨김 처리 (픽셀 기반)
if (baseComponent.hideBelow && viewportWidth < baseComponent.hideBelow) {
return null;
}
// 오버라이드 병합
const mergedComponent = getMergedComponent(baseComponent);
// pop-break 특수 처리
if (mergedComponent.type === "pop-break") {
return (
<div
key={componentId}
className={cn(
"w-full",
isDesignMode
? "h-4 border-2 border-dashed border-gray-300 rounded flex items-center justify-center cursor-pointer hover:border-gray-400"
: "h-0"
)}
style={{ flexBasis: "100%" }}
onClick={() => onComponentClick?.(componentId)}
>
{isDesignMode && (
<span className="text-xs text-gray-400"></span>
)}
</div>
);
}
return (
<DraggableComponentWrapper
key={componentId}
componentId={componentId}
containerId={container.id}
index={index}
isDesignMode={isDesignMode}
onReorder={onReorderComponent}
>
<ComponentRendererV4
componentId={componentId}
component={mergedComponent}
settings={settings}
viewportWidth={viewportWidth}
isDesignMode={isDesignMode}
isSelected={selectedComponentId === componentId}
onClick={() => onComponentClick?.(componentId)}
onResize={onComponentResize}
/>
</DraggableComponentWrapper>
);
})}
</div>
);
}
// ========================================
// 드래그 가능한 컴포넌트 래퍼
// ========================================
interface DraggableComponentWrapperProps {
componentId: string;
containerId: string;
index: number;
isDesignMode: boolean;
onReorder?: (containerId: string, fromIndex: number, toIndex: number) => void;
children: React.ReactNode;
}
function DraggableComponentWrapper({
componentId,
containerId,
index,
isDesignMode,
onReorder,
children,
}: DraggableComponentWrapperProps) {
// 디자인 모드가 아니면 그냥 children 반환 (훅 호출 전에 체크)
// DndProvider가 없는 환경에서 useDrag/useDrop 훅 호출 방지
if (!isDesignMode) {
return <>{children}</>;
}
// 디자인 모드일 때만 드래그 기능 활성화
return (
<DraggableComponentWrapperInner
componentId={componentId}
containerId={containerId}
index={index}
onReorder={onReorder}
>
{children}
</DraggableComponentWrapperInner>
);
}
// 디자인 모드 전용 내부 컴포넌트 (DndProvider 필요)
function DraggableComponentWrapperInner({
componentId,
containerId,
index,
onReorder,
children,
}: Omit<DraggableComponentWrapperProps, "isDesignMode">) {
const ref = useRef<HTMLDivElement>(null);
const [{ isDragging }, drag] = useDrag({
type: DND_COMPONENT_REORDER,
item: (): DragItem => ({
type: DND_COMPONENT_REORDER,
componentId,
containerId,
index,
}),
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
const [{ isOver, canDrop }, drop] = useDrop({
accept: DND_COMPONENT_REORDER,
canDrop: (item: DragItem) => {
// 같은 컨테이너 내에서만 이동 가능 (일단은)
return item.containerId === containerId && item.index !== index;
},
drop: (item: DragItem) => {
if (item.index !== index && onReorder) {
onReorder(containerId, item.index, index);
}
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
});
// drag와 drop 합치기
drag(drop(ref));
return (
<div
ref={ref}
className={cn(
"relative",
isDragging && "opacity-50",
isOver && canDrop && "ring-2 ring-blue-500 ring-offset-2"
)}
style={{ cursor: isDragging ? "grabbing" : "grab" }}
>
{children}
{/* 드롭 인디케이터 */}
{isOver && canDrop && (
<div className="absolute inset-0 bg-blue-500/10 pointer-events-none rounded" />
)}
</div>
);
}
// ========================================
// v4 컴포넌트 렌더러 (리사이즈 핸들 포함)
// ========================================
interface ComponentRendererV4Props {
componentId: string;
component: PopComponentDefinitionV4;
settings: PopLayoutDataV4["settings"];
viewportWidth: number;
isDesignMode?: boolean;
isSelected?: boolean;
onClick?: () => void;
onResize?: (componentId: string, size: Partial<PopSizeConstraintV4>) => void;
}
function ComponentRendererV4({
componentId,
component,
settings,
viewportWidth,
isDesignMode = false,
isSelected = false,
onClick,
onResize,
}: ComponentRendererV4Props) {
const { size, alignSelf, type, label } = component;
const containerRef = useRef<HTMLDivElement>(null);
// 비율 스케일 계산 (디자인 모드에서는 1, 뷰어에서는 실제 비율 적용)
const scale = isDesignMode ? 1 : viewportWidth / BASE_VIEWPORT_WIDTH;
// 리사이즈 상태
const [isResizing, setIsResizing] = useState(false);
const [resizeDirection, setResizeDirection] = useState<"width" | "height" | "both" | null>(null);
const resizeStartRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null);
// 크기 스타일 계산 (스케일 적용)
const sizeStyle = useMemo((): React.CSSProperties => {
return calculateSizeStyle(size, settings, scale);
}, [size, settings, scale]);
// alignSelf 스타일
const alignStyle: React.CSSProperties = alignSelf
? { alignSelf: mapAlignment(alignSelf) }
: {};
const typeLabel = COMPONENT_TYPE_LABELS[type] || type;
// 리사이즈 시작
const handleResizeStart = useCallback((
e: React.MouseEvent,
direction: "width" | "height" | "both"
) => {
e.stopPropagation();
e.preventDefault();
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
resizeStartRef.current = {
x: e.clientX,
y: e.clientY,
width: rect.width,
height: rect.height,
};
setIsResizing(true);
setResizeDirection(direction);
}, []);
// 리사이즈 중
useCallback((e: MouseEvent) => {
if (!isResizing || !resizeStartRef.current || !onResize) return;
const deltaX = e.clientX - resizeStartRef.current.x;
const deltaY = e.clientY - resizeStartRef.current.y;
const updates: Partial<PopSizeConstraintV4> = {};
if (resizeDirection === "width" || resizeDirection === "both") {
const newWidth = Math.max(48, Math.round(resizeStartRef.current.width + deltaX));
updates.width = "fixed";
updates.fixedWidth = newWidth;
}
if (resizeDirection === "height" || resizeDirection === "both") {
const newHeight = Math.max(settings.touchTargetMin, Math.round(resizeStartRef.current.height + deltaY));
updates.height = "fixed";
updates.fixedHeight = newHeight;
}
onResize(componentId, updates);
}, [isResizing, resizeDirection, componentId, onResize, settings.touchTargetMin]);
// 리사이즈 종료 및 이벤트 등록
React.useEffect(() => {
if (!isResizing) return;
const handleMouseMove = (e: MouseEvent) => {
if (!resizeStartRef.current || !onResize) return;
const deltaX = e.clientX - resizeStartRef.current.x;
const deltaY = e.clientY - resizeStartRef.current.y;
const updates: Partial<PopSizeConstraintV4> = {};
if (resizeDirection === "width" || resizeDirection === "both") {
const newWidth = Math.max(48, Math.round(resizeStartRef.current.width + deltaX));
updates.width = "fixed";
updates.fixedWidth = newWidth;
}
if (resizeDirection === "height" || resizeDirection === "both") {
const newHeight = Math.max(settings.touchTargetMin, Math.round(resizeStartRef.current.height + deltaY));
updates.height = "fixed";
updates.fixedHeight = newHeight;
}
onResize(componentId, updates);
};
const handleMouseUp = () => {
setIsResizing(false);
setResizeDirection(null);
resizeStartRef.current = null;
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [isResizing, resizeDirection, componentId, onResize, settings.touchTargetMin]);
return (
<div
ref={containerRef}
className={cn(
"relative flex flex-col overflow-visible rounded-lg border-2 bg-white transition-all",
isSelected
? "border-primary ring-2 ring-primary/30 z-10"
: "border-gray-200",
isDesignMode && !isResizing && "cursor-pointer hover:border-gray-300",
isResizing && "select-none"
)}
style={{
...sizeStyle,
...alignStyle,
}}
onClick={(e) => {
e.stopPropagation();
if (!isResizing) {
onClick?.();
}
}}
>
{/* 컴포넌트 라벨 (디자인 모드에서만) */}
{isDesignMode && (
<div
className={cn(
"flex h-5 shrink-0 items-center border-b px-2",
isSelected ? "bg-primary/10" : "bg-gray-50"
)}
>
<span className="text-[10px] font-medium text-gray-600">
{label || typeLabel}
</span>
</div>
)}
{/* 컴포넌트 내용 */}
<div className="flex flex-1 items-center justify-center p-2 overflow-hidden">
{renderComponentContent(component, isDesignMode, settings)}
</div>
{/* 리사이즈 핸들 (디자인 모드 + 선택 시에만) */}
{isDesignMode && isSelected && onResize && (
<>
{/* 오른쪽 핸들 (너비 조정) */}
<div
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 w-3 h-8 bg-primary rounded cursor-ew-resize hover:bg-primary/80 flex items-center justify-center z-20"
onMouseDown={(e) => handleResizeStart(e, "width")}
title="너비 조정"
>
<div className="w-0.5 h-4 bg-white rounded" />
</div>
{/* 아래쪽 핸들 (높이 조정) */}
<div
className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 h-3 w-8 bg-primary rounded cursor-ns-resize hover:bg-primary/80 flex items-center justify-center z-20"
onMouseDown={(e) => handleResizeStart(e, "height")}
title="높이 조정"
>
<div className="h-0.5 w-4 bg-white rounded" />
</div>
{/* 오른쪽 아래 핸들 (너비 + 높이 동시 조정) */}
<div
className="absolute right-0 bottom-0 translate-x-1/2 translate-y-1/2 w-4 h-4 bg-primary rounded cursor-nwse-resize hover:bg-primary/80 flex items-center justify-center z-20"
onMouseDown={(e) => handleResizeStart(e, "both")}
title="크기 조정"
>
<div className="w-2 h-2 border-r-2 border-b-2 border-white" />
</div>
</>
)}
</div>
);
}
// ========================================
// 헬퍼 함수들
// ========================================
/**
*
*/
function applyResponsiveRules(
container: PopContainerV4,
viewportWidth: number
): PopContainerV4 & { hidden?: boolean } {
if (!container.responsive || container.responsive.length === 0) {
return container;
}
// 현재 뷰포트에 적용되는 규칙 찾기 (가장 큰 breakpoint부터)
const sortedRules = [...container.responsive].sort((a, b) => b.breakpoint - a.breakpoint);
const applicableRule = sortedRules.find((rule) => viewportWidth <= rule.breakpoint);
if (!applicableRule) {
return container;
}
return {
...container,
direction: applicableRule.direction ?? container.direction,
gap: applicableRule.gap ?? container.gap,
hidden: applicableRule.hidden ?? false,
};
}
/**
* (10 릿 )
*
*/
const BASE_VIEWPORT_WIDTH = 1024;
/**
* CSS
* @param scale - (viewportWidth / BASE_VIEWPORT_WIDTH)
*/
function calculateSizeStyle(
size: PopSizeConstraintV4,
settings: PopLayoutDataV4["settings"],
scale: number = 1
): React.CSSProperties {
const style: React.CSSProperties = {};
// 스케일된 터치 최소 크기
const scaledTouchMin = settings.touchTargetMin * scale;
// 너비
switch (size.width) {
case "fixed":
// fixed 크기도 비율에 맞게 스케일
style.width = size.fixedWidth ? `${size.fixedWidth * scale}px` : "auto";
style.flexShrink = 0;
break;
case "fill":
style.flex = 1;
style.minWidth = size.minWidth ? `${size.minWidth * scale}px` : 0;
style.maxWidth = size.maxWidth ? `${size.maxWidth * scale}px` : undefined;
break;
case "hug":
style.width = "auto";
style.flexShrink = 0;
break;
}
// 높이
switch (size.height) {
case "fixed":
const scaledFixedHeight = (size.fixedHeight || settings.touchTargetMin) * scale;
const minHeight = Math.max(scaledFixedHeight, scaledTouchMin);
style.height = `${minHeight}px`;
break;
case "fill":
style.flexGrow = 1;
style.minHeight = size.minHeight
? `${Math.max(size.minHeight * scale, scaledTouchMin)}px`
: `${scaledTouchMin}px`;
break;
case "hug":
style.height = "auto";
style.minHeight = `${scaledTouchMin}px`;
break;
}
return style;
}
/**
* alignItems
*/
function mapAlignment(value: string): React.CSSProperties["alignItems"] {
switch (value) {
case "start":
return "flex-start";
case "end":
return "flex-end";
case "center":
return "center";
case "stretch":
return "stretch";
default:
return "stretch";
}
}
/**
* justifyContent
*/
function mapJustify(value: string): React.CSSProperties["justifyContent"] {
switch (value) {
case "start":
return "flex-start";
case "end":
return "flex-end";
case "center":
return "center";
case "space-between":
return "space-between";
default:
return "flex-start";
}
}
/**
*
*/
function renderComponentContent(
component: PopComponentDefinitionV4,
isDesignMode: boolean,
settings: PopLayoutDataV4["settings"]
): React.ReactNode {
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
// 디자인 모드에서는 플레이스홀더 표시
if (isDesignMode) {
// Spacer는 디자인 모드에서 점선 배경으로 표시
if (component.type === "pop-spacer") {
return (
<div className="h-full w-full flex items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50/50 rounded">
<span className="text-xs text-gray-400"> </span>
</div>
);
}
return (
<div className="text-xs text-gray-400 text-center">
{typeLabel}
</div>
);
}
// 뷰어 모드: 실제 컴포넌트 렌더링
switch (component.type) {
case "pop-field":
return (
<input
type="text"
placeholder={component.label || "입력하세요"}
className="w-full h-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
);
case "pop-button":
return (
<button className="w-full h-full px-4 py-2 text-sm font-medium text-white bg-primary rounded-md hover:bg-primary/90 transition-colors">
{component.label || "버튼"}
</button>
);
case "pop-list":
return (
<div className="w-full h-full overflow-auto p-2">
<div className="text-xs text-gray-500 text-center">
( )
</div>
</div>
);
case "pop-indicator":
return (
<div className="text-center">
<div className="text-2xl font-bold text-primary">0</div>
<div className="text-xs text-gray-500">{component.label || "지표"}</div>
</div>
);
case "pop-scanner":
return (
<div className="text-center text-gray-500">
<div className="text-xs"></div>
<div className="text-[10px]"> </div>
</div>
);
case "pop-numpad":
return (
<div className="grid grid-cols-3 gap-1 p-1 w-full">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => (
<button
key={key}
className="aspect-square text-xs font-medium bg-gray-100 rounded hover:bg-gray-200"
>
{key}
</button>
))}
</div>
);
case "pop-spacer":
// 실제 모드에서 Spacer는 완전히 투명 (공간만 차지)
return null;
default:
return (
<div className="text-xs text-gray-400">
{typeLabel}
</div>
);
}
}
export default PopFlexRenderer;

View File

@ -1,401 +0,0 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
import {
PopLayoutDataV3,
PopLayoutModeKey,
PopModeLayoutV3,
GridPosition,
MODE_RESOLUTIONS,
PopComponentDefinition,
} from "../types/pop-layout";
// ========================================
// Props 정의
// ========================================
interface PopLayoutRendererProps {
/** 레이아웃 데이터 (v3.0) */
layout: PopLayoutDataV3;
/** 현재 모드 키 (tablet_landscape, tablet_portrait 등) */
modeKey: PopLayoutModeKey;
/** 디자인 모드 여부 (true: 편집 가능, false: 뷰어 모드) */
isDesignMode?: boolean;
/** 선택된 컴포넌트 ID */
selectedComponentId?: string | null;
/** 컴포넌트 클릭 시 호출 */
onComponentClick?: (componentId: string) => void;
/** 배경 클릭 시 호출 (선택 해제용) */
onBackgroundClick?: () => void;
/** 커스텀 모드 레이아웃 (fallback 등에서 변환된 레이아웃 사용 시) */
customModeLayout?: PopModeLayoutV3;
/** 추가 className */
className?: string;
/** 추가 style */
style?: React.CSSProperties;
}
// ========================================
// 컴포넌트 타입별 라벨
// ========================================
const COMPONENT_TYPE_LABELS: Record<string, string> = {
"pop-field": "필드",
"pop-button": "버튼",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
"pop-numpad": "숫자패드",
};
// ========================================
// POP 레이아웃 렌더러 (v3)
//
// 핵심 역할:
// - 디자이너와 뷰어에서 **동일한** 렌더링 결과 보장
// - 컴포넌트가 캔버스에 직접 배치 (섹션 없음)
// - CSS Grid + 1fr 비율 기반
// ========================================
export function PopLayoutRenderer({
layout,
modeKey,
isDesignMode = false,
selectedComponentId,
onComponentClick,
onBackgroundClick,
customModeLayout,
className,
style,
}: PopLayoutRendererProps) {
const { components, layouts, settings } = layout;
const canvasGrid = settings.canvasGrid;
// 현재 모드의 레이아웃
const modeLayout = customModeLayout || layouts[modeKey];
// 컴포넌트가 없으면 빈 상태 표시
if (!modeLayout || Object.keys(modeLayout.componentPositions).length === 0) {
return (
<div
className={cn(
"flex h-full w-full items-center justify-center bg-gray-50",
className
)}
style={style}
onClick={onBackgroundClick}
>
<div className="text-center text-sm text-gray-400">
<p> </p>
{isDesignMode && <p className="mt-1"> </p>}
</div>
</div>
);
}
// 컴포넌트 ID 목록
const componentIds = Object.keys(modeLayout.componentPositions);
return (
<div
className={cn("relative w-full h-full bg-white", className)}
style={{
// CSS Grid: 디자이너와 동일
display: "grid",
gridTemplateColumns: `repeat(${canvasGrid.columns}, 1fr)`,
gridTemplateRows: `repeat(${canvasGrid.rows || 24}, 1fr)`,
gap: `${canvasGrid.gap}px`,
padding: `${canvasGrid.gap}px`,
...style,
}}
onClick={(e) => {
if (e.target === e.currentTarget) {
onBackgroundClick?.();
}
}}
>
{/* 컴포넌트들 직접 렌더링 */}
{componentIds.map((componentId) => {
const compDef = components[componentId];
const compPos = modeLayout.componentPositions[componentId];
if (!compDef || !compPos) return null;
return (
<ComponentRenderer
key={componentId}
componentId={componentId}
component={compDef}
position={compPos}
isDesignMode={isDesignMode}
isSelected={selectedComponentId === componentId}
onComponentClick={() => onComponentClick?.(componentId)}
/>
);
})}
</div>
);
}
// ========================================
// 컴포넌트 렌더러
// ========================================
interface ComponentRendererProps {
componentId: string;
component: PopComponentDefinition;
position: GridPosition;
isDesignMode?: boolean;
isSelected?: boolean;
onComponentClick?: () => void;
}
function ComponentRenderer({
componentId,
component,
position,
isDesignMode = false,
isSelected = false,
onComponentClick,
}: ComponentRendererProps) {
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
return (
<div
className={cn(
"relative flex flex-col overflow-hidden rounded-lg border-2 bg-white transition-all",
isSelected
? "border-primary ring-2 ring-primary/30 z-10"
: "border-gray-200",
isDesignMode && "cursor-pointer hover:border-gray-300"
)}
style={{
gridColumn: `${position.col} / span ${position.colSpan}`,
gridRow: `${position.row} / span ${position.rowSpan}`,
}}
onClick={(e) => {
e.stopPropagation();
onComponentClick?.();
}}
>
{/* 컴포넌트 라벨 (디자인 모드에서만) */}
{isDesignMode && (
<div
className={cn(
"flex h-5 shrink-0 items-center border-b px-2",
isSelected ? "bg-primary/10" : "bg-gray-50"
)}
>
<span className="text-[10px] font-medium text-gray-600">
{component.label || typeLabel}
</span>
</div>
)}
{/* 컴포넌트 내용 */}
<div className="flex flex-1 items-center justify-center p-2">
{renderComponentContent(component, isDesignMode)}
</div>
</div>
);
}
// ========================================
// 컴포넌트별 렌더링
// ========================================
function renderComponentContent(
component: PopComponentDefinition,
isDesignMode: boolean
): React.ReactNode {
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
// 디자인 모드에서는 플레이스홀더 표시
if (isDesignMode) {
return (
<div className="text-xs text-gray-400 text-center">
{typeLabel}
</div>
);
}
// 뷰어 모드: 실제 컴포넌트 렌더링
switch (component.type) {
case "pop-field":
return (
<input
type="text"
placeholder={component.label || "입력하세요"}
className="w-full h-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
);
case "pop-button":
return (
<button className="w-full h-full px-4 py-2 text-sm font-medium text-white bg-primary rounded-md hover:bg-primary/90 transition-colors">
{component.label || "버튼"}
</button>
);
case "pop-list":
return (
<div className="w-full h-full overflow-auto p-2">
<div className="text-xs text-gray-500 text-center">
( )
</div>
</div>
);
case "pop-indicator":
return (
<div className="text-center">
<div className="text-2xl font-bold text-primary">0</div>
<div className="text-xs text-gray-500">{component.label || "지표"}</div>
</div>
);
case "pop-scanner":
return (
<div className="text-center text-gray-500">
<div className="text-xs"></div>
<div className="text-[10px]"> </div>
</div>
);
case "pop-numpad":
return (
<div className="grid grid-cols-3 gap-1 p-1 w-full">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => (
<button
key={key}
className="aspect-square text-xs font-medium bg-gray-100 rounded hover:bg-gray-200"
>
{key}
</button>
))}
</div>
);
default:
return (
<div className="text-xs text-gray-400">
{typeLabel}
</div>
);
}
}
// ========================================
// 헬퍼 함수들 (export)
// ========================================
/**
*
*/
export function hasModeLayout(
layout: PopLayoutDataV3,
modeKey: PopLayoutModeKey
): boolean {
const modeLayout = layout.layouts[modeKey];
return modeLayout && Object.keys(modeLayout.componentPositions).length > 0;
}
/**
* 릿 ( )
*/
export function hasBaseLayout(layout: PopLayoutDataV3): boolean {
return hasModeLayout(layout, "tablet_landscape");
}
/**
* 릿
*/
export function autoConvertLayout(
layout: PopLayoutDataV3,
targetModeKey: PopLayoutModeKey
): PopModeLayoutV3 {
const sourceKey: PopLayoutModeKey = "tablet_landscape";
const sourceLayout = layout.layouts[sourceKey];
const sourceRes = MODE_RESOLUTIONS[sourceKey];
const targetRes = MODE_RESOLUTIONS[targetModeKey];
// 비율 계산
const widthRatio = targetRes.width / sourceRes.width;
const heightRatio = targetRes.height / sourceRes.height;
// 가로 → 세로 변환인지 확인
const isOrientationChange =
sourceRes.width > sourceRes.height !== targetRes.width > targetRes.height;
// 컴포넌트 위치 변환
const convertedPositions: Record<string, GridPosition> = {};
let currentRow = 1;
// 컴포넌트를 row, col 순으로 정렬
const sortedComponentIds = Object.keys(sourceLayout.componentPositions).sort(
(a, b) => {
const posA = sourceLayout.componentPositions[a];
const posB = sourceLayout.componentPositions[b];
if (posA.row !== posB.row) return posA.row - posB.row;
return posA.col - posB.col;
}
);
for (const componentId of sortedComponentIds) {
const sourcePos = sourceLayout.componentPositions[componentId];
if (isOrientationChange) {
// 가로 → 세로: 세로 스택 방식
const canvasColumns = layout.settings.canvasGrid.columns;
convertedPositions[componentId] = {
col: 1,
row: currentRow,
colSpan: canvasColumns,
rowSpan: Math.max(3, Math.round(sourcePos.rowSpan * 1.5)),
};
currentRow += convertedPositions[componentId].rowSpan + 1;
} else {
// 같은 방향: 비율 변환
convertedPositions[componentId] = {
col: Math.max(1, Math.round(sourcePos.col * widthRatio)),
row: Math.max(1, Math.round(sourcePos.row * heightRatio)),
colSpan: Math.max(1, Math.round(sourcePos.colSpan * widthRatio)),
rowSpan: Math.max(1, Math.round(sourcePos.rowSpan * heightRatio)),
};
}
}
return {
componentPositions: convertedPositions,
};
}
/**
* ( )
*/
export function getEffectiveModeLayout(
layout: PopLayoutDataV3,
targetModeKey: PopLayoutModeKey
): {
modeLayout: PopModeLayoutV3;
isConverted: boolean;
sourceModeKey: PopLayoutModeKey;
} {
// 해당 모드에 레이아웃이 있으면 그대로 사용
if (hasModeLayout(layout, targetModeKey)) {
return {
modeLayout: layout.layouts[targetModeKey],
isConverted: false,
sourceModeKey: targetModeKey,
};
}
// 없으면 태블릿 가로 모드를 기준으로 자동 변환
return {
modeLayout: autoConvertLayout(layout, targetModeKey),
isConverted: true,
sourceModeKey: "tablet_landscape",
};
}
export default PopLayoutRenderer;

View File

@ -0,0 +1,280 @@
"use client";
import React, { useMemo } from "react";
import { cn } from "@/lib/utils";
import {
PopLayoutDataV5,
PopComponentDefinitionV5,
PopGridPosition,
GridMode,
GRID_BREAKPOINTS,
detectGridMode,
PopComponentType,
} from "../types/pop-layout";
// ========================================
// Props
// ========================================
interface PopRendererProps {
/** v5 레이아웃 데이터 */
layout: PopLayoutDataV5;
/** 현재 뷰포트 너비 */
viewportWidth: number;
/** 현재 모드 (자동 감지 또는 수동 지정) */
currentMode?: GridMode;
/** 디자인 모드 여부 */
isDesignMode?: boolean;
/** 그리드 가이드 표시 여부 */
showGridGuide?: boolean;
/** 선택된 컴포넌트 ID */
selectedComponentId?: string | null;
/** 컴포넌트 클릭 */
onComponentClick?: (componentId: string) => void;
/** 배경 클릭 */
onBackgroundClick?: () => void;
/** 추가 className */
className?: string;
}
// ========================================
// 컴포넌트 타입별 라벨
// ========================================
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"pop-sample": "샘플",
};
// ========================================
// PopRenderer: v5 그리드 렌더러
// ========================================
export default function PopRenderer({
layout,
viewportWidth,
currentMode,
isDesignMode = false,
showGridGuide = true,
selectedComponentId,
onComponentClick,
onBackgroundClick,
className,
}: PopRendererProps) {
const { gridConfig, components, overrides } = layout;
// 현재 모드 (자동 감지 또는 지정)
const mode = currentMode || detectGridMode(viewportWidth);
const breakpoint = GRID_BREAKPOINTS[mode];
// CSS Grid 스타일
const gridStyle = useMemo((): React.CSSProperties => ({
display: "grid",
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
gridAutoRows: `${breakpoint.rowHeight}px`,
gap: `${breakpoint.gap}px`,
padding: `${breakpoint.padding}px`,
minHeight: "100%",
backgroundColor: "#ffffff",
position: "relative",
}), [breakpoint]);
// 그리드 가이드 셀 생성
const gridCells = useMemo(() => {
if (!isDesignMode || !showGridGuide) return [];
const cells = [];
const rowCount = 20; // 충분한 행 수
for (let row = 1; row <= rowCount; row++) {
for (let col = 1; col <= breakpoint.columns; col++) {
cells.push({
id: `cell-${col}-${row}`,
col,
row
});
}
}
return cells;
}, [isDesignMode, showGridGuide, breakpoint.columns]);
// visibility 체크
const isVisible = (comp: PopComponentDefinitionV5): boolean => {
if (!comp.visibility) return true;
const modeVisibility = comp.visibility[mode];
return modeVisibility !== false;
};
// 위치 변환 (12칸 기준 → 현재 모드 칸 수)
const convertPosition = (position: PopGridPosition): React.CSSProperties => {
const sourceColumns = 12; // 항상 12칸 기준으로 저장
const targetColumns = breakpoint.columns;
// 같은 칸 수면 그대로 사용
if (sourceColumns === targetColumns) {
return {
gridColumn: `${position.col} / span ${position.colSpan}`,
gridRow: `${position.row} / span ${position.rowSpan}`,
};
}
// 비율 계산 (12칸 → 4칸, 6칸, 8칸)
const ratio = targetColumns / sourceColumns;
// 열 위치 변환
let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1);
let newColSpan = Math.max(1, Math.round(position.colSpan * ratio));
// 범위 초과 방지
if (newCol > targetColumns) {
newCol = 1;
}
if (newCol + newColSpan - 1 > targetColumns) {
newColSpan = targetColumns - newCol + 1;
}
return {
gridColumn: `${newCol} / span ${Math.max(1, newColSpan)}`,
gridRow: `${position.row} / span ${position.rowSpan}`,
};
};
// 오버라이드 적용
const getEffectivePosition = (comp: PopComponentDefinitionV5): PopGridPosition => {
const override = overrides?.[mode]?.positions?.[comp.id];
if (override) {
return { ...comp.position, ...override };
}
return comp.position;
};
// 오버라이드 숨김 체크
const isHiddenByOverride = (comp: PopComponentDefinitionV5): boolean => {
return overrides?.[mode]?.hidden?.includes(comp.id) ?? false;
};
return (
<div
className={cn("relative min-h-full w-full", className)}
style={gridStyle}
onClick={(e) => {
if (e.target === e.currentTarget) {
onBackgroundClick?.();
}
}}
>
{/* 그리드 가이드 셀 (실제 DOM) */}
{gridCells.map(cell => (
<div
key={cell.id}
className="pointer-events-none border border-dashed border-blue-300/40"
style={{
gridColumn: cell.col,
gridRow: cell.row,
}}
/>
))}
{/* 컴포넌트 렌더링 (z-index로 위에 표시) */}
{Object.values(components).map((comp) => {
// visibility 체크
if (!isVisible(comp)) return null;
// 오버라이드 숨김 체크
if (isHiddenByOverride(comp)) return null;
const position = getEffectivePosition(comp);
const positionStyle = convertPosition(position);
const isSelected = selectedComponentId === comp.id;
return (
<div
key={comp.id}
className={cn(
"relative rounded-lg border-2 bg-white transition-all overflow-hidden z-10",
isSelected
? "border-primary ring-2 ring-primary/30"
: "border-gray-200",
isDesignMode && "cursor-pointer hover:border-gray-300 hover:shadow-sm"
)}
style={positionStyle}
onClick={(e) => {
e.stopPropagation();
onComponentClick?.(comp.id);
}}
>
<ComponentContent
component={comp}
isDesignMode={isDesignMode}
isSelected={isSelected}
/>
</div>
);
})}
</div>
);
}
// ========================================
// 컴포넌트 내용 렌더링
// ========================================
interface ComponentContentProps {
component: PopComponentDefinitionV5;
isDesignMode: boolean;
isSelected: boolean;
}
function ComponentContent({ component, isDesignMode, isSelected }: ComponentContentProps) {
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
// 디자인 모드: 플레이스홀더 표시
if (isDesignMode) {
return (
<div className="flex h-full w-full flex-col">
{/* 헤더 */}
<div
className={cn(
"flex h-5 shrink-0 items-center border-b px-2",
isSelected ? "bg-primary/10 border-primary" : "bg-gray-50 border-gray-200"
)}
>
<span className={cn(
"text-[10px] font-medium truncate",
isSelected ? "text-primary" : "text-gray-600"
)}>
{component.label || typeLabel}
</span>
</div>
{/* 내용 */}
<div className="flex flex-1 items-center justify-center p-2">
<span className="text-xs text-gray-400">{typeLabel}</span>
</div>
{/* 위치 정보 표시 */}
<div className="absolute bottom-1 right-1 text-[9px] text-gray-400 bg-white/80 px-1 rounded">
{component.position.col},{component.position.row}
({component.position.colSpan}×{component.position.rowSpan})
</div>
</div>
);
}
// 실제 모드: 컴포넌트 렌더링
return renderActualComponent(component);
}
// ========================================
// 실제 컴포넌트 렌더링 (뷰어 모드)
// ========================================
function renderActualComponent(component: PopComponentDefinitionV5): React.ReactNode {
const typeLabel = COMPONENT_TYPE_LABELS[component.type];
// 샘플 박스 렌더링
return (
<div className="flex h-full w-full items-center justify-center p-2">
<span className="text-xs text-gray-500">{component.label || typeLabel}</span>
</div>
);
}

View File

@ -1,11 +1,4 @@
// POP 레이아웃 렌더러 모듈 (v3) // POP 레이아웃 렌더러 모듈 (v5 그리드 시스템)
// 디자이너와 뷰어에서 동일한 렌더링을 보장하기 위한 공용 렌더러 // 디자이너와 뷰어에서 동일한 렌더링을 보장하기 위한 공용 렌더러
// 섹션 제거됨, 컴포넌트 직접 배치
export { PopLayoutRenderer, default } from "./PopLayoutRenderer"; export { default as PopRenderer } from "./PopRenderer";
export {
hasModeLayout,
hasBaseLayout,
autoConvertLayout,
getEffectiveModeLayout,
} from "./PopLayoutRenderer";

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,301 @@
import {
PopGridPosition,
GridMode,
GRID_BREAKPOINTS
} from "../types/pop-layout";
// ========================================
// 그리드 위치 변환
// ========================================
/**
* 12
*/
export function convertPositionToMode(
position: PopGridPosition,
targetMode: GridMode
): PopGridPosition {
const sourceColumns = 12;
const targetColumns = GRID_BREAKPOINTS[targetMode].columns;
// 같은 칸 수면 그대로 반환
if (sourceColumns === targetColumns) {
return position;
}
const ratio = targetColumns / sourceColumns;
// 열 위치 변환
let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1);
let newColSpan = Math.max(1, Math.round(position.colSpan * ratio));
// 범위 초과 방지
if (newCol > targetColumns) {
newCol = 1;
}
if (newCol + newColSpan - 1 > targetColumns) {
newColSpan = targetColumns - newCol + 1;
}
return {
col: newCol,
row: position.row,
colSpan: Math.max(1, newColSpan),
rowSpan: position.rowSpan,
};
}
// ========================================
// 겹침 감지 및 해결
// ========================================
/**
*
*/
export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean {
// 열 겹침 체크
const aColEnd = a.col + a.colSpan - 1;
const bColEnd = b.col + b.colSpan - 1;
const colOverlap = !(aColEnd < b.col || bColEnd < a.col);
// 행 겹침 체크
const aRowEnd = a.row + a.rowSpan - 1;
const bRowEnd = b.row + b.rowSpan - 1;
const rowOverlap = !(aRowEnd < b.row || bRowEnd < a.row);
return colOverlap && rowOverlap;
}
/**
* ( )
*/
export function resolveOverlaps(
positions: Array<{ id: string; position: PopGridPosition }>,
columns: number
): Array<{ id: string; position: PopGridPosition }> {
// row, col 순으로 정렬
const sorted = [...positions].sort((a, b) =>
a.position.row - b.position.row || a.position.col - b.position.col
);
const resolved: Array<{ id: string; position: PopGridPosition }> = [];
sorted.forEach((item) => {
let { row, col, colSpan, rowSpan } = item.position;
// 열이 범위를 초과하면 조정
if (col + colSpan - 1 > columns) {
colSpan = columns - col + 1;
}
// 기존 배치와 겹치면 아래로 이동
let attempts = 0;
const maxAttempts = 100;
while (attempts < maxAttempts) {
const currentPos: PopGridPosition = { col, row, colSpan, rowSpan };
const hasOverlap = resolved.some(r => isOverlapping(currentPos, r.position));
if (!hasOverlap) break;
row++;
attempts++;
}
resolved.push({
id: item.id,
position: { col, row, colSpan, rowSpan },
});
});
return resolved;
}
// ========================================
// 좌표 변환
// ========================================
/**
*
*/
export function mouseToGridPosition(
mouseX: number,
mouseY: number,
canvasRect: DOMRect,
columns: number,
rowHeight: number,
gap: number,
padding: number
): { col: number; row: number } {
// 캔버스 내 상대 위치
const relX = mouseX - canvasRect.left - padding;
const relY = mouseY - canvasRect.top - padding;
// 칸 너비 계산
const totalGap = gap * (columns - 1);
const colWidth = (canvasRect.width - padding * 2 - totalGap) / columns;
// 그리드 좌표 계산 (1부터 시작)
const col = Math.max(1, Math.min(columns, Math.floor(relX / (colWidth + gap)) + 1));
const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1);
return { col, row };
}
/**
*
*/
export function gridToPixelPosition(
col: number,
row: number,
colSpan: number,
rowSpan: number,
canvasWidth: number,
columns: number,
rowHeight: number,
gap: number,
padding: number
): { x: number; y: number; width: number; height: number } {
const totalGap = gap * (columns - 1);
const colWidth = (canvasWidth - padding * 2 - totalGap) / columns;
return {
x: padding + (col - 1) * (colWidth + gap),
y: padding + (row - 1) * (rowHeight + gap),
width: colWidth * colSpan + gap * (colSpan - 1),
height: rowHeight * rowSpan + gap * (rowSpan - 1),
};
}
// ========================================
// 위치 검증
// ========================================
/**
*
*/
export function isValidPosition(
position: PopGridPosition,
columns: number
): boolean {
return (
position.col >= 1 &&
position.row >= 1 &&
position.colSpan >= 1 &&
position.rowSpan >= 1 &&
position.col + position.colSpan - 1 <= columns
);
}
/**
*
*/
export function clampPosition(
position: PopGridPosition,
columns: number
): PopGridPosition {
let { col, row, colSpan, rowSpan } = position;
// 최소값 보장
col = Math.max(1, col);
row = Math.max(1, row);
colSpan = Math.max(1, colSpan);
rowSpan = Math.max(1, rowSpan);
// 열 범위 초과 방지
if (col + colSpan - 1 > columns) {
if (col > columns) {
col = 1;
}
colSpan = columns - col + 1;
}
return { col, row, colSpan, rowSpan };
}
// ========================================
// 자동 배치
// ========================================
/**
*
*/
export function findNextEmptyPosition(
existingPositions: PopGridPosition[],
colSpan: number,
rowSpan: number,
columns: number
): PopGridPosition {
let row = 1;
let col = 1;
const maxAttempts = 1000;
let attempts = 0;
while (attempts < maxAttempts) {
const candidatePos: PopGridPosition = { col, row, colSpan, rowSpan };
// 범위 체크
if (col + colSpan - 1 > columns) {
col = 1;
row++;
continue;
}
// 겹침 체크
const hasOverlap = existingPositions.some(pos =>
isOverlapping(candidatePos, pos)
);
if (!hasOverlap) {
return candidatePos;
}
// 다음 위치로 이동
col++;
if (col + colSpan - 1 > columns) {
col = 1;
row++;
}
attempts++;
}
// 실패 시 마지막 행에 배치
return { col: 1, row: row + 1, colSpan, rowSpan };
}
/**
*
*/
export function autoLayoutComponents(
components: Array<{ id: string; colSpan: number; rowSpan: number }>,
columns: number
): Array<{ id: string; position: PopGridPosition }> {
const result: Array<{ id: string; position: PopGridPosition }> = [];
let currentRow = 1;
let currentCol = 1;
components.forEach(comp => {
// 현재 행에 공간이 부족하면 다음 행으로
if (currentCol + comp.colSpan - 1 > columns) {
currentRow++;
currentCol = 1;
}
result.push({
id: comp.id,
position: {
col: currentCol,
row: currentRow,
colSpan: comp.colSpan,
rowSpan: comp.rowSpan,
},
});
currentCol += comp.colSpan;
});
return result;
}

View File

@ -1,588 +1,273 @@
# POP 화면 시스템 아키텍처 # POP 화면 시스템 아키텍처
**최종 업데이트: 2026-02-04** **최종 업데이트: 2026-02-05 (v5 그리드 시스템)**
POP(Point of Production) 화면은 모바일/태블릿 환경에 최적화된 터치 기반 화면 시스템입니다. POP(Point of Production) 화면은 모바일/태블릿 환경에 최적화된 터치 기반 화면 시스템입니다.
이 문서는 POP 화면 구현에 관련된 모든 파일과 그 역할을 정리합니다.
--- ---
## 목차 ## 현재 버전: v5 (CSS Grid)
1. [폴더 구조 개요](#1-폴더-구조-개요) | 항목 | v5 (현재) |
2. [App 라우팅 (app/(pop))](#2-app-라우팅-apppop) |------|----------|
3. [컴포넌트 (components/pop)](#3-컴포넌트-componentspop) | 레이아웃 | CSS Grid |
4. [라이브러리 (lib)](#4-라이브러리-lib) | 배치 방식 | 좌표 기반 (col, row, colSpan, rowSpan) |
5. [버전별 레이아웃 시스템](#5-버전별-레이아웃-시스템) | 모드 | 4개 (mobile_portrait, mobile_landscape, tablet_portrait, tablet_landscape) |
6. [데이터 흐름](#6-데이터-흐름) | 칸 수 | 4/6/8/12칸 |
--- ---
## 1. 폴더 구조 개요 ## 폴더 구조
``` ```
frontend/ frontend/
├── app/(pop)/ # Next.js App Router - POP 라우팅 ├── app/(pop)/ # Next.js App Router
│ ├── layout.tsx # POP 전용 레이아웃 │ ├── layout.tsx # POP 전용 레이아웃
│ └── pop/ │ └── pop/
│ ├── page.tsx # POP 대시보드 (메인) │ ├── page.tsx # 대시보드
│ ├── screens/[screenId]/ # 개별 POP 화면 뷰어 │ ├── screens/[screenId]/ # 화면 뷰어 (v5)
│ ├── test-v4/ # v4 렌더러 테스트 페이지
│ └── work/ # 작업 화면 │ └── work/ # 작업 화면
├── components/pop/ # POP 컴포넌트 라이브러리 ├── components/pop/ # POP 컴포넌트
│ ├── designer/ # 디자이너 모듈 │ ├── designer/ # 디자이너 모듈 ★
│ │ ├── panels/ # 편집 패널 (좌측/우측) │ │ ├── PopDesigner.tsx # 메인 (레이아웃 로드/저장)
│ │ ├── renderers/ # 레이아웃 렌더러 │ │ ├── PopCanvas.tsx # 캔버스 (DnD, 줌, 모드)
│ │ └── types/ # 타입 정의 │ │ ├── panels/
│ ├── management/ # 화면 관리 모듈 │ │ │ └── ComponentEditorPanel.tsx # 속성 편집
│ └── dashboard/ # 대시보드 모듈 │ │ ├── renderers/
│ │ │ └── PopRenderer.tsx # CSS Grid 렌더링
│ │ ├── types/
│ │ │ └── pop-layout.ts # v5 타입 정의
│ │ └── utils/
│ │ └── gridUtils.ts # 위치 계산
│ ├── management/ # 화면 관리
│ └── dashboard/ # 대시보드
└── lib/ └── lib/
├── api/popScreenGroup.ts # POP 화면 그룹 API ├── api/screen.ts # 화면 API
├── registry/PopComponentRegistry.ts # 컴포넌트 레지스트리 └── registry/ # 컴포넌트 레지스트리
└── schemas/popComponentConfig.ts # 컴포넌트 설정 스키마
``` ```
--- ---
## 2. App 라우팅 (app/(pop)) ## 핵심 파일
### `app/(pop)/layout.tsx` ### 1. PopDesigner.tsx (메인)
POP 전용 레이아웃. 데스크톱 레이아웃과 분리되어 터치 최적화 환경 제공. **역할**: 레이아웃 로드/저장, 컴포넌트 CRUD, 히스토리
### `app/(pop)/pop/page.tsx`
**경로**: `/pop`
POP 메인 대시보드. 메뉴 그리드, KPI, 공지사항 등을 표시.
### `app/(pop)/pop/screens/[screenId]/page.tsx`
**경로**: `/pop/screens/:screenId`
**역할**: 개별 POP 화면 뷰어 (디자인 모드 X, 실행 모드)
**핵심 기능**:
- v3/v4 레이아웃 자동 감지 및 렌더링
- 반응형 모드 감지 (태블릿/모바일, 가로/세로)
- 프리뷰 모드 지원 (`?preview=true`)
- **뷰포트 감지 및 비율 스케일링**
```typescript ```typescript
// 뷰포트 너비 감지 (최대 1366px 제한) // 상태 관리
const [viewportWidth, setViewportWidth] = useState(1024); const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
useEffect(() => {
const updateViewportWidth = () => {
setViewportWidth(Math.min(window.innerWidth, 1366));
};
updateViewportWidth();
window.addEventListener("resize", updateViewportWidth);
return () => window.removeEventListener("resize", updateViewportWidth);
}, []);
// 레이아웃 버전 감지 및 렌더링
if (popLayoutV4) {
// v4: PopFlexRenderer 사용 (비율 스케일링 적용)
<div className="mx-auto h-full" style={{ maxWidth: 1366 }}>
<PopFlexRenderer
layout={popLayoutV4}
viewportWidth={viewportWidth}
isDesignMode={false} // 뷰어에서 스케일 적용
/>
</div>
} else if (popLayoutV3) {
// v3: PopLayoutRenderer 사용
<PopLayoutV3Renderer layout={popLayoutV3} modeKey={currentModeKey} />
}
```
### `app/(pop)/pop/test-v4/page.tsx`
**경로**: `/pop/test-v4`
**역할**: v4 레이아웃 시스템 테스트 페이지
**구성**:
- 왼쪽: 컴포넌트 팔레트 (PopPanel)
- 중앙: v4 캔버스 (PopCanvasV4)
- 오른쪽: 속성 패널 (ComponentEditorPanelV4)
---
## 3. 컴포넌트 (components/pop)
### 3.1 디자이너 모듈 (`designer/`)
#### `PopDesigner.tsx`
**역할**: POP 화면 디자이너 메인 컴포넌트
**핵심 기능**:
- v3/v4 모드 전환 (상단 탭)
- 레이아웃 로드/저장
- 컴포넌트 추가/삭제/수정
- 드래그 앤 드롭 (react-dnd)
**상태 관리**:
```typescript
const [layoutMode, setLayoutMode] = useState<"v3" | "v4">("v3");
const [layoutV3, setLayoutV3] = useState<PopLayoutDataV3>(...);
const [layoutV4, setLayoutV4] = useState<PopLayoutDataV4>(...);
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null); const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
const [history, setHistory] = useState<PopLayoutDataV5[]>([]);
// 핵심 함수
handleSave() // 레이아웃 저장
handleAddComponent() // 컴포넌트 추가
handleUpdateComponent() // 컴포넌트 수정
handleDeleteComponent() // 컴포넌트 삭제
handleUndo() / handleRedo() // 히스토리
``` ```
**레이아웃**: ### 2. PopCanvas.tsx (캔버스)
```
┌─────────────────────────────────────────────────┐
│ 툴바 (뒤로가기, 화면명, 모드전환, 저장) │
├──────────┬──────────────────────────┬──────────┤
│ 왼쪽 │ 중앙 캔버스 │ 오른쪽 │
│ 패널 │ │ 패널 │
│ (20%) │ (60%) │ (20%) │
│ │ │ (v4만) │
└──────────┴──────────────────────────┴──────────┘
```
#### `PopCanvas.tsx` (v3용) **역할**: 그리드 캔버스, DnD, 줌, 패닝, 모드 전환
**역할**: v3 레이아웃용 CSS Grid 기반 캔버스
**핵심 기능**:
- 4개 모드 전환 (태블릿 가로/세로, 모바일 가로/세로)
- 그리드 기반 컴포넌트 배치
- 드래그로 위치/크기 조정
#### `PopCanvasV4.tsx` (v4용)
**역할**: v4 레이아웃용 Flexbox 기반 캔버스
**핵심 기능**:
- 단일 캔버스 + 뷰포트 프리뷰
- 3가지 프리셋 (모바일 375px, 태블릿 768px, 데스크톱 1024px)
- 너비 슬라이더로 반응형 테스트
- 줌 컨트롤 (30%~150%)
```typescript ```typescript
// DnD 설정
const DND_ITEM_TYPES = { COMPONENT: "component" };
// 뷰포트 프리셋 (4개 모드)
const VIEWPORT_PRESETS = [ const VIEWPORT_PRESETS = [
{ id: "mobile", label: "모바일", width: 375, height: 667 }, { id: "mobile_portrait", width: 375, columns: 4 },
{ id: "tablet", label: "태블릿", width: 768, height: 1024 }, { id: "mobile_landscape", width: 667, columns: 6 },
{ id: "desktop", label: "데스크톱", width: 1024, height: 768 }, { id: "tablet_portrait", width: 768, columns: 8 },
{ id: "tablet_landscape", width: 1024, columns: 12 },
]; ];
// 기능
- useDrop(): 팔레트에서 컴포넌트 드롭
- handleWheel(): 줌 (30%~150%)
- Space + 드래그: 패닝
``` ```
--- ### 3. PopRenderer.tsx (렌더러)
### 3.2 패널 모듈 (`designer/panels/`) **역할**: CSS Grid 기반 레이아웃 렌더링
#### `PopPanel.tsx`
**역할**: 왼쪽 패널 - 컴포넌트 팔레트 & 편집 탭
**탭 구성**:
1. **컴포넌트 탭**: 드래그 가능한 6개 컴포넌트
2. **편집 탭**: 선택된 컴포넌트 설정
**컴포넌트 팔레트**:
```typescript ```typescript
const COMPONENT_PALETTE = [ // Props
{ type: "pop-field", label: "필드", description: "텍스트, 숫자 등 데이터 입력" }, interface PopRendererProps {
{ type: "pop-button", label: "버튼", description: "저장, 삭제 등 액션 실행" }, layout: PopLayoutDataV5;
{ type: "pop-list", label: "리스트", description: "데이터 목록" }, viewportWidth: number;
{ type: "pop-indicator", label: "인디케이터", description: "KPI, 상태 표시" }, currentMode: GridMode;
{ type: "pop-scanner", label: "스캐너", description: "바코드/QR 스캔" }, isDesignMode: boolean;
{ type: "pop-numpad", label: "숫자패드", description: "숫자 입력 전용" }, selectedComponentId?: string | null;
]; onSelectComponent?: (id: string | null) => void;
```
**드래그 아이템 타입**:
```typescript
export const DND_ITEM_TYPES = { COMPONENT: "component" };
export interface DragItemComponent {
type: typeof DND_ITEM_TYPES.COMPONENT;
componentType: PopComponentType;
} }
```
#### `ComponentEditorPanelV4.tsx` // CSS Grid 스타일 생성
const gridStyle = useMemo(() => ({
**역할**: v4 오른쪽 패널 - 컴포넌트/컨테이너 속성 편집 display: "grid",
gridTemplateColumns: `repeat(${columns}, 1fr)`,
**3개 탭**: gridTemplateRows: `repeat(${rows}, 1fr)`,
1. **크기 탭**: 너비/높이 제약 (fixed/fill/hug) gap: `${gap}px`,
2. **설정 탭**: 라벨, 타입별 설정 padding: `${padding}px`,
3. **데이터 탭**: 데이터 바인딩 (미구현) }), [mode]);
**크기 제약 편집**: // 위치 변환 (12칸 → 다른 모드)
```typescript const convertPosition = (pos: PopGridPosition, targetMode: GridMode) => {
// 너비/높이 모드 const ratio = GRID_BREAKPOINTS[targetMode].columns / 12;
type SizeMode = "fixed" | "fill" | "hug"; return {
col: Math.max(1, Math.round(pos.col * ratio)),
// fixed: 고정 px 값 colSpan: Math.max(1, Math.round(pos.colSpan * ratio)),
// fill: 남은 공간 채움 (flex: 1) row: pos.row,
// hug: 내용에 맞춤 (width: auto) rowSpan: pos.rowSpan,
```
**컨테이너 설정**:
- 방향 (horizontal/vertical)
- 줄바꿈 (wrap)
- 간격 (gap)
- 패딩 (padding)
- 정렬 (alignItems, justifyContent)
---
### 3.3 렌더러 모듈 (`designer/renderers/`)
#### `PopLayoutRenderer.tsx` (v3용)
**역할**: v3 레이아웃을 CSS Grid로 렌더링
**입력**:
- `layout`: PopLayoutDataV3
- `modeKey`: 현재 모드 (tablet_landscape 등)
- `isDesignMode`: 디자인 모드 여부
#### `PopFlexRenderer.tsx` (v4용)
**역할**: v4 레이아웃을 Flexbox로 렌더링 + 비율 스케일링
**핵심 기능**:
- 컨테이너 재귀 렌더링
- 반응형 규칙 적용 (breakpoint)
- 크기 제약 → CSS 스타일 변환
- 컴포넌트 숨김 처리 (hideBelow)
- **비율 스케일링** (뷰어 모드)
**비율 스케일링 시스템**:
```typescript
// 기준 너비 (10인치 태블릿 가로)
const BASE_VIEWPORT_WIDTH = 1024;
// 스케일 계산 (디자인 모드: 1, 뷰어 모드: 실제 비율)
const scale = isDesignMode ? 1 : viewportWidth / BASE_VIEWPORT_WIDTH;
// 예시: 12인치(1366px) 화면
// scale = 1366 / 1024 = 1.33
// 200px 컴포넌트 → 266px
```
**크기 제약 변환 로직** (스케일 적용):
```typescript
function calculateSizeStyle(
size: PopSizeConstraintV4,
settings: PopGlobalSettingsV4,
scale: number = 1 // 스케일 파라미터 추가
): React.CSSProperties {
const style: React.CSSProperties = {};
// 너비 (스케일 적용)
switch (size.width) {
case "fixed":
style.width = `${size.fixedWidth * scale}px`;
style.flexShrink = 0;
break;
case "fill":
style.flex = 1;
style.minWidth = size.minWidth ? `${size.minWidth * scale}px` : 0;
break;
case "hug":
style.width = "auto";
style.flexShrink = 0;
break;
}
// 높이 (스케일 적용)
switch (size.height) {
case "fixed":
style.height = `${size.fixedHeight * scale}px`;
break;
case "fill":
style.flexGrow = 1;
break;
case "hug":
style.height = "auto";
break;
}
return style;
}
```
**컨테이너 스케일 적용**:
```typescript
// gap, padding도 스케일 적용
const scaledGap = gap * scale;
const scaledPadding = padding ? padding * scale : undefined;
```
#### `ComponentRenderer.tsx`
**역할**: 개별 컴포넌트 렌더링 (디자인 모드용 플레이스홀더)
---
### 3.4 타입 정의 (`designer/types/`)
#### `pop-layout.ts`
**역할**: POP 레이아웃 전체 타입 시스템 정의
**파일 크기**: 1442줄 (v1~v4 모든 버전 포함)
상세 내용은 [버전별 레이아웃 시스템](#5-버전별-레이아웃-시스템) 참조.
---
### 3.5 관리 모듈 (`management/`)
#### `PopCategoryTree.tsx`
POP 화면 카테고리 트리 컴포넌트
#### `PopScreenSettingModal.tsx`
POP 화면 설정 모달
#### `PopScreenPreview.tsx`
POP 화면 미리보기
#### `PopScreenFlowView.tsx`
화면 간 플로우 시각화
---
### 3.6 대시보드 모듈 (`dashboard/`)
| 파일 | 역할 |
|------|------|
| `PopDashboard.tsx` | 대시보드 메인 컴포넌트 |
| `DashboardHeader.tsx` | 상단 헤더 (로고, 시간, 사용자) |
| `DashboardFooter.tsx` | 하단 푸터 |
| `MenuGrid.tsx` | 메뉴 그리드 (앱 아이콘 형태) |
| `KpiBar.tsx` | KPI 요약 바 |
| `NoticeBanner.tsx` | 공지 배너 |
| `NoticeList.tsx` | 공지 목록 |
| `ActivityList.tsx` | 최근 활동 목록 |
---
## 4. 라이브러리 (lib)
### `lib/api/popScreenGroup.ts`
**역할**: POP 화면 그룹 API 클라이언트
**API 함수**:
```typescript
// 조회
getPopScreenGroups(searchTerm?: string): Promise<PopScreenGroup[]>
// 생성
createPopScreenGroup(data: CreatePopScreenGroupRequest): Promise<...>
// 수정
updatePopScreenGroup(id: number, data: UpdatePopScreenGroupRequest): Promise<...>
// 삭제
deletePopScreenGroup(id: number): Promise<...>
// 루트 그룹 확보
ensurePopRootGroup(): Promise<...>
```
**트리 변환 유틸리티**:
```typescript
// 플랫 리스트 → 트리 구조
buildPopGroupTree(groups: PopScreenGroup[]): PopScreenGroup[]
```
### `lib/registry/PopComponentRegistry.ts`
**역할**: POP 컴포넌트 중앙 레지스트리
**주요 메서드**:
```typescript
class PopComponentRegistry {
static registerComponent(definition: PopComponentDefinition): void
static unregisterComponent(id: string): void
static getComponent(id: string): PopComponentDefinition | undefined
static getComponentByUrl(url: string): PopComponentDefinition | undefined
static getAllComponents(): PopComponentDefinition[]
static getComponentsByCategory(category: PopComponentCategory): PopComponentDefinition[]
static getComponentsByDevice(device: "mobile" | "tablet"): PopComponentDefinition[]
static searchComponents(query: string): PopComponentDefinition[]
}
```
**카테고리**:
```typescript
type PopComponentCategory =
| "display" // 데이터 표시 (카드, 리스트, 배지)
| "input" // 입력 (스캐너, 터치 입력)
| "action" // 액션 (버튼, 스와이프)
| "layout" // 레이아웃 (컨테이너, 그리드)
| "feedback"; // 피드백 (토스트, 로딩)
```
### `lib/schemas/popComponentConfig.ts`
**역할**: POP 컴포넌트 설정 스키마 (Zod 기반)
**제공 내용**:
- 컴포넌트별 기본값 (`popCardListDefaults`, `popTouchButtonDefaults` 등)
- 컴포넌트별 Zod 스키마 (`popCardListOverridesSchema` 등)
- URL → 기본값/스키마 조회 함수
---
## 5. 버전별 레이아웃 시스템
### v1.0 (deprecated)
- 단일 모드
- 섹션 중첩 구조
- CSS Grid
### v2.0 (deprecated)
- 4개 모드 (태블릿/모바일 x 가로/세로)
- 섹션 + 컴포넌트 분리
- CSS Grid
### v3.0 (현재 기본)
- 4개 모드
- **섹션 제거**, 컴포넌트 직접 배치
- CSS Grid
```typescript
interface PopLayoutDataV3 {
version: "pop-3.0";
layouts: {
tablet_landscape: { componentPositions: Record<string, GridPosition> };
tablet_portrait: { componentPositions: Record<string, GridPosition> };
mobile_landscape: { componentPositions: Record<string, GridPosition> };
mobile_portrait: { componentPositions: Record<string, GridPosition> };
}; };
components: Record<string, PopComponentDefinition>; };
dataFlow: PopDataFlow;
settings: PopGlobalSettings;
}
``` ```
### v4.0 (신규, 권장) ### 4. ComponentEditorPanel.tsx (속성 패널)
- **단일 소스** (1번 설계 → 모든 화면 자동 적응) **역할**: 선택된 컴포넌트 속성 편집
- **제약 기반** (fixed/fill/hug)
- **Flexbox** 렌더링
- **반응형 규칙** (breakpoint)
```typescript ```typescript
interface PopLayoutDataV4 { // 탭 구조
version: "pop-4.0"; - grid: col, row, colSpan, rowSpan (기본 모드에서만 편집)
root: PopContainerV4; // 루트 컨테이너 (스택) - settings: label, type 등
components: Record<string, PopComponentDefinitionV4>; - data: 데이터 바인딩 (미구현)
dataFlow: PopDataFlow; - visibility: 모드별 표시/숨김
settings: PopGlobalSettingsV4; ```
### 5. pop-layout.ts (타입 정의)
**역할**: v5 타입 정의
```typescript
// 그리드 모드
type GridMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
// 브레이크포인트 설정
const GRID_BREAKPOINTS = {
mobile_portrait: { columns: 4, rowHeight: 48, gap: 8, padding: 12 },
mobile_landscape: { columns: 6, rowHeight: 44, gap: 8, padding: 16 },
tablet_portrait: { columns: 8, rowHeight: 52, gap: 12, padding: 20 },
tablet_landscape: { columns: 12, rowHeight: 56, gap: 12, padding: 24 },
};
// 레이아웃 데이터
interface PopLayoutDataV5 {
version: "pop-5.0";
metadata: PopLayoutMetadata;
gridConfig: PopGridConfig;
components: PopComponentDefinitionV5[];
globalSettings: PopGlobalSettingsV5;
} }
interface PopContainerV4 { // 컴포넌트 정의
interface PopComponentDefinitionV5 {
id: string; id: string;
type: "stack"; type: PopComponentType;
direction: "horizontal" | "vertical"; label: string;
wrap: boolean; gridPosition: PopGridPosition; // col, row, colSpan, rowSpan
gap: number; config: PopComponentConfig;
alignItems: "start" | "center" | "end" | "stretch"; visibility: Record<GridMode, boolean>;
justifyContent: "start" | "center" | "end" | "space-between"; modeOverrides?: Record<GridMode, PopModeOverrideV5>;
padding?: number;
responsive?: PopResponsiveRuleV4[]; // 반응형 규칙
children: (string | PopContainerV4)[]; // 컴포넌트 ID 또는 중첩 컨테이너
} }
interface PopSizeConstraintV4 { // 위치
width: "fixed" | "fill" | "hug"; interface PopGridPosition {
height: "fixed" | "fill" | "hug"; col: number; // 시작 열 (1부터)
fixedWidth?: number; row: number; // 시작 행 (1부터)
fixedHeight?: number; colSpan: number; // 열 크기 (1~12)
minWidth?: number; rowSpan: number; // 행 크기 (1~)
maxWidth?: number;
minHeight?: number;
} }
``` ```
### 버전 비교표 ### 6. gridUtils.ts (유틸리티)
| 항목 | v3 | v4 | **역할**: 그리드 위치 계산
|------|----|----|
| 설계 횟수 | 4번 (모드별) | 1번 |
| 위치 지정 | col, row, colSpan, rowSpan | 제약 (fill/fixed/hug) |
| 렌더링 | CSS Grid | Flexbox |
| 반응형 | 수동 (모드 전환) | 자동 (breakpoint 규칙) |
| 복잡도 | 높음 | 낮음 |
--- ```typescript
// 위치 변환
convertPositionToMode(pos, targetMode)
## 6. 데이터 흐름 // 겹침 감지
isOverlapping(posA, posB)
### 화면 로드 흐름 // 빈 위치 찾기
findNextEmptyPosition(layout, mode)
``` // 마우스 → 그리드 좌표
[사용자 접속] mouseToGridPosition(mouseX, mouseY, canvasRect, mode)
[/pop/screens/:screenId]
[screenApi.getLayoutPop(screenId)]
[레이아웃 버전 감지]
├── v4 → PopFlexRenderer
├── v3 → PopLayoutRenderer
└── v1/v2 → ensureV3Layout() → v3로 변환
```
### 디자이너 저장 흐름
```
[사용자 편집]
[hasChanges = true]
[저장 버튼 클릭]
[screenApi.saveLayoutPop(screenId, layoutV3 | layoutV4)]
[hasChanges = false]
```
### 컴포넌트 드래그 앤 드롭 흐름
```
[PopPanel의 컴포넌트 드래그]
[DragItemComponent { type: "component", componentType: "pop-button" }]
[캔버스 Drop 감지]
[v3: handleDropComponentV3(type, gridPosition)]
[v4: handleDropComponentV4(type, containerId)]
[레이아웃 상태 업데이트]
[hasChanges = true]
``` ```
--- ---
## 관련 문서 ## 데이터 흐름
- [PLAN.md](./PLAN.md) - 개발 계획 및 로드맵 ```
- [components-spec.md](./components-spec.md) - 컴포넌트 상세 스펙 [사용자 액션]
- [CHANGELOG.md](./CHANGELOG.md) - 변경 이력
[PopDesigner] ← 상태 관리 (layout, selectedComponentId, history)
[PopCanvas] ← DnD, 줌, 모드 전환
[PopRenderer] ← CSS Grid 렌더링
[컴포넌트 표시]
```
### 저장 흐름
```
[저장 버튼]
PopDesigner.handleSave()
screenApi.saveLayoutPop(screenId, layout)
[백엔드] screenManagementService.saveLayoutPop()
[DB] screen_layouts_pop 테이블
```
### 로드 흐름
```
[페이지 로드]
PopDesigner useEffect
screenApi.getLayoutPop(screenId)
isV5Layout(data) 체크
setLayout(data) 또는 createEmptyPopLayoutV5()
```
--- ---
*이 문서는 POP 화면 시스템의 구조를 이해하고 유지보수하기 위한 참조용으로 작성되었습니다.* ## API 엔드포인트
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/screen-management/layout-pop/:screenId` | 레이아웃 조회 |
| POST | `/api/screen-management/layout-pop/:screenId` | 레이아웃 저장 |
---
## 삭제된 레거시 (참고용)
| 파일 | 버전 | 이유 |
|------|------|------|
| PopCanvasV4.tsx | v4 | Flexbox 기반, v5로 대체 |
| PopFlexRenderer.tsx | v4 | Flexbox 렌더러, v5로 대체 |
| PopLayoutRenderer.tsx | v3 | 절대 좌표 기반, v5로 대체 |
| ComponentEditorPanelV4.tsx | v4 | v5 전용으로 통합 |
---
*상세 스펙: [SPEC.md](./SPEC.md) | 파일 목록: [FILES.md](./FILES.md)*

View File

@ -6,10 +6,105 @@
## [미출시] ## [미출시]
- Phase 2: 모드별 오버라이드 기능 (진행 중) - Phase 4: 실제 컴포넌트 구현 (pop-field, pop-button 등)
- Phase 3: 컴포넌트 표시/숨김 - 데이터 바인딩 구현
- Phase 4: 순서 오버라이드 - 워크플로우 연동
- Tier 2, 3 컴포넌트
---
## [2026-02-05] v5 그리드 시스템 완전 통합
### 배경 (왜 v5로 전환했는가)
**문제 상황**:
- v4 Flexbox로 반응형 구현 시도 → 배치가 예측 불가능
- 캔버스에 "그리듯이" 배치하면 화면 크기별로 깨짐
**상급자 피드백**:
> "이런 식이면 나중에 문제가 생긴다."
> "스크린의 픽셀 규격과 마진 간격 규칙을 설정해라.
> 큰 화면 디자인의 전체 프레임 규격과 사이즈 간격 규칙을 정한 다음에
> 거기에 컴포넌트를 끼워 맞추듯 우리의 규칙 내로 움직이게 바탕을 잡아라."
**연구 내용**:
- Softr: 블록 기반, 제약 기반 레이아웃
- Ant Design: 24열 그리드, 8px 간격
- Material Design: 4/8/12열, 반응형 브레이크포인트
**결정**: CSS Grid 기반 그리드 시스템 (v5) 채택
→ 상세: [decisions/003-v5-grid-system.md](./decisions/003-v5-grid-system.md)
### popdocs 문서 구조 재정비
**배경**: 문서가 AI 에이전트 진입점 역할을 못함, 컨텍스트 효율화 필요
**적용 기법**: Progressive Disclosure (점진적 공개), Token as Currency
**추가된 파일**:
- `SAVE_RULES.md`: AI 저장/조회 규칙, 템플릿
- `STATUS.md`: 현재 진행 상태, 중단점
- `PROBLEMS.md`: 문제-해결 색인
- `INDEX.md`: 기능별 색인
- `sessions/`: 날짜별 작업 기록
**문서 계층**:
- Layer 1 (진입점): README, STATUS, SAVE_RULES
- Layer 2 (상세): CHANGELOG, PROBLEMS, INDEX, FILES, ARCHITECTURE
- Layer 3 (심화): decisions/, sessions/, archive/
### Breaking Changes
- **v1, v2, v3, v4 레이아웃 완전 삭제**
- 기존 POP 화면 데이터 전체 초기화 필요
- 레거시 컴포넌트 및 타입 삭제
### Added
- **CSS Grid 기반 그리드 시스템 (v5)**
- 4개 모드별 칸 수: 4/6/8/12칸
- 명시적 위치 지정 (col, row, colSpan, rowSpan)
- 모드별 오버라이드 지원
- 자동 위치 변환 (12칸 기준 → 다른 모드)
- **통합된 파일 구조**
- `PopCanvas.tsx`: 그리드 캔버스 (DnD + 줌 + 모드 전환)
- `PopRenderer.tsx`: 그리드 렌더링
- `ComponentEditorPanel.tsx`: 속성 편집
- `pop-layout.ts`: v5 전용 타입 정의
- `gridUtils.ts`: 그리드 유틸리티 함수
### Removed
- `PopCanvasV4.tsx`, `PopCanvas.tsx (v3)`
- `PopFlexRenderer.tsx`, `PopLayoutRenderer.tsx`
- `ComponentEditorPanelV4.tsx`, `PopPanel.tsx`
- v1, v2, v3, v4 타입 정의 및 유틸리티 함수
- `test-v4` 테스트 페이지
### Changed
- `screenManagementService.ts`: v5 전용으로 단순화
- `screen_layouts_pop` 테이블: 기존 데이터 삭제, v5 전용
- `PopDesigner.tsx`: v5 전용으로 리팩토링
- 뷰어 페이지: v5 렌더러 전용
### Technical Details
```typescript
// v5 그리드 모드
type GridMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
// 그리드 설정
const GRID_BREAKPOINTS = {
mobile_portrait: { columns: 4, rowHeight: 48, gap: 8, padding: 12 },
mobile_landscape: { columns: 6, rowHeight: 44, gap: 8, padding: 16 },
tablet_portrait: { columns: 8, rowHeight: 52, gap: 12, padding: 20 },
tablet_landscape: { columns: 12, rowHeight: 56, gap: 12, padding: 24 },
};
// 컴포넌트 위치
interface PopGridPosition {
col: number; // 시작 열 (1부터)
row: number; // 시작 행 (1부터)
colSpan: number; // 열 크기
rowSpan: number; // 행 크기
}
```
--- ---

View File

@ -1,6 +1,6 @@
# POP 파일 상세 목록 # POP 파일 상세 목록
**최종 업데이트: 2026-02-04** **최종 업데이트: 2026-02-05 (v5 그리드 시스템 통합)**
이 문서는 POP 화면 시스템과 관련된 모든 파일을 나열하고 각 파일의 역할을 설명합니다. 이 문서는 POP 화면 시스템과 관련된 모든 파일을 나열하고 각 파일의 역할을 설명합니다.
@ -13,10 +13,11 @@
3. [Panels 파일](#3-panels-파일) 3. [Panels 파일](#3-panels-파일)
4. [Renderers 파일](#4-renderers-파일) 4. [Renderers 파일](#4-renderers-파일)
5. [Types 파일](#5-types-파일) 5. [Types 파일](#5-types-파일)
6. [Management 파일](#6-management-파일) 6. [Utils 파일](#6-utils-파일)
7. [Dashboard 파일](#7-dashboard-파일) 7. [Management 파일](#7-management-파일)
8. [Library 파일](#8-library-파일) 8. [Dashboard 파일](#8-dashboard-파일)
9. [루트 컴포넌트 파일](#9-루트-컴포넌트-파일) 9. [Library 파일](#9-library-파일)
10. [루트 컴포넌트 파일](#10-루트-컴포넌트-파일)
--- ---
@ -46,34 +47,36 @@
| 항목 | 내용 | | 항목 | 내용 |
|------|------| |------|------|
| 역할 | 개별 POP 화면 뷰어 | | 역할 | 개별 POP 화면 뷰어 (v5 전용) |
| 경로 | `/pop/screens/:screenId` | | 경로 | `/pop/screens/:screenId` |
| 라인 수 | 468줄 | | 버전 | v5 그리드 시스템 전용 |
**핵심 코드 구조**: **핵심 코드 구조**:
```typescript ```typescript
// 상태 // v5 레이아웃 상태
const [popLayoutV3, setPopLayoutV3] = useState<PopLayoutDataV3 | null>(null); const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
const [popLayoutV4, setPopLayoutV4] = useState<PopLayoutDataV4 | null>(null);
// 레이아웃 로드 // 레이아웃 로드
useEffect(() => { useEffect(() => {
const popLayout = await screenApi.getLayoutPop(screenId); const popLayout = await screenApi.getLayoutPop(screenId);
if (isPopLayoutV4(popLayout)) { if (isV5Layout(popLayout)) {
setPopLayoutV4(popLayout); setLayout(popLayout);
} else if (isPopLayout(popLayout)) { } else {
const v3Layout = ensureV3Layout(popLayout); // 레거시 레이아웃은 빈 v5로 처리
setPopLayoutV3(v3Layout); setLayout(createEmptyPopLayoutV5());
} }
}, [screenId]); }, [screenId]);
// 렌더링 분기 // v5 그리드 렌더링
{popLayoutV4 ? ( {hasComponents ? (
<PopFlexRenderer layout={popLayoutV4} viewportWidth={...} /> <PopRenderer
) : popLayoutV3 ? ( layout={layout}
<PopLayoutV3Renderer layout={popLayoutV3} modeKey={currentModeKey} /> viewportWidth={viewportWidth}
currentMode={currentModeKey}
isDesignMode={false}
/>
) : ( ) : (
// 빈 화면 // 빈 화면
)} )}
@ -83,43 +86,7 @@ useEffect(() => {
- 반응형 모드 감지 (useResponsiveModeWithOverride) - 반응형 모드 감지 (useResponsiveModeWithOverride)
- 프리뷰 모드 (`?preview=true`) - 프리뷰 모드 (`?preview=true`)
- 디바이스/방향 수동 전환 (프리뷰 모드) - 디바이스/방향 수동 전환 (프리뷰 모드)
- v1/v2/v3/v4 레이아웃 자동 감지 - 4개 그리드 모드 지원
---
### `frontend/app/(pop)/pop/test-v4/page.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | v4 렌더러 테스트 페이지 |
| 경로 | `/pop/test-v4` |
| 라인 수 | 150줄 |
**핵심 코드 구조**:
```typescript
export default function TestV4Page() {
const [layout, setLayout] = useState<PopLayoutDataV4>(createEmptyPopLayoutV4());
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
const [selectedContainerId, setSelectedContainerId] = useState<string | null>(null);
const [idCounter, setIdCounter] = useState(1);
// 컴포넌트 CRUD
const handleDropComponent = useCallback(...);
const handleDeleteComponent = useCallback(...);
const handleUpdateComponent = useCallback(...);
const handleUpdateContainer = useCallback(...);
return (
<DndProvider backend={HTML5Backend}>
{/* 3-column 레이아웃 */}
<PopPanel />
<PopCanvasV4 ... />
<ComponentEditorPanelV4 ... />
</DndProvider>
);
}
```
--- ---
@ -138,8 +105,7 @@ export default function TestV4Page() {
| 항목 | 내용 | | 항목 | 내용 |
|------|------| |------|------|
| 역할 | POP 화면 디자이너 메인 | | 역할 | POP 화면 디자이너 메인 (v5 전용) |
| 라인 수 | 524줄 |
| 의존성 | react-dnd, ResizablePanelGroup | | 의존성 | react-dnd, ResizablePanelGroup |
**핵심 Props**: **핵심 Props**:
@ -155,41 +121,29 @@ interface PopDesignerProps {
**상태 관리**: **상태 관리**:
```typescript ```typescript
// 레이아웃 모드 // v5 레이아웃
const [layoutMode, setLayoutMode] = useState<"v3" | "v4">("v3"); const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
// v3 레이아웃
const [layoutV3, setLayoutV3] = useState<PopLayoutDataV3>(createEmptyPopLayoutV3());
// v4 레이아웃
const [layoutV4, setLayoutV4] = useState<PopLayoutDataV4>(createEmptyPopLayoutV4());
// 선택 상태 // 선택 상태
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null); const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
const [selectedContainerId, setSelectedContainerId] = useState<string | null>(null);
// 그리드 모드 (4개)
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
// UI 상태 // UI 상태
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false); const [hasChanges, setHasChanges] = useState(false);
// v3용 상태
const [activeDevice, setActiveDevice] = useState<DeviceType>("tablet");
const [activeModeKey, setActiveModeKey] = useState<PopLayoutModeKey>("tablet_landscape");
``` ```
**주요 핸들러**: **주요 핸들러**:
| 핸들러 | 역할 | | 핸들러 | 역할 |
|--------|------| |--------|------|
| `handleDropComponentV3` | v3 컴포넌트 드롭 | | `handleDropComponent` | 컴포넌트 드롭 (그리드 위치 계산) |
| `handleDropComponentV4` | v4 컴포넌트 드롭 | | `handleUpdateComponent` | 컴포넌트 속성 수정 |
| `handleUpdateComponentDefinitionV3` | v3 컴포넌트 정의 수정 | | `handleDeleteComponent` | 컴포넌트 삭제 |
| `handleUpdateComponentV4` | v4 컴포넌트 수정 | | `handleSave` | v5 레이아웃 저장 |
| `handleUpdateContainerV4` | v4 컨테이너 수정 |
| `handleDeleteComponentV3` | v3 컴포넌트 삭제 |
| `handleDeleteComponentV4` | v4 컴포넌트 삭제 |
| `handleSave` | 레이아웃 저장 |
--- ---
@ -197,32 +151,21 @@ const [activeModeKey, setActiveModeKey] = useState<PopLayoutModeKey>("tablet_lan
| 항목 | 내용 | | 항목 | 내용 |
|------|------| |------|------|
| 역할 | v3 CSS Grid 기반 캔버스 | | 역할 | v5 CSS Grid 기반 캔버스 |
| 렌더링 | CSS Grid | | 렌더링 | CSS Grid (4/6/8/12칸) |
| 모드 | 4개 (태블릿/모바일 x 가로/세로) | | 모드 | 4개 (태블릿/모바일 x 가로/세로) |
---
### `frontend/components/pop/designer/PopCanvasV4.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | v4 Flexbox 기반 캔버스 |
| 라인 수 | 309줄 |
| 렌더링 | Flexbox (via PopFlexRenderer) |
**핵심 Props**: **핵심 Props**:
```typescript ```typescript
interface PopCanvasV4Props { interface PopCanvasProps {
layout: PopLayoutDataV4; layout: PopLayoutDataV5;
selectedComponentId: string | null; selectedComponentId: string | null;
selectedContainerId: string | null; currentMode: GridMode;
onModeChange: (mode: GridMode) => void;
onSelectComponent: (id: string | null) => void; onSelectComponent: (id: string | null) => void;
onSelectContainer: (id: string | null) => void; onDropComponent: (type: PopComponentType, position: PopGridPosition) => void;
onDropComponent: (type: PopComponentType, containerId: string) => void; onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinitionV5>) => void;
onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinitionV4>) => void;
onUpdateContainer: (containerId: string, updates: Partial<PopContainerV4>) => void;
onDeleteComponent: (componentId: string) => void; onDeleteComponent: (componentId: string) => void;
} }
``` ```
@ -231,18 +174,19 @@ interface PopCanvasV4Props {
```typescript ```typescript
const VIEWPORT_PRESETS = [ const VIEWPORT_PRESETS = [
{ id: "mobile", label: "모바일", width: 375, height: 667, icon: Smartphone }, { id: "mobile_portrait", label: "모바일 세로", width: 375, height: 667 }, // 4칸
{ id: "tablet", label: "태블릿", width: 768, height: 1024, icon: Tablet }, { id: "mobile_landscape", label: "모바일 가로", width: 667, height: 375 }, // 6칸
{ id: "desktop", label: "데스크톱", width: 1024, height: 768, icon: Monitor }, { id: "tablet_portrait", label: "태블릿 세로", width: 768, height: 1024 }, // 8칸
{ id: "tablet_landscape", label: "태블릿 가로", width: 1024, height: 768 }, // 12칸
]; ];
``` ```
**제공 기능**: **제공 기능**:
- 뷰포트 프리셋 전환 - 4개 모드 프리셋 전환
- 너비 슬라이더 (320px ~ 1440px)
- 줌 컨트롤 (30% ~ 150%) - 줌 컨트롤 (30% ~ 150%)
- 패닝 (Space + 드래그 또는 휠 클릭) - 패닝 (Space + 드래그)
- 컴포넌트 드롭 - 컴포넌트 드래그 앤 드롭
- 그리드 좌표 계산
--- ---
@ -250,75 +194,31 @@ const VIEWPORT_PRESETS = [
```typescript ```typescript
export { default as PopDesigner } from "./PopDesigner"; export { default as PopDesigner } from "./PopDesigner";
export { PopCanvas } from "./PopCanvas"; export { default as PopCanvas } from "./PopCanvas";
export { PopCanvasV4 } from "./PopCanvasV4"; export { default as ComponentEditorPanel } from "./panels/ComponentEditorPanel";
export * from "./panels"; export { default as PopRenderer } from "./renderers/PopRenderer";
export * from "./renderers";
export * from "./types"; export * from "./types";
export * from "./utils/gridUtils";
``` ```
--- ---
## 3. Panels 파일 ## 3. Panels 파일
### `frontend/components/pop/designer/panels/PopPanel.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | 왼쪽 패널 (컴포넌트 팔레트 + 편집) |
| 라인 수 | 369줄 |
**탭 구성**:
1. `components` - 컴포넌트 팔레트
2. `edit` - 선택된 컴포넌트 편집
**컴포넌트 팔레트**:
```typescript
const COMPONENT_PALETTE = [
{ type: "pop-field", label: "필드", icon: Type, description: "텍스트, 숫자 등 데이터 입력" },
{ type: "pop-button", label: "버튼", icon: MousePointer, description: "저장, 삭제 등 액션 실행" },
{ type: "pop-list", label: "리스트", icon: List, description: "데이터 목록 (카드 템플릿 지원)" },
{ type: "pop-indicator", label: "인디케이터", icon: Activity, description: "KPI, 상태 표시" },
{ type: "pop-scanner", label: "스캐너", icon: ScanLine, description: "바코드/QR 스캔" },
{ type: "pop-numpad", label: "숫자패드", icon: Calculator, description: "숫자 입력 전용" },
];
```
**내보내기 (exports)**:
```typescript
export const DND_ITEM_TYPES = { COMPONENT: "component" };
export interface DragItemComponent { ... }
export function PopPanel({ ... }: PopPanelProps) { ... }
```
---
### `frontend/components/pop/designer/panels/ComponentEditorPanel.tsx` ### `frontend/components/pop/designer/panels/ComponentEditorPanel.tsx`
| 항목 | 내용 | | 항목 | 내용 |
|------|------| |------|------|
| 역할 | v3 컴포넌트 편집 패널 | | 역할 | v5 컴포넌트 편집 패널 |
| 용도 | PopPanel 내부에서 사용 | | 위치 | 오른쪽 사이드바 |
---
### `frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | v4 오른쪽 속성 패널 |
| 라인 수 | 609줄 |
**핵심 Props**: **핵심 Props**:
```typescript ```typescript
interface ComponentEditorPanelV4Props { interface ComponentEditorPanelProps {
component: PopComponentDefinitionV4 | null; component: PopComponentDefinitionV5 | null;
container: PopContainerV4 | null; currentMode: GridMode;
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV4>) => void; onUpdateComponent?: (updates: Partial<PopComponentDefinitionV5>) => void;
onUpdateContainer?: (updates: Partial<PopContainerV4>) => void;
className?: string; className?: string;
} }
``` ```
@ -327,115 +227,71 @@ interface ComponentEditorPanelV4Props {
| 탭 | 아이콘 | 내용 | | 탭 | 아이콘 | 내용 |
|----|--------|------| |----|--------|------|
| `size` | Maximize2 | 크기 제약 (fixed/fill/hug) | | `grid` | Grid3x3 | 그리드 위치 (col, row, colSpan, rowSpan) |
| `settings` | Settings | 라벨, 타입별 설정 | | `settings` | Settings | 라벨, 타입별 설정 |
| `data` | Database | 데이터 바인딩 (미구현) | | `data` | Database | 데이터 바인딩 (Phase 4) |
**내부 컴포넌트**:
| 컴포넌트 | 역할 |
|----------|------|
| `SizeConstraintForm` | 너비/높이 제약 편집 |
| `SizeButton` | fixed/fill/hug 선택 버튼 |
| `ContainerSettingsForm` | 컨테이너 방향/정렬/간격 편집 |
| `ComponentSettingsForm` | 라벨 편집 |
| `DataBindingPlaceholder` | 데이터 바인딩 플레이스홀더 |
--- ---
### `frontend/components/pop/designer/panels/index.ts` ### `frontend/components/pop/designer/panels/index.ts`
```typescript ```typescript
export { PopPanel, DND_ITEM_TYPES } from "./PopPanel"; export { default as ComponentEditorPanel, default } from "./ComponentEditorPanel";
export type { DragItemComponent } from "./PopPanel";
export { ComponentEditorPanel } from "./ComponentEditorPanel";
export { ComponentEditorPanelV4 } from "./ComponentEditorPanelV4";
``` ```
--- ---
## 4. Renderers 파일 ## 4. Renderers 파일
### `frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx` ### `frontend/components/pop/designer/renderers/PopRenderer.tsx`
| 항목 | 내용 | | 항목 | 내용 |
|------|------| |------|------|
| 역할 | v3 레이아웃 CSS Grid 렌더러 | | 역할 | v5 레이아웃 CSS Grid 렌더러 |
| 입력 | PopLayoutDataV3, modeKey | | 입력 | PopLayoutDataV5, viewportWidth, currentMode |
**내보내기**:
```typescript
export function PopLayoutRenderer({ ... }) { ... }
export function hasBaseLayout(layout: PopLayoutDataV3): boolean { ... }
export function getEffectiveModeLayout(layout: PopLayoutDataV3, modeKey: PopLayoutModeKey) { ... }
```
---
### `frontend/components/pop/designer/renderers/PopFlexRenderer.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | v4 레이아웃 Flexbox 렌더러 |
| 라인 수 | 498줄 |
| 입력 | PopLayoutDataV4, viewportWidth |
**핵심 Props**: **핵심 Props**:
```typescript ```typescript
interface PopFlexRendererProps { interface PopRendererProps {
layout: PopLayoutDataV4; layout: PopLayoutDataV5;
viewportWidth: number; viewportWidth: number;
currentMode?: GridMode;
isDesignMode?: boolean; isDesignMode?: boolean;
selectedComponentId?: string | null; selectedComponentId?: string | null;
onComponentClick?: (componentId: string) => void; onComponentClick?: (componentId: string) => void;
onContainerClick?: (containerId: string) => void;
onBackgroundClick?: () => void; onBackgroundClick?: () => void;
className?: string; className?: string;
} }
``` ```
**내부 컴포넌트**: **CSS Grid 스타일 생성**:
| 컴포넌트 | 역할 |
|----------|------|
| `ContainerRenderer` | 컨테이너 재귀 렌더링 |
| `ComponentRendererV4` | v4 컴포넌트 렌더링 |
**핵심 함수**:
```typescript ```typescript
// 반응형 규칙 적용 const gridStyle: React.CSSProperties = {
function applyResponsiveRules(container: PopContainerV4, viewportWidth: number): PopContainerV4 display: "grid",
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
// 크기 제약 → CSS 스타일 gridAutoRows: `${breakpoint.rowHeight}px`,
function calculateSizeStyle(size: PopSizeConstraintV4, settings: PopGlobalSettingsV4): React.CSSProperties gap: `${breakpoint.gap}px`,
padding: `${breakpoint.padding}px`,
// 정렬 값 변환 };
function mapAlignment(value: string): React.CSSProperties["alignItems"]
function mapJustify(value: string): React.CSSProperties["justifyContent"]
// 컴포넌트 내용 렌더링
function renderComponentContent(component: PopComponentDefinitionV4, ...): React.ReactNode
``` ```
--- **컴포넌트 위치 변환**:
### `frontend/components/pop/designer/renderers/ComponentRenderer.tsx` ```typescript
const convertPosition = (position: PopGridPosition): React.CSSProperties => ({
| 항목 | 내용 | gridColumn: `${position.col} / span ${position.colSpan}`,
|------|------| gridRow: `${position.row} / span ${position.rowSpan}`,
| 역할 | 개별 컴포넌트 렌더러 (디자인 모드용) | });
```
--- ---
### `frontend/components/pop/designer/renderers/index.ts` ### `frontend/components/pop/designer/renderers/index.ts`
```typescript ```typescript
export { PopLayoutRenderer, hasBaseLayout, getEffectiveModeLayout } from "./PopLayoutRenderer"; export { default as PopRenderer, default } from "./PopRenderer";
export { ComponentRenderer } from "./ComponentRenderer";
export { PopFlexRenderer } from "./PopFlexRenderer";
``` ```
--- ---
@ -446,85 +302,55 @@ export { PopFlexRenderer } from "./PopFlexRenderer";
| 항목 | 내용 | | 항목 | 내용 |
|------|------| |------|------|
| 역할 | POP 레이아웃 전체 타입 시스템 | | 역할 | POP 레이아웃 v5 타입 시스템 |
| 라인 수 | 1442줄 | | 버전 | v5 전용 (레거시 제거됨) |
**주요 타입** (v4): **핵심 타입**:
```typescript ```typescript
// v4 레이아웃 // 그리드 모드
interface PopLayoutDataV4 { type GridMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
version: "pop-4.0";
root: PopContainerV4;
components: Record<string, PopComponentDefinitionV4>;
dataFlow: PopDataFlow;
settings: PopGlobalSettingsV4;
metadata?: PopLayoutMetadata;
}
// v4 컨테이너 // 그리드 브레이크포인트
interface PopContainerV4 { interface GridBreakpoint {
id: string; label: string;
type: "stack"; columns: number;
direction: "horizontal" | "vertical"; minWidth: number;
wrap: boolean; maxWidth: number;
rowHeight: number;
gap: number; gap: number;
alignItems: "start" | "center" | "end" | "stretch"; padding: number;
justifyContent: "start" | "center" | "end" | "space-between";
padding?: number;
responsive?: PopResponsiveRuleV4[];
children: (string | PopContainerV4)[];
} }
// v4 크기 제약 // v5 레이아웃
interface PopSizeConstraintV4 { interface PopLayoutDataV5 {
width: "fixed" | "fill" | "hug"; version: "pop-5.0";
height: "fixed" | "fill" | "hug"; gridConfig: PopGridConfig;
fixedWidth?: number; components: Record<string, PopComponentDefinitionV5>;
fixedHeight?: number;
minWidth?: number;
maxWidth?: number;
minHeight?: number;
}
// v4 반응형 규칙
interface PopResponsiveRuleV4 {
breakpoint: number;
direction?: "horizontal" | "vertical";
gap?: number;
hidden?: boolean;
}
```
**주요 타입** (v3):
```typescript
// v3 레이아웃
interface PopLayoutDataV3 {
version: "pop-3.0";
layouts: {
tablet_landscape: PopModeLayoutV3;
tablet_portrait: PopModeLayoutV3;
mobile_landscape: PopModeLayoutV3;
mobile_portrait: PopModeLayoutV3;
};
components: Record<string, PopComponentDefinition>;
dataFlow: PopDataFlow; dataFlow: PopDataFlow;
settings: PopGlobalSettings; settings: PopGlobalSettingsV5;
metadata?: PopLayoutMetadata; metadata?: PopLayoutMetadata;
} overrides?: Record<GridMode, PopModeOverrideV5>;
// v3 모드별 레이아웃
interface PopModeLayoutV3 {
componentPositions: Record<string, GridPosition>;
} }
// 그리드 위치 // 그리드 위치
interface GridPosition { interface PopGridPosition {
col: number; col: number; // 시작 열 (1부터)
row: number; row: number; // 시작 행 (1부터)
colSpan: number; colSpan: number; // 열 크기
rowSpan: number; rowSpan: number; // 행 크기
}
// v5 컴포넌트 정의
interface PopComponentDefinitionV5 {
id: string;
type: PopComponentType;
label?: string;
position: PopGridPosition;
visibility?: { modes: GridMode[]; defaultVisible: boolean };
dataBinding?: PopDataBinding;
style?: PopStylePreset;
config?: PopComponentConfig;
} }
``` ```
@ -532,21 +358,11 @@ interface GridPosition {
| 함수 | 역할 | | 함수 | 역할 |
|------|------| |------|------|
| `createEmptyPopLayoutV4()` | 빈 v4 레이아웃 생성 | | `createEmptyPopLayoutV5()` | 빈 v5 레이아웃 생성 |
| `createEmptyPopLayoutV3()` | 빈 v3 레이아웃 생성 | | `addComponentToV5Layout()` | v5에 컴포넌트 추가 |
| `addComponentToV4Layout()` | v4에 컴포넌트 추가 | | `createComponentDefinitionV5()` | v5 컴포넌트 정의 생성 |
| `removeComponentFromV4Layout()` | v4에서 컴포넌트 삭제 | | `isV5Layout()` | v5 타입 가드 |
| `updateComponentInV4Layout()` | v4 컴포넌트 수정 | | `detectGridMode()` | 뷰포트 너비로 모드 감지 |
| `updateContainerV4()` | v4 컨테이너 수정 |
| `findContainerV4()` | v4 컨테이너 찾기 |
| `addComponentToV3Layout()` | v3에 컴포넌트 추가 |
| `removeComponentFromV3Layout()` | v3에서 컴포넌트 삭제 |
| `updateComponentPositionInModeV3()` | v3 특정 모드 위치 수정 |
| `isV4Layout()` | v4 타입 가드 |
| `isV3Layout()` | v3 타입 가드 |
| `ensureV3Layout()` | v1/v2/v3 → v3 변환 |
| `migrateV2ToV3()` | v2 → v3 마이그레이션 |
| `migrateV1ToV3()` | v1 → v3 마이그레이션 |
--- ---
@ -558,7 +374,32 @@ export * from "./pop-layout";
--- ---
## 6. Management 파일 ## 6. Utils 파일
### `frontend/components/pop/designer/utils/gridUtils.ts`
| 항목 | 내용 |
|------|------|
| 역할 | 그리드 위치 계산 유틸리티 |
| 용도 | 좌표 변환, 겹침 감지, 자동 배치 |
**주요 함수**:
| 함수 | 역할 |
|------|------|
| `convertPositionToMode()` | 12칸 기준 위치를 다른 모드로 변환 |
| `isOverlapping()` | 두 위치 겹침 여부 확인 |
| `resolveOverlaps()` | 겹침 해결 (아래로 밀기) |
| `mouseToGridPosition()` | 마우스 좌표 → 그리드 좌표 |
| `gridToPixelPosition()` | 그리드 좌표 → 픽셀 좌표 |
| `isValidPosition()` | 위치 유효성 검사 |
| `clampPosition()` | 위치 범위 조정 |
| `findNextEmptyPosition()` | 다음 빈 위치 찾기 |
| `autoLayoutComponents()` | 자동 배치 |
---
## 7. Management 파일
### `frontend/components/pop/management/PopCategoryTree.tsx` ### `frontend/components/pop/management/PopCategoryTree.tsx`
@ -607,7 +448,7 @@ export { PopScreenFlowView } from "./PopScreenFlowView";
--- ---
## 7. Dashboard 파일 ## 8. Dashboard 파일
### `frontend/components/pop/dashboard/PopDashboard.tsx` ### `frontend/components/pop/dashboard/PopDashboard.tsx`
@ -618,134 +459,27 @@ export { PopScreenFlowView } from "./PopScreenFlowView";
--- ---
### `frontend/components/pop/dashboard/DashboardHeader.tsx` ### 기타 Dashboard 컴포넌트
| 항목 | 내용 | | 파일 | 역할 |
|------|------| |------|------|
| 역할 | 상단 헤더 | | `DashboardHeader.tsx` | 상단 헤더 |
| 표시 | 로고, 시간, 사용자 정보 | | `DashboardFooter.tsx` | 하단 푸터 |
| `MenuGrid.tsx` | 메뉴 그리드 |
| `KpiBar.tsx` | KPI 요약 바 |
| `NoticeBanner.tsx` | 공지 배너 |
| `NoticeList.tsx` | 공지 목록 |
| `ActivityList.tsx` | 최근 활동 목록 |
--- ---
### `frontend/components/pop/dashboard/DashboardFooter.tsx` ## 9. Library 파일
| 항목 | 내용 |
|------|------|
| 역할 | 하단 푸터 |
---
### `frontend/components/pop/dashboard/MenuGrid.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | 메뉴 그리드 |
| 스타일 | 앱 아이콘 형태 |
---
### `frontend/components/pop/dashboard/KpiBar.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | KPI 요약 바 |
| 표시 | 핵심 지표 수치 |
---
### `frontend/components/pop/dashboard/NoticeBanner.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | 공지 배너 |
| 스타일 | 슬라이드 배너 |
---
### `frontend/components/pop/dashboard/NoticeList.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | 공지 목록 |
| 스타일 | 리스트 형태 |
---
### `frontend/components/pop/dashboard/ActivityList.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | 최근 활동 목록 |
---
### `frontend/components/pop/dashboard/index.ts`
```typescript
export { PopDashboard } from "./PopDashboard";
export { DashboardHeader } from "./DashboardHeader";
export { DashboardFooter } from "./DashboardFooter";
export { MenuGrid } from "./MenuGrid";
export { KpiBar } from "./KpiBar";
export { NoticeBanner } from "./NoticeBanner";
export { NoticeList } from "./NoticeList";
export { ActivityList } from "./ActivityList";
```
---
### `frontend/components/pop/dashboard/types.ts`
대시보드 관련 타입 정의
---
### `frontend/components/pop/dashboard/data.ts`
대시보드 샘플/목업 데이터
---
### `frontend/components/pop/dashboard/dashboard.css`
대시보드 전용 스타일
---
## 8. Library 파일
### `frontend/lib/api/popScreenGroup.ts` ### `frontend/lib/api/popScreenGroup.ts`
| 항목 | 내용 | | 항목 | 내용 |
|------|------| |------|------|
| 역할 | POP 화면 그룹 API 클라이언트 | | 역할 | POP 화면 그룹 API 클라이언트 |
| 라인 수 | 183줄 |
**타입**:
```typescript
interface PopScreenGroup extends ScreenGroup {
children?: PopScreenGroup[];
}
interface CreatePopScreenGroupRequest {
group_name: string;
group_code: string;
description?: string;
icon?: string;
display_order?: number;
parent_group_id?: number | null;
target_company_code?: string;
}
interface UpdatePopScreenGroupRequest {
group_name?: string;
description?: string;
icon?: string;
display_order?: number;
is_active?: boolean;
}
```
**API 함수**: **API 함수**:
@ -757,12 +491,6 @@ async function deletePopScreenGroup(id: number): Promise<...>
async function ensurePopRootGroup(): Promise<...> async function ensurePopRootGroup(): Promise<...>
``` ```
**유틸리티**:
```typescript
function buildPopGroupTree(groups: PopScreenGroup[]): PopScreenGroup[]
```
--- ---
### `frontend/lib/registry/PopComponentRegistry.ts` ### `frontend/lib/registry/PopComponentRegistry.ts`
@ -770,56 +498,6 @@ function buildPopGroupTree(groups: PopScreenGroup[]): PopScreenGroup[]
| 항목 | 내용 | | 항목 | 내용 |
|------|------| |------|------|
| 역할 | POP 컴포넌트 중앙 레지스트리 | | 역할 | POP 컴포넌트 중앙 레지스트리 |
| 라인 수 | 268줄 |
**타입**:
```typescript
interface PopComponentDefinition {
id: string;
name: string;
description: string;
category: PopComponentCategory;
icon?: string;
component: React.ComponentType<any>;
configPanel?: React.ComponentType<any>;
defaultProps?: Record<string, any>;
touchOptimized?: boolean;
minTouchArea?: number;
supportedDevices?: ("mobile" | "tablet")[];
createdAt?: Date;
updatedAt?: Date;
}
type PopComponentCategory =
| "display"
| "input"
| "action"
| "layout"
| "feedback";
```
**메서드**:
```typescript
class PopComponentRegistry {
static registerComponent(definition: PopComponentDefinition): void
static unregisterComponent(id: string): void
static getComponent(id: string): PopComponentDefinition | undefined
static getComponentByUrl(url: string): PopComponentDefinition | undefined
static getAllComponents(): PopComponentDefinition[]
static getComponentsByCategory(category: PopComponentCategory): PopComponentDefinition[]
static getComponentsByDevice(device: "mobile" | "tablet"): PopComponentDefinition[]
static searchComponents(query: string): PopComponentDefinition[]
static getComponentCount(): number
static getStatsByCategory(): Record<PopComponentCategory, number>
static addEventListener(callback: (event: PopComponentRegistryEvent) => void): void
static removeEventListener(callback: (event: PopComponentRegistryEvent) => void): void
static clear(): void
static hasComponent(id: string): boolean
static debug(): void
}
```
--- ---
@ -828,39 +506,11 @@ class PopComponentRegistry {
| 항목 | 내용 | | 항목 | 내용 |
|------|------| |------|------|
| 역할 | POP 컴포넌트 설정 스키마 | | 역할 | POP 컴포넌트 설정 스키마 |
| 라인 수 | 232줄 |
| 검증 | Zod 기반 | | 검증 | Zod 기반 |
**기본값**:
```typescript
const popCardListDefaults = { ... }
const popTouchButtonDefaults = { ... }
const popScannerInputDefaults = { ... }
const popStatusBadgeDefaults = { ... }
```
**스키마**:
```typescript
const popCardListOverridesSchema = z.object({ ... })
const popTouchButtonOverridesSchema = z.object({ ... })
const popScannerInputOverridesSchema = z.object({ ... })
const popStatusBadgeOverridesSchema = z.object({ ... })
```
**유틸리티**:
```typescript
function getPopComponentUrl(componentType: string): string
function getPopComponentDefaults(componentType: string): Record<string, any>
function getPopDefaultsByUrl(componentUrl: string): Record<string, any>
function parsePopOverridesByUrl(componentUrl: string, overrides: Record<string, any>): Record<string, any>
```
--- ---
## 9. 루트 컴포넌트 파일 ## 10. 루트 컴포넌트 파일
### `frontend/components/pop/index.ts` ### `frontend/components/pop/index.ts`
@ -873,24 +523,6 @@ export * from "./dashboard";
--- ---
### `frontend/components/pop/types.ts`
POP 공통 타입 정의
---
### `frontend/components/pop/data.ts`
POP 샘플/목업 데이터
---
### `frontend/components/pop/styles.css`
POP 전역 스타일
---
### 기타 루트 레벨 컴포넌트 ### 기타 루트 레벨 컴포넌트
| 파일 | 역할 | | 파일 | 역할 |
@ -912,14 +544,28 @@ POP 전역 스타일
| 폴더 | 파일 수 | 설명 | | 폴더 | 파일 수 | 설명 |
|------|---------|------| |------|---------|------|
| `app/(pop)` | 6 | App Router 페이지 | | `app/(pop)` | 4 | App Router 페이지 |
| `components/pop/designer` | 12 | 디자이너 모듈 | | `components/pop/designer` | 9 | 디자이너 모듈 (v5) |
| `components/pop/management` | 5 | 관리 모듈 | | `components/pop/management` | 5 | 관리 모듈 |
| `components/pop/dashboard` | 12 | 대시보드 모듈 | | `components/pop/dashboard` | 12 | 대시보드 모듈 |
| `components/pop` (루트) | 15 | 루트 컴포넌트 | | `components/pop` (루트) | 15 | 루트 컴포넌트 |
| `lib` | 3 | 라이브러리 | | `lib` | 3 | 라이브러리 |
| **총계** | **53** | | | **총계** | **48** | |
--- ---
*이 문서는 POP 화면 시스템의 파일 목록을 관리하기 위한 참조용으로 작성되었습니다.* ## 삭제된 파일 (v5 통합으로 제거)
| 파일 | 이유 |
|------|------|
| `PopCanvasV4.tsx` | v4 Flexbox 캔버스 |
| `PopFlexRenderer.tsx` | v4 Flexbox 렌더러 |
| `PopLayoutRenderer.tsx` | v3 CSS Grid 렌더러 |
| `ComponentRenderer.tsx` | 레거시 컴포넌트 렌더러 |
| `ComponentEditorPanelV4.tsx` | v4 편집 패널 |
| `PopPanel.tsx` | 레거시 팔레트 패널 |
| `test-v4/page.tsx` | v4 테스트 페이지 |
---
*이 문서는 POP 화면 시스템의 파일 목록을 관리하기 위한 참조용으로 작성되었습니다. (v5 그리드 시스템 기준)*

763
popdocs/GRID_CODING_PLAN.md Normal file
View File

@ -0,0 +1,763 @@
# POP 그리드 시스템 코딩 계획
> 작성일: 2026-02-05
> 상태: 코딩 준비 완료
---
## 작업 목록
```
Phase 5.1: 타입 정의 ─────────────────────────────
[ ] 1. v5 타입 정의 (PopLayoutDataV5, PopGridConfig 등)
[ ] 2. 브레이크포인트 상수 정의
[ ] 3. v5 생성/변환 함수
Phase 5.2: 그리드 렌더러 ─────────────────────────
[ ] 4. PopGridRenderer.tsx 생성
[ ] 5. 위치 변환 로직 (12칸→4칸)
Phase 5.3: 디자이너 UI ───────────────────────────
[ ] 6. PopCanvasV5.tsx 생성
[ ] 7. 드래그 스냅 기능
[ ] 8. ComponentEditorPanelV5.tsx
Phase 5.4: 통합 ──────────────────────────────────
[ ] 9. 자동 변환 알고리즘
[ ] 10. PopDesigner.tsx 통합
```
---
## Phase 5.1: 타입 정의
### 작업 1: v5 타입 정의
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
**추가할 코드**:
```typescript
// ========================================
// v5.0 그리드 기반 레이아웃
// ========================================
// 핵심: CSS Grid로 정확한 위치 지정
// - 열/행 좌표로 배치 (col, row)
// - 칸 단위 크기 (colSpan, rowSpan)
/**
* v5 레이아웃 (그리드 기반)
*/
export interface PopLayoutDataV5 {
version: "pop-5.0";
// 그리드 설정
gridConfig: PopGridConfig;
// 컴포넌트 정의 (ID → 정의)
components: Record<string, PopComponentDefinitionV5>;
// 데이터 흐름 (기존과 동일)
dataFlow: PopDataFlow;
// 전역 설정
settings: PopGlobalSettingsV5;
// 메타데이터
metadata?: PopLayoutMetadata;
// 모드별 오버라이드 (위치 변경용)
overrides?: {
mobile_portrait?: PopModeOverrideV5;
mobile_landscape?: PopModeOverrideV5;
tablet_portrait?: PopModeOverrideV5;
};
}
/**
* 그리드 설정
*/
export interface PopGridConfig {
// 행 높이 (px) - 1행의 기본 높이
rowHeight: number; // 기본 48px
// 간격 (px)
gap: number; // 기본 8px
// 패딩 (px)
padding: number; // 기본 16px
}
/**
* v5 컴포넌트 정의
*/
export interface PopComponentDefinitionV5 {
id: string;
type: PopComponentType;
label?: string;
// 위치 (열/행 좌표) - 기본 모드(태블릿 가로 12칸) 기준
position: PopGridPosition;
// 모드별 표시/숨김
visibility?: {
tablet_landscape?: boolean;
tablet_portrait?: boolean;
mobile_landscape?: boolean;
mobile_portrait?: boolean;
};
// 기존 속성
dataBinding?: PopDataBinding;
style?: PopStylePreset;
config?: PopComponentConfig;
}
/**
* 그리드 위치
*/
export interface PopGridPosition {
col: number; // 시작 열 (1부터, 최대 12)
row: number; // 시작 행 (1부터)
colSpan: number; // 차지할 열 수 (1~12)
rowSpan: number; // 차지할 행 수 (1~)
}
/**
* v5 전역 설정
*/
export interface PopGlobalSettingsV5 {
// 터치 최소 크기 (px)
touchTargetMin: number; // 기본 48
// 모드
mode: "normal" | "industrial";
}
/**
* v5 모드별 오버라이드
*/
export interface PopModeOverrideV5 {
// 컴포넌트별 위치 오버라이드
positions?: Record<string, Partial<PopGridPosition>>;
// 컴포넌트별 숨김
hidden?: string[];
}
```
### 작업 2: 브레이크포인트 상수
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
```typescript
// ========================================
// 그리드 브레이크포인트
// ========================================
export type GridMode =
| "mobile_portrait"
| "mobile_landscape"
| "tablet_portrait"
| "tablet_landscape";
export const GRID_BREAKPOINTS: Record<GridMode, {
minWidth?: number;
maxWidth?: number;
columns: number;
rowHeight: number;
gap: number;
padding: number;
label: string;
}> = {
// 4~6인치 모바일 세로
mobile_portrait: {
maxWidth: 599,
columns: 4,
rowHeight: 40,
gap: 8,
padding: 12,
label: "모바일 세로 (4칸)",
},
// 6~8인치 모바일 가로 / 작은 태블릿
mobile_landscape: {
minWidth: 600,
maxWidth: 839,
columns: 6,
rowHeight: 44,
gap: 8,
padding: 16,
label: "모바일 가로 (6칸)",
},
// 8~10인치 태블릿 세로
tablet_portrait: {
minWidth: 840,
maxWidth: 1023,
columns: 8,
rowHeight: 48,
gap: 12,
padding: 16,
label: "태블릿 세로 (8칸)",
},
// 10~14인치 태블릿 가로 (기본)
tablet_landscape: {
minWidth: 1024,
columns: 12,
rowHeight: 48,
gap: 16,
padding: 24,
label: "태블릿 가로 (12칸)",
},
};
// 기본 모드
export const DEFAULT_GRID_MODE: GridMode = "tablet_landscape";
// 뷰포트 너비로 모드 감지
export function detectGridMode(viewportWidth: number): GridMode {
if (viewportWidth < 600) return "mobile_portrait";
if (viewportWidth < 840) return "mobile_landscape";
if (viewportWidth < 1024) return "tablet_portrait";
return "tablet_landscape";
}
```
### 작업 3: v5 생성/변환 함수
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
```typescript
// ========================================
// v5 유틸리티 함수
// ========================================
/**
* 빈 v5 레이아웃 생성
*/
export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({
version: "pop-5.0",
gridConfig: {
rowHeight: 48,
gap: 8,
padding: 16,
},
components: {},
dataFlow: { connections: [] },
settings: {
touchTargetMin: 48,
mode: "normal",
},
});
/**
* v5 레이아웃 여부 확인
*/
export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => {
return layout?.version === "pop-5.0";
};
/**
* v5 컴포넌트 정의 생성
*/
export const createComponentDefinitionV5 = (
id: string,
type: PopComponentType,
position: PopGridPosition,
label?: string
): PopComponentDefinitionV5 => ({
id,
type,
label,
position,
});
/**
* 컴포넌트 타입별 기본 크기 (칸 단위)
*/
export const DEFAULT_COMPONENT_SIZE: Record<PopComponentType, { colSpan: number; rowSpan: number }> = {
"pop-field": { colSpan: 3, rowSpan: 1 },
"pop-button": { colSpan: 2, rowSpan: 1 },
"pop-list": { colSpan: 12, rowSpan: 4 },
"pop-indicator": { colSpan: 3, rowSpan: 2 },
"pop-scanner": { colSpan: 6, rowSpan: 2 },
"pop-numpad": { colSpan: 4, rowSpan: 5 },
"pop-spacer": { colSpan: 1, rowSpan: 1 },
"pop-break": { colSpan: 12, rowSpan: 0 },
};
/**
* v4 → v5 마이그레이션
*/
export const migrateV4ToV5 = (layoutV4: PopLayoutDataV4): PopLayoutDataV5 => {
const componentsV4 = Object.values(layoutV4.components);
const componentsV5: Record<string, PopComponentDefinitionV5> = {};
// Flexbox 순서 → Grid 위치 변환
let currentRow = 1;
let currentCol = 1;
const columns = 12;
componentsV4.forEach((comp) => {
// 픽셀 → 칸 변환 (대략적)
const colSpan = comp.size.width === "fill"
? columns
: Math.max(1, Math.min(12, Math.round((comp.size.fixedWidth || 100) / 85)));
const rowSpan = Math.max(1, Math.round((comp.size.fixedHeight || 48) / 48));
// 줄바꿈 체크
if (currentCol + colSpan - 1 > columns) {
currentRow += 1;
currentCol = 1;
}
componentsV5[comp.id] = {
id: comp.id,
type: comp.type,
label: comp.label,
position: {
col: currentCol,
row: currentRow,
colSpan,
rowSpan,
},
visibility: comp.visibility,
dataBinding: comp.dataBinding,
config: comp.config,
};
currentCol += colSpan;
});
return {
version: "pop-5.0",
gridConfig: {
rowHeight: 48,
gap: layoutV4.settings.defaultGap,
padding: layoutV4.settings.defaultPadding,
},
components: componentsV5,
dataFlow: layoutV4.dataFlow,
settings: {
touchTargetMin: layoutV4.settings.touchTargetMin,
mode: layoutV4.settings.mode,
},
};
};
```
---
## Phase 5.2: 그리드 렌더러
### 작업 4: PopGridRenderer.tsx
**파일**: `frontend/components/pop/designer/renderers/PopGridRenderer.tsx`
```typescript
"use client";
import React, { useMemo } from "react";
import { cn } from "@/lib/utils";
import {
PopLayoutDataV5,
PopComponentDefinitionV5,
PopGridPosition,
GridMode,
GRID_BREAKPOINTS,
detectGridMode,
} from "../types/pop-layout";
interface PopGridRendererProps {
layout: PopLayoutDataV5;
viewportWidth: number;
currentMode?: GridMode;
isDesignMode?: boolean;
selectedComponentId?: string | null;
onComponentClick?: (componentId: string) => void;
onBackgroundClick?: () => void;
className?: string;
}
export function PopGridRenderer({
layout,
viewportWidth,
currentMode,
isDesignMode = false,
selectedComponentId,
onComponentClick,
onBackgroundClick,
className,
}: PopGridRendererProps) {
const { gridConfig, components, overrides } = layout;
// 현재 모드 (자동 감지 또는 지정)
const mode = currentMode || detectGridMode(viewportWidth);
const breakpoint = GRID_BREAKPOINTS[mode];
// CSS Grid 스타일
const gridStyle = useMemo((): React.CSSProperties => ({
display: "grid",
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
gridAutoRows: `${breakpoint.rowHeight}px`,
gap: `${breakpoint.gap}px`,
padding: `${breakpoint.padding}px`,
minHeight: "100%",
}), [breakpoint]);
// visibility 체크
const isVisible = (comp: PopComponentDefinitionV5): boolean => {
if (!comp.visibility) return true;
return comp.visibility[mode] !== false;
};
// 위치 변환 (12칸 기준 → 현재 모드 칸 수)
const convertPosition = (position: PopGridPosition): React.CSSProperties => {
const sourceColumns = 12; // 항상 12칸 기준으로 저장
const targetColumns = breakpoint.columns;
if (sourceColumns === targetColumns) {
return {
gridColumn: `${position.col} / span ${position.colSpan}`,
gridRow: `${position.row} / span ${position.rowSpan}`,
};
}
// 비율 계산
const ratio = targetColumns / sourceColumns;
let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1);
let newColSpan = Math.max(1, Math.round(position.colSpan * ratio));
// 범위 초과 방지
if (newCol + newColSpan - 1 > targetColumns) {
newColSpan = targetColumns - newCol + 1;
}
return {
gridColumn: `${newCol} / span ${Math.max(1, newColSpan)}`,
gridRow: `${position.row} / span ${position.rowSpan}`,
};
};
// 오버라이드 적용
const getEffectivePosition = (comp: PopComponentDefinitionV5): PopGridPosition => {
const override = overrides?.[mode]?.positions?.[comp.id];
if (override) {
return { ...comp.position, ...override };
}
return comp.position;
};
return (
<div
className={cn("relative min-h-full w-full bg-white", className)}
style={gridStyle}
onClick={(e) => {
if (e.target === e.currentTarget) {
onBackgroundClick?.();
}
}}
>
{Object.values(components).map((comp) => {
if (!isVisible(comp)) return null;
const position = getEffectivePosition(comp);
const positionStyle = convertPosition(position);
return (
<div
key={comp.id}
className={cn(
"relative rounded-lg border-2 bg-white transition-all overflow-hidden",
selectedComponentId === comp.id
? "border-primary ring-2 ring-primary/30 z-10"
: "border-gray-200",
isDesignMode && "cursor-pointer hover:border-gray-300"
)}
style={positionStyle}
onClick={(e) => {
e.stopPropagation();
onComponentClick?.(comp.id);
}}
>
{/* 컴포넌트 내용 */}
<ComponentContent component={comp} isDesignMode={isDesignMode} />
</div>
);
})}
</div>
);
}
// 컴포넌트 내용 렌더링
function ComponentContent({
component,
isDesignMode
}: {
component: PopComponentDefinitionV5;
isDesignMode: boolean;
}) {
const typeLabels: Record<string, string> = {
"pop-field": "필드",
"pop-button": "버튼",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
"pop-numpad": "숫자패드",
"pop-spacer": "스페이서",
"pop-break": "줄바꿈",
};
if (isDesignMode) {
return (
<div className="flex h-full w-full flex-col">
<div className="flex h-5 shrink-0 items-center border-b bg-gray-50 px-2">
<span className="text-[10px] font-medium text-gray-600">
{component.label || typeLabels[component.type] || component.type}
</span>
</div>
<div className="flex flex-1 items-center justify-center p-2">
<span className="text-xs text-gray-400">
{typeLabels[component.type]}
</span>
</div>
</div>
);
}
// 실제 컴포넌트 렌더링 (Phase 4에서 구현)
return (
<div className="flex h-full w-full items-center justify-center p-2">
<span className="text-xs text-gray-500">
{component.label || typeLabels[component.type]}
</span>
</div>
);
}
export default PopGridRenderer;
```
### 작업 5: 위치 변환 유틸리티
**파일**: `frontend/components/pop/designer/utils/gridUtils.ts`
```typescript
import { PopGridPosition, GridMode, GRID_BREAKPOINTS } from "../types/pop-layout";
/**
* 12칸 기준 위치를 다른 모드로 변환
*/
export function convertPositionToMode(
position: PopGridPosition,
targetMode: GridMode
): PopGridPosition {
const sourceColumns = 12;
const targetColumns = GRID_BREAKPOINTS[targetMode].columns;
if (sourceColumns === targetColumns) {
return position;
}
const ratio = targetColumns / sourceColumns;
// 열 위치 변환
let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1);
let newColSpan = Math.max(1, Math.round(position.colSpan * ratio));
// 범위 초과 방지
if (newCol > targetColumns) {
newCol = 1;
}
if (newCol + newColSpan - 1 > targetColumns) {
newColSpan = targetColumns - newCol + 1;
}
return {
col: newCol,
row: position.row,
colSpan: Math.max(1, newColSpan),
rowSpan: position.rowSpan,
};
}
/**
* 두 위치가 겹치는지 확인
*/
export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean {
// 열 겹침
const colOverlap = !(a.col + a.colSpan - 1 < b.col || b.col + b.colSpan - 1 < a.col);
// 행 겹침
const rowOverlap = !(a.row + a.rowSpan - 1 < b.row || b.row + b.rowSpan - 1 < a.row);
return colOverlap && rowOverlap;
}
/**
* 겹침 해결 (아래로 밀기)
*/
export function resolveOverlaps(
positions: Array<{ id: string; position: PopGridPosition }>,
columns: number
): Array<{ id: string; position: PopGridPosition }> {
// row, col 순으로 정렬
const sorted = [...positions].sort((a, b) =>
a.position.row - b.position.row || a.position.col - b.position.col
);
const resolved: Array<{ id: string; position: PopGridPosition }> = [];
sorted.forEach((item) => {
let { row, col, colSpan, rowSpan } = item.position;
// 기존 배치와 겹치면 아래로 이동
let attempts = 0;
while (attempts < 100) {
const currentPos: PopGridPosition = { col, row, colSpan, rowSpan };
const hasOverlap = resolved.some(r => isOverlapping(currentPos, r.position));
if (!hasOverlap) break;
row++;
attempts++;
}
resolved.push({
id: item.id,
position: { col, row, colSpan, rowSpan },
});
});
return resolved;
}
/**
* 마우스 좌표 → 그리드 좌표 변환
*/
export function mouseToGridPosition(
mouseX: number,
mouseY: number,
canvasRect: DOMRect,
columns: number,
rowHeight: number,
gap: number,
padding: number
): { col: number; row: number } {
// 캔버스 내 상대 위치
const relX = mouseX - canvasRect.left - padding;
const relY = mouseY - canvasRect.top - padding;
// 칸 너비 계산
const totalGap = gap * (columns - 1);
const colWidth = (canvasRect.width - padding * 2 - totalGap) / columns;
// 그리드 좌표 계산 (1부터 시작)
const col = Math.max(1, Math.min(columns, Math.floor(relX / (colWidth + gap)) + 1));
const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1);
return { col, row };
}
/**
* 그리드 좌표 → 픽셀 좌표 변환
*/
export function gridToPixelPosition(
col: number,
row: number,
canvasWidth: number,
columns: number,
rowHeight: number,
gap: number,
padding: number
): { x: number; y: number; width: number; height: number } {
const totalGap = gap * (columns - 1);
const colWidth = (canvasWidth - padding * 2 - totalGap) / columns;
return {
x: padding + (col - 1) * (colWidth + gap),
y: padding + (row - 1) * (rowHeight + gap),
width: colWidth,
height: rowHeight,
};
}
```
---
## Phase 5.3: 디자이너 UI
### 작업 6-7: PopCanvasV5.tsx
**파일**: `frontend/components/pop/designer/PopCanvasV5.tsx`
핵심 기능:
- 그리드 배경 표시 (바둑판)
- 4개 모드 프리셋 버튼
- 드래그 앤 드롭 (칸에 스냅)
- 컴포넌트 리사이즈 (칸 단위)
### 작업 8: ComponentEditorPanelV5.tsx
**파일**: `frontend/components/pop/designer/panels/ComponentEditorPanelV5.tsx`
핵심 기능:
- 위치 편집 (col, row 입력)
- 크기 편집 (colSpan, rowSpan 입력)
- visibility 체크박스
---
## Phase 5.4: 통합
### 작업 9: 자동 변환 알고리즘
이미 `gridUtils.ts`에 포함
### 작업 10: PopDesigner.tsx 통합
**수정 파일**: `frontend/components/pop/designer/PopDesigner.tsx`
변경 사항:
- v5 레이아웃 상태 추가
- v3/v4/v5 자동 판별
- 새 화면 → v5로 시작
- v4 → v5 마이그레이션 옵션
---
## 파일 목록
| 상태 | 파일 | 작업 |
|------|------|------|
| 수정 | `types/pop-layout.ts` | v5 타입, 상수, 함수 추가 |
| 생성 | `renderers/PopGridRenderer.tsx` | 그리드 렌더러 |
| 생성 | `utils/gridUtils.ts` | 유틸리티 함수 |
| 생성 | `PopCanvasV5.tsx` | 그리드 캔버스 |
| 생성 | `panels/ComponentEditorPanelV5.tsx` | 속성 패널 |
| 수정 | `PopDesigner.tsx` | v5 통합 |
---
## 시작 순서
```
1. pop-layout.ts에 v5 타입 추가 (작업 1-3)
2. PopGridRenderer.tsx 생성 (작업 4)
3. gridUtils.ts 생성 (작업 5)
4. PopCanvasV5.tsx 생성 (작업 6-7)
5. ComponentEditorPanelV5.tsx 생성 (작업 8)
6. PopDesigner.tsx 수정 (작업 9-10)
7. 테스트
```
---
*다음 단계: Phase 5.1 작업 1 시작 (v5 타입 정의)*

View File

@ -0,0 +1,329 @@
# POP 화면 그리드 시스템 설계
> 작성일: 2026-02-05
> 상태: 계획 (Plan)
> 관련: Softr, Ant Design, Material Design 분석 기반
---
## 1. 목적
POP 화면의 반응형 레이아웃을 **일관성 있고 예측 가능하게** 만들기 위한 그리드 시스템 설계
### 현재 문제
- 픽셀 단위 자유 배치 → 화면 크기별로 깨짐
- 컴포넌트 크기 규칙 없음 → 디자인 불일치
- 반응형 규칙 부족 → 모드별 수동 조정 필요
### 목표
- 그리드 기반 배치로 일관성 확보
- 크기 프리셋으로 디자인 통일
- 자동 반응형으로 작업량 감소
---
## 2. 대상 디바이스
### 지원 범위
| 구분 | 크기 범위 | 기준 해상도 | 비고 |
|------|----------|-------------|------|
| 모바일 | 4~8인치 | 375x667 (세로) | 산업용 PDA 포함 |
| 태블릿 | 8~14인치 | 1024x768 (가로) | 기본 기준 |
### 참고: 산업용 디바이스 해상도
| 디바이스 | 화면 크기 | 해상도 |
|----------|----------|--------|
| Zebra TC57 PDA | 5인치 | 720x1280 |
| Honeywell CT47 | 5.5인치 | 2160x1080 |
| Honeywell RT10A | 10.1인치 | 1920x1200 |
---
## 3. 그리드 시스템 설계
### 3.1 브레이크포인트 (Breakpoints)
Material Design 가이드라인 기반으로 4단계 정의:
| 모드 | 약어 | 너비 범위 | 대표 디바이스 | 그리드 칸 수 |
|------|------|----------|---------------|-------------|
| 모바일 세로 | `mp` | ~599px | 4~6인치 폰 | **4 columns** |
| 모바일 가로 | `ml` | 600~839px | 폰 가로, 7인치 태블릿 | **6 columns** |
| 태블릿 세로 | `tp` | 840~1023px | 8~10인치 태블릿 세로 | **8 columns** |
| 태블릿 가로 | `tl` | 1024px~ | 10~14인치 태블릿 가로 | **12 columns** |
### 3.2 기준 해상도
| 모드 | 기준 너비 | 기준 높이 | 비고 |
|------|----------|----------|------|
| 모바일 세로 | 375px | 667px | iPhone SE 기준 |
| 모바일 가로 | 667px | 375px | - |
| 태블릿 세로 | 768px | 1024px | iPad 기준 |
| **태블릿 가로** | **1024px** | **768px** | **기본 설계 모드** |
### 3.3 그리드 구조
```
태블릿 가로 (12 columns)
┌──────────────────────────────────────────────────────────────┐
│ ← 16px →│ Col │ 16px │ Col │ 16px │ ... │ Col │← 16px →│
│ margin │ 1 │ gap │ 2 │ gap │ │ 12 │ margin │
└──────────────────────────────────────────────────────────────┘
모바일 세로 (4 columns)
┌────────────────────────┐
│← 16px →│ Col │ 8px │ Col │ 8px │ Col │ 8px │ Col │← 16px →│
│ margin │ 1 │ gap │ 2 │ gap │ 3 │ gap │ 4 │ margin │
└────────────────────────┘
```
### 3.4 마진/간격 규칙
8px 기반 간격 시스템 (Material Design 표준):
| 속성 | 태블릿 | 모바일 | 용도 |
|------|--------|--------|------|
| screenPadding | 24px | 16px | 화면 가장자리 여백 |
| gapSm | 8px | 8px | 컴포넌트 사이 최소 간격 |
| gapMd | 16px | 12px | 기본 간격 |
| gapLg | 24px | 16px | 섹션 간 간격 |
| rowGap | 16px | 12px | 줄 사이 간격 |
---
## 4. 컴포넌트 크기 시스템
### 4.1 열 단위 (Span) 크기
픽셀 대신 **열 단위(span)** 로 크기 지정:
| 크기 | 태블릿 가로 (12col) | 태블릿 세로 (8col) | 모바일 (4col) |
|------|--------------------|--------------------|---------------|
| XS | 1 span | 1 span | 1 span |
| S | 2 span | 2 span | 2 span |
| M | 3 span | 2 span | 2 span |
| L | 4 span | 4 span | 4 span (full) |
| XL | 6 span | 4 span | 4 span (full) |
| Full | 12 span | 8 span | 4 span |
### 4.2 높이 프리셋
| 프리셋 | 픽셀값 | 용도 |
|--------|--------|------|
| `xs` | 32px | 배지, 아이콘 버튼 |
| `sm` | 48px | 일반 버튼, 입력 필드 |
| `md` | 80px | 카드, 인디케이터 |
| `lg` | 120px | 큰 카드, 리스트 아이템 |
| `xl` | 200px | 대형 영역 |
| `auto` | 내용 기반 | 가변 높이 |
### 4.3 컴포넌트별 기본값
| 컴포넌트 | 태블릿 span | 모바일 span | 높이 | 비고 |
|----------|------------|-------------|------|------|
| pop-field | 3 (M) | 2 (S) | sm | 입력/표시 |
| pop-button | 2 (S) | 2 (S) | sm | 액션 버튼 |
| pop-list | 12 (Full) | 4 (Full) | auto | 데이터 목록 |
| pop-indicator | 3 (M) | 2 (S) | md | KPI 표시 |
| pop-scanner | 6 (XL) | 4 (Full) | lg | 스캔 영역 |
| pop-numpad | 6 (XL) | 4 (Full) | auto | 숫자 패드 |
| pop-spacer | 1 (XS) | 1 (XS) | - | 빈 공간 |
| pop-break | Full | Full | 0 | 줄바꿈 |
---
## 5. 반응형 규칙
### 5.1 자동 조정
설계자가 별도 설정하지 않아도 자동 적용:
```
태블릿 가로 (12col): [A:3] [B:3] [C:3] [D:3] → 한 줄
태블릿 세로 (8col): [A:2] [B:2] [C:2] [D:2] → 한 줄
모바일 (4col): [A:2] [B:2] → 두 줄
[C:2] [D:2]
```
### 5.2 수동 오버라이드
필요시 모드별 설정 가능:
```typescript
interface ResponsiveOverride {
// 크기 변경
span?: number;
height?: HeightPreset;
// 표시/숨김
hidden?: boolean;
// 내부 요소 숨김 (컴포넌트별)
hideElements?: string[];
}
```
### 5.3 표시/숨김 예시
```
태블릿: [제품명] [수량] [단가] [합계] [비고]
모바일: [제품명] [수량] [비고] ← 단가, 합계 숨김
```
설정:
```typescript
{
id: "unit-price",
type: "pop-field",
visibility: {
mobile_portrait: false,
mobile_landscape: false
}
}
```
---
## 6. 데이터 구조 (제안)
### 6.1 레이아웃 데이터 (v5 제안)
```typescript
interface PopLayoutDataV5 {
version: "5.0";
// 그리드 설정 (전역)
gridConfig: {
tablet: { columns: 12; gap: 16; padding: 24 };
mobile: { columns: 4; gap: 8; padding: 16 };
};
// 컴포넌트 목록 (순서대로)
components: PopComponentV5[];
// 모드별 오버라이드 (선택)
modeOverrides?: {
[mode: string]: {
gridConfig?: Partial<GridConfig>;
componentOverrides?: Record<string, ResponsiveOverride>;
};
};
}
```
### 6.2 컴포넌트 데이터
```typescript
interface PopComponentV5 {
id: string;
type: PopComponentType;
// 크기 (span 단위)
size: {
span: number; // 기본 열 개수 (1~12)
height: HeightPreset; // xs, sm, md, lg, xl, auto
};
// 반응형 크기 (선택)
responsiveSize?: {
mobile?: { span?: number; height?: HeightPreset };
tablet_portrait?: { span?: number; height?: HeightPreset };
};
// 표시/숨김
visibility?: {
[mode: string]: boolean;
};
// 컴포넌트별 설정
config?: any;
// 데이터 바인딩
dataBinding?: any;
}
```
---
## 7. 현재 v4와의 관계
### 7.1 v4 유지 사항
- Flexbox 기반 렌더링
- 오버라이드 시스템
- visibility 속성
### 7.2 변경 사항
| v4 | v5 (제안) |
|----|-----------|
| `fixedWidth: number` | `span: 1~12` |
| `minWidth`, `maxWidth` | 그리드 기반 자동 계산 |
| 자유 픽셀 | 열 단위 프리셋 |
### 7.3 마이그레이션 방향
```
v4 fixedWidth: 200px
v5 span: 3 (태블릿 기준 약 25%)
```
---
## 8. 구현 우선순위
### Phase 1: 프리셋만 적용 (최소 변경)
- [ ] 높이 프리셋 드롭다운
- [ ] 너비 프리셋 드롭다운 (XS~Full)
- [ ] 기존 Flexbox 렌더링 유지
### Phase 2: 그리드 시스템 도입
- [ ] 브레이크포인트 감지
- [ ] 그리드 칸 수 자동 변경
- [ ] span → 픽셀 자동 계산
### Phase 3: 반응형 자동화
- [ ] 모드별 자동 span 변환
- [ ] 줄바꿈 자동 처리
- [ ] 오버라이드 최소화
---
## 9. 참고 자료
### 분석 대상
| 도구 | 핵심 특징 | 적용 가능 요소 |
|------|----------|---------------|
| **Softr** | 블록 기반, 제약 기반 레이아웃 | 컨테이너 슬롯 방식 |
| **Ant Design** | 24열 그리드, 8px 간격 | 그리드 시스템, 간격 규칙 |
| **Material Design** | 4/8/12열, 반응형 브레이크포인트 | 디바이스별 칸 수 |
### 핵심 원칙
1. **Flexbox는 도구**: 그리드 시스템 안에서 사용
2. **제약은 자유**: 규칙이 있어야 일관된 디자인 가능
3. **최소 설정, 최대 효과**: 기본값이 좋으면 오버라이드 불필요
---
## 10. FAQ
### Q1: 기존 v4 화면은 어떻게 되나요?
A: 하위 호환 유지. v4 화면은 v4로 계속 동작.
### Q2: 컴포넌트를 그리드 칸 사이에 배치할 수 있나요?
A: 아니요. 칸 단위로만 배치. 이게 일관성의 핵심.
### Q3: 그리드 칸 수를 바꿀 수 있나요?
A: 기본값(4/6/8/12) 권장. 필요시 프로젝트 레벨 설정 가능.
### Q4: Flexbox와 Grid 중 뭘 쓰나요?
A: 둘 다. Grid로 칸 나누고, Flexbox로 칸 안에서 정렬.
---
*이 문서는 계획 단계이며, 실제 구현 시 수정될 수 있습니다.*
*최종 업데이트: 2026-02-05*

480
popdocs/GRID_SYSTEM_PLAN.md Normal file
View File

@ -0,0 +1,480 @@
# POP 그리드 시스템 도입 계획
> 작성일: 2026-02-05
> 상태: 계획 승인, 구현 대기
---
## 개요
### 목표
현재 Flexbox 기반 v4 시스템을 **CSS Grid 기반 v5 시스템**으로 전환하여
4~14인치 화면에서 일관된 배치와 예측 가능한 반응형 레이아웃 구현
### 핵심 변경점
| 항목 | v4 (현재) | v5 (그리드) |
|------|----------|-------------|
| 배치 방식 | Flexbox 흐름 | **Grid 위치 지정** |
| 크기 단위 | 픽셀 (200px) | **칸 (col, row)** |
| 위치 지정 | 순서대로 자동 | **열/행 좌표** |
| 줄바꿈 | 자동 (넘치면) | **명시적 (row 지정)** |
---
## Phase 구조
```
[Phase 5.1] [Phase 5.2] [Phase 5.3] [Phase 5.4]
그리드 타입 정의 → 그리드 렌더러 → 디자이너 UI → 반응형 자동화
1주 1주 1~2주 1주
```
---
## Phase 5.1: 그리드 타입 정의
### 목표
v5 레이아웃 데이터 구조 설계
### 작업 항목
- [ ] `PopLayoutDataV5` 인터페이스 정의
- [ ] `PopGridConfig` 인터페이스 (그리드 설정)
- [ ] `PopComponentPositionV5` 인터페이스 (위치: col, row, colSpan, rowSpan)
- [ ] `PopSizeConstraintV5` 인터페이스 (칸 기반 크기)
- [ ] 브레이크포인트 상수 정의
- [ ] `createEmptyPopLayoutV5()` 생성 함수
- [ ] `isV5Layout()` 타입 가드
### 데이터 구조 설계
```typescript
// v5 레이아웃
interface PopLayoutDataV5 {
version: "pop-5.0";
// 그리드 설정
gridConfig: PopGridConfig;
// 컴포넌트 목록
components: Record<string, PopComponentDefinitionV5>;
// 모드별 오버라이드
overrides?: {
mobile_portrait?: PopModeOverrideV5;
mobile_landscape?: PopModeOverrideV5;
tablet_portrait?: PopModeOverrideV5;
};
// 기존 호환
dataFlow: PopDataFlow;
settings: PopGlobalSettingsV5;
}
// 그리드 설정
interface PopGridConfig {
// 모드별 칸 수
columns: {
tablet_landscape: 12; // 기본 (10~14인치)
tablet_portrait: 8; // 8~10인치 세로
mobile_landscape: 6; // 6~8인치 가로
mobile_portrait: 4; // 4~6인치 세로
};
// 행 높이 (px) - 1행의 기본 높이
rowHeight: number; // 기본 48px
// 간격
gap: number; // 기본 8px
padding: number; // 기본 16px
}
// 컴포넌트 정의
interface PopComponentDefinitionV5 {
id: string;
type: PopComponentType;
label?: string;
// 위치 (열/행 좌표) - 기본 모드(태블릿 가로) 기준
position: {
col: number; // 시작 열 (1부터)
row: number; // 시작 행 (1부터)
colSpan: number; // 차지할 열 수 (1~12)
rowSpan: number; // 차지할 행 수 (1~)
};
// 모드별 표시/숨김
visibility?: {
tablet_landscape?: boolean;
tablet_portrait?: boolean;
mobile_landscape?: boolean;
mobile_portrait?: boolean;
};
// 기존 속성
dataBinding?: PopDataBinding;
config?: PopComponentConfig;
}
```
### 브레이크포인트 정의
```typescript
// 브레이크포인트 상수
const GRID_BREAKPOINTS = {
// 4~6인치 모바일 세로
mobile_portrait: {
maxWidth: 599,
columns: 4,
rowHeight: 40,
gap: 8,
padding: 12,
},
// 6~8인치 모바일 가로 / 작은 태블릿
mobile_landscape: {
minWidth: 600,
maxWidth: 839,
columns: 6,
rowHeight: 44,
gap: 8,
padding: 16,
},
// 8~10인치 태블릿 세로
tablet_portrait: {
minWidth: 840,
maxWidth: 1023,
columns: 8,
rowHeight: 48,
gap: 12,
padding: 16,
},
// 10~14인치 태블릿 가로 (기본)
tablet_landscape: {
minWidth: 1024,
columns: 12,
rowHeight: 48,
gap: 16,
padding: 24,
},
} as const;
```
### 산출물
- `frontend/components/pop/designer/types/pop-layout-v5.ts`
---
## Phase 5.2: 그리드 렌더러
### 목표
CSS Grid 기반 렌더러 구현
### 작업 항목
- [ ] `PopGridRenderer.tsx` 생성
- [ ] CSS Grid 스타일 계산 로직
- [ ] 브레이크포인트 감지 및 칸 수 자동 변경
- [ ] 컴포넌트 위치 렌더링 (grid-column, grid-row)
- [ ] 모드별 자동 위치 재계산 (12칸→4칸 변환)
- [ ] visibility 처리
- [ ] 기존 PopFlexRenderer와 공존
### 렌더링 로직
```typescript
// CSS Grid 스타일 생성
function calculateGridStyle(config: PopGridConfig, mode: string): React.CSSProperties {
const columns = config.columns[mode];
const { rowHeight, gap, padding } = config;
return {
display: 'grid',
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gridAutoRows: `${rowHeight}px`,
gap: `${gap}px`,
padding: `${padding}px`,
};
}
// 컴포넌트 위치 스타일
function calculatePositionStyle(
position: PopComponentPositionV5['position'],
sourceColumns: number, // 원본 모드 칸 수 (12)
targetColumns: number // 현재 모드 칸 수 (4)
): React.CSSProperties {
// 12칸 → 4칸 변환 예시
// col: 7, colSpan: 3 → col: 3, colSpan: 1
const ratio = targetColumns / sourceColumns;
const newCol = Math.max(1, Math.ceil(position.col * ratio));
const newColSpan = Math.max(1, Math.round(position.colSpan * ratio));
return {
gridColumn: `${newCol} / span ${Math.min(newColSpan, targetColumns - newCol + 1)}`,
gridRow: `${position.row} / span ${position.rowSpan}`,
};
}
```
### 산출물
- `frontend/components/pop/designer/renderers/PopGridRenderer.tsx`
---
## Phase 5.3: 디자이너 UI
### 목표
그리드 기반 편집 UI 구현
### 작업 항목
- [ ] `PopCanvasV5.tsx` 생성 (그리드 캔버스)
- [ ] 그리드 배경 표시 (바둑판 모양)
- [ ] 컴포넌트 드래그 배치 (칸에 스냅)
- [ ] 컴포넌트 리사이즈 (칸 단위)
- [ ] 위치 편집 패널 (col, row, colSpan, rowSpan)
- [ ] 모드 전환 시 그리드 칸 수 변경 표시
- [ ] v4/v5 자동 판별 및 전환
### UI 구조
```
┌─────────────────────────────────────────────────────────────────┐
│ ← 목록 화면명 *변경됨 [↶][↷] 그리드 레이아웃 (v5) [저장] │
├─────────────────────────────────────────────────────────────────┤
│ 미리보기: [모바일↕ 4칸] [모바일↔ 6칸] [태블릿↕ 8칸] [태블릿↔ 12칸] │
├────────────┬────────────────────────────────────┬───────────────┤
│ │ 1 2 3 4 5 6 ... 12 │ │
│ 컴포넌트 │ ┌───────────┬───────────┐ │ 위치 │
│ │1│ A │ B │ │ 열: [1-12] │
│ 필드 │ ├───────────┴───────────┤ │ 행: [1-99] │
│ 버튼 │2│ C │ │ 너비: [1-12]│
│ 리스트 │ ├───────────┬───────────┤ │ 높이: [1-10]│
│ 인디케이터 │3│ D │ E │ │ │
│ ... │ └───────────┴───────────┘ │ 표시 설정 │
│ │ │ [x] 태블릿↔ │
│ │ (그리드 배경 표시) │ [x] 모바일↕ │
└────────────┴────────────────────────────────────┴───────────────┘
```
### 드래그 앤 드롭 로직
```typescript
// 마우스 위치 → 그리드 좌표 변환
function mouseToGridPosition(
mouseX: number,
mouseY: number,
gridConfig: PopGridConfig,
canvasRect: DOMRect
): { col: number; row: number } {
const { columns, rowHeight, gap, padding } = gridConfig;
// 캔버스 내 상대 위치
const relX = mouseX - canvasRect.left - padding;
const relY = mouseY - canvasRect.top - padding;
// 칸 너비 계산
const totalGap = gap * (columns - 1);
const colWidth = (canvasRect.width - padding * 2 - totalGap) / columns;
// 그리드 좌표 계산
const col = Math.max(1, Math.min(columns, Math.floor(relX / (colWidth + gap)) + 1));
const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1);
return { col, row };
}
```
### 산출물
- `frontend/components/pop/designer/PopCanvasV5.tsx`
- `frontend/components/pop/designer/panels/ComponentEditorPanelV5.tsx`
---
## Phase 5.4: 반응형 자동화
### 목표
모드 전환 시 자동 레이아웃 조정
### 작업 항목
- [ ] 12칸 → 4칸 자동 변환 알고리즘
- [ ] 겹침 감지 및 자동 재배치
- [ ] 모드별 오버라이드 저장
- [ ] "자동 배치" vs "수동 고정" 선택
- [ ] 변환 미리보기
### 자동 변환 알고리즘
```typescript
// 12칸 → 4칸 변환 전략
function convertLayoutToMode(
components: PopComponentDefinitionV5[],
sourceMode: 'tablet_landscape', // 12칸
targetMode: 'mobile_portrait' // 4칸
): PopComponentDefinitionV5[] {
const sourceColumns = 12;
const targetColumns = 4;
const ratio = targetColumns / sourceColumns; // 0.333
// 1. 각 컴포넌트 위치 변환
const converted = components.map(comp => {
const newCol = Math.max(1, Math.ceil(comp.position.col * ratio));
const newColSpan = Math.max(1, Math.round(comp.position.colSpan * ratio));
return {
...comp,
position: {
...comp.position,
col: newCol,
colSpan: Math.min(newColSpan, targetColumns),
},
};
});
// 2. 겹침 감지 및 해결
return resolveOverlaps(converted, targetColumns);
}
// 겹침 해결 (아래로 밀기)
function resolveOverlaps(
components: PopComponentDefinitionV5[],
columns: number
): PopComponentDefinitionV5[] {
// 행 단위로 그리드 점유 상태 추적
const grid: boolean[][] = [];
// row 순서대로 처리
const sorted = [...components].sort((a, b) =>
a.position.row - b.position.row || a.position.col - b.position.col
);
return sorted.map(comp => {
let { row, col, colSpan, rowSpan } = comp.position;
// 배치 가능한 위치 찾기
while (isOccupied(grid, row, col, colSpan, rowSpan, columns)) {
row++; // 아래로 이동
}
// 그리드에 표시
markOccupied(grid, row, col, colSpan, rowSpan);
return {
...comp,
position: { row, col, colSpan, rowSpan },
};
});
}
```
### 산출물
- `frontend/components/pop/designer/utils/gridLayoutUtils.ts`
---
## 마이그레이션 전략
### v4 → v5 변환
```typescript
function migrateV4ToV5(layoutV4: PopLayoutDataV4): PopLayoutDataV5 {
const componentsList = Object.values(layoutV4.components);
// Flexbox 순서 → Grid 위치 변환
let currentRow = 1;
let currentCol = 1;
const columns = 12;
const componentsV5: Record<string, PopComponentDefinitionV5> = {};
componentsList.forEach((comp, index) => {
// 기본 크기 추정 (픽셀 → 칸)
const colSpan = Math.max(1, Math.round((comp.size.fixedWidth || 100) / 85));
const rowSpan = Math.max(1, Math.round((comp.size.fixedHeight || 48) / 48));
// 줄바꿈 체크
if (currentCol + colSpan - 1 > columns) {
currentRow++;
currentCol = 1;
}
componentsV5[comp.id] = {
...comp,
position: {
col: currentCol,
row: currentRow,
colSpan,
rowSpan,
},
};
currentCol += colSpan;
});
return {
version: "pop-5.0",
gridConfig: { /* 기본값 */ },
components: componentsV5,
dataFlow: layoutV4.dataFlow,
settings: { /* 변환 */ },
};
}
```
### 하위 호환
| 버전 | 처리 방식 |
|------|----------|
| v1~v2 | v3로 변환 후 v5로 |
| v3 | v5로 직접 변환 |
| v4 | v5로 직접 변환 |
| v5 | 그대로 사용 |
---
## 일정 (예상)
| Phase | 작업 | 예상 기간 |
|-------|------|----------|
| 5.1 | 타입 정의 | 2~3일 |
| 5.2 | 그리드 렌더러 | 3~5일 |
| 5.3 | 디자이너 UI | 5~7일 |
| 5.4 | 반응형 자동화 | 3~5일 |
| - | 테스트 및 버그 수정 | 2~3일 |
| **총** | | **약 2~3주** |
---
## 리스크 및 대응
| 리스크 | 영향 | 대응 |
|--------|------|------|
| 기존 v4 화면 깨짐 | 높음 | 하위 호환 유지, v4 렌더러 보존 |
| 자동 변환 품질 | 중간 | 수동 오버라이드로 보완 |
| 드래그 UX 복잡 | 중간 | 스냅 기능으로 편의성 확보 |
| 성능 저하 | 낮음 | CSS Grid는 네이티브 성능 |
---
## 성공 기준
1. **배치 예측 가능**: "2열 3행"이라고 하면 정확히 그 위치에 표시
2. **일관된 디자인**: 12칸 → 4칸 전환 시 비율 유지
3. **쉬운 편집**: 드래그로 칸에 스냅되어 배치
4. **하위 호환**: 기존 v4 화면이 정상 동작
---
## 관련 문서
- [GRID_SYSTEM_DESIGN.md](./GRID_SYSTEM_DESIGN.md) - 그리드 시스템 설계 상세
- [PLAN.md](./PLAN.md) - 전체 POP 개발 계획
- [V4_UNIFIED_DESIGN_SPEC.md](./V4_UNIFIED_DESIGN_SPEC.md) - 현재 v4 스펙
---
*최종 업데이트: 2026-02-05*

83
popdocs/INDEX.md Normal file
View File

@ -0,0 +1,83 @@
# 기능별 색인
> **용도**: "이 기능 어디있어?", "비슷한 기능 찾아줘"
> **검색 팁**: Ctrl+F로 기능명, 키워드 검색
---
## 렌더링
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 그리드 렌더링 | PopRenderer.tsx | `PopRenderer` | CSS Grid 기반 v5 렌더링 |
| 위치 변환 | gridUtils.ts | `convertPositionToMode()` | 12칸 → 4/6/8칸 변환 |
| 모드 감지 | pop-layout.ts | `detectGridMode()` | 뷰포트 너비로 모드 판별 |
| 컴포넌트 스타일 | PopRenderer.tsx | `convertPosition()` | 그리드 좌표 → CSS |
## 드래그 앤 드롭
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 드롭 영역 | PopCanvas.tsx | `useDrop` | 캔버스에 컴포넌트 드롭 |
| 좌표 계산 | gridUtils.ts | `mouseToGridPosition()` | 마우스 → 그리드 좌표 |
| 빈 위치 찾기 | gridUtils.ts | `findNextEmptyPosition()` | 자동 배치 |
| DnD 타입 정의 | PopCanvas.tsx | `DND_ITEM_TYPES` | 인라인 정의 |
## 편집
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 위치 편집 | ComponentEditorPanel.tsx | position 탭 | col, row 수정 |
| 크기 편집 | ComponentEditorPanel.tsx | position 탭 | colSpan, rowSpan 수정 |
| 라벨 편집 | ComponentEditorPanel.tsx | settings 탭 | 컴포넌트 라벨 |
| 표시/숨김 | ComponentEditorPanel.tsx | visibility 탭 | 모드별 표시 |
## 레이아웃 관리
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 컴포넌트 추가 | pop-layout.ts | `addComponentToV5Layout()` | v5에 컴포넌트 추가 |
| 빈 레이아웃 | pop-layout.ts | `createEmptyPopLayoutV5()` | 초기 레이아웃 생성 |
| 타입 가드 | pop-layout.ts | `isV5Layout()` | v5 여부 확인 |
## 상태 관리
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 레이아웃 상태 | PopDesigner.tsx | `useState<PopLayoutDataV5>` | 메인 레이아웃 |
| 히스토리 | PopDesigner.tsx | `history[]`, `historyIndex` | Undo/Redo |
| 선택 상태 | PopDesigner.tsx | `selectedComponentId` | 현재 선택 |
| 모드 상태 | PopDesigner.tsx | `currentMode` | 그리드 모드 |
## 저장/로드
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 레이아웃 로드 | PopDesigner.tsx | `useEffect` | 화면 로드 시 |
| 레이아웃 저장 | PopDesigner.tsx | `handleSave()` | 저장 버튼 |
| API 호출 | screen.ts (lib/api) | `screenApi.saveLayoutPop()` | 백엔드 통신 |
## 뷰포트/줌
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 프리셋 전환 | PopCanvas.tsx | `VIEWPORT_PRESETS` | 4개 모드 |
| 줌 컨트롤 | PopCanvas.tsx | `canvasScale` | 30%~150% |
| 패닝 | PopCanvas.tsx | Space + 드래그 | 캔버스 이동 |
---
## 파일별 주요 기능
| 파일 | 핵심 기능 |
|------|----------|
| PopDesigner.tsx | 레이아웃 로드/저장, 컴포넌트 CRUD, 히스토리 |
| PopCanvas.tsx | DnD, 줌, 패닝, 모드 전환, 그리드 표시 |
| PopRenderer.tsx | CSS Grid 렌더링, 위치 변환, 컴포넌트 표시 |
| ComponentEditorPanel.tsx | 속성 편집 (위치, 크기, 설정, 표시) |
| pop-layout.ts | 타입 정의, 유틸리티 함수, 상수 |
| gridUtils.ts | 좌표 계산, 겹침 감지, 자동 배치 |
---
*새 기능 추가 시 해당 카테고리 테이블에 행 추가*

View File

@ -48,11 +48,20 @@
## 작업 순서 ## 작업 순서
``` ```
[Phase 1] [Phase 2] [Phase 3] [Phase 4] [Phase 1~3] [Phase 4] [Phase 5]
v4 기본 구조 → 오버라이드 기능 → 컴포넌트 숨김 → 순서 오버라이드 v4 Flexbox → 실제 컴포넌트 → 그리드 시스템 (v5)
완료 다음 계획 계획 완료 다음 계획 승인
``` ```
### Phase 5: 그리드 시스템 (v5) - 신규 계획
```
[Phase 5.1] [Phase 5.2] [Phase 5.3] [Phase 5.4]
그리드 타입 정의 → 그리드 렌더러 → 디자이너 UI → 반응형 자동화
```
**상세 계획**: [GRID_SYSTEM_PLAN.md](./GRID_SYSTEM_PLAN.md)
--- ---
## Phase 1: 기본 구조 (완료) ## Phase 1: 기본 구조 (완료)
@ -127,6 +136,58 @@ v4 기본 구조 → 오버라이드 기능 → 컴포넌트 숨김 →
--- ---
## Phase 5: 그리드 시스템 (v5) - 계획 승인
> 상세 계획: [GRID_SYSTEM_PLAN.md](./GRID_SYSTEM_PLAN.md)
### 개요
Flexbox 흐름 기반 → **CSS Grid 위치 지정** 방식으로 전환
| 항목 | v4 (현재) | v5 (그리드) |
|------|----------|-------------|
| 배치 | Flexbox 흐름 | Grid 좌표 (열/행) |
| 크기 | 픽셀 (200px) | 칸 (colSpan, rowSpan) |
| 줄바꿈 | 자동 | 명시적 |
### Phase 5.1: 그리드 타입 정의
- [ ] `PopLayoutDataV5` 인터페이스
- [ ] `PopGridConfig` (칸 수, 행 높이, 간격)
- [ ] `PopComponentPositionV5` (col, row, colSpan, rowSpan)
- [ ] 브레이크포인트 상수 (4칸/6칸/8칸/12칸)
### Phase 5.2: 그리드 렌더러
- [ ] `PopGridRenderer.tsx` 생성
- [ ] CSS Grid 스타일 계산
- [ ] 브레이크포인트 감지 및 칸 수 변경
- [ ] 위치 변환 (12칸 → 4칸)
### Phase 5.3: 디자이너 UI
- [ ] `PopCanvasV5.tsx` (그리드 캔버스)
- [ ] 바둑판 배경 표시
- [ ] 드래그 스냅 (칸에 맞춤)
- [ ] 위치 편집 패널
### Phase 5.4: 반응형 자동화
- [ ] 자동 변환 알고리즘 (12칸 → 4칸)
- [ ] 겹침 감지 및 재배치
- [ ] 모드별 오버라이드
### 브레이크포인트
| 모드 | 화면 범위 | 그리드 칸 수 |
|------|----------|-------------|
| 모바일 세로 | ~599px (4~6인치) | 4칸 |
| 모바일 가로 | 600~839px (6~8인치) | 6칸 |
| 태블릿 세로 | 840~1023px (8~10인치) | 8칸 |
| 태블릿 가로 | 1024px~ (10~14인치) | 12칸 |
---
## 완료된 기능 목록 ## 완료된 기능 목록
### v4 타입 정의 ### v4 타입 정의
@ -241,4 +302,4 @@ scaledSize = originalSize * scale
--- ---
*최종 업데이트: 2026-02-04* *최종 업데이트: 2026-02-05 (Phase 5 그리드 시스템 계획 추가)*

62
popdocs/PROBLEMS.md Normal file
View File

@ -0,0 +1,62 @@
# 문제-해결 색인
> **용도**: "이전에 비슷한 문제 어떻게 해결했어?"
> **검색 팁**: Ctrl+F로 키워드 검색 (에러 메시지, 컴포넌트명 등)
---
## 렌더링 관련
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| rowSpan이 적용 안됨 | gridTemplateRows를 `1fr`로 변경 | 2026-02-02 | grid, rowSpan, CSS |
| 컴포넌트 크기 스케일 안됨 | viewportWidth 기반 scale 계산 추가 | 2026-02-04 | scale, viewport, 반응형 |
## DnD (드래그앤드롭) 관련
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| useDrag 에러 (뷰어에서) | isDesignMode 체크 후 early return | 2026-02-04 | DnD, useDrag, 뷰어 |
| DndProvider 중복 에러 | 최상위에서만 Provider 사용 | 2026-02-04 | DndProvider, react-dnd |
## 타입 관련
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| 인터페이스 이름 불일치 | V5 접미사 제거, 통일 | 2026-02-05 | 타입, interface, Props |
| v3/v4 타입 혼재 | v5 전용으로 통합, 레거시 삭제 | 2026-02-05 | 버전, 타입, 마이그레이션 |
## 레이아웃 관련
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| Flexbox 배치 예측 불가 | CSS Grid로 전환 (v5) | 2026-02-05 | Flexbox, Grid, 반응형 |
| 4모드 각각 배치 힘듦 | 제약조건 기반 시스템 (v4) | 2026-02-03 | 모드, 반응형, 제약조건 |
| 4모드 자동 전환 안됨 | useResponsiveMode 훅 추가 | 2026-02-01 | 모드, 훅, 반응형 |
## 저장/로드 관련
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| 레이아웃 버전 충돌 | isV5Layout 타입 가드로 분기 | 2026-02-05 | 버전, 로드, 타입가드 |
| 빈 레이아웃 판별 실패 | components 존재 여부로 판별 | 2026-02-04 | 빈 레이아웃, 로드 |
## UI/UX 관련
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| root 레이아웃 오염 | tempLayout 도입 (임시 상태 분리) | 2026-02-04 | tempLayout, 상태, 오염 |
| 속성 패널 다른 모드 수정 | isDefaultMode 체크로 비활성화 | 2026-02-04 | 속성패널, 모드, 비활성화 |
---
## 해결 안 된 문제 (진행 중)
| 문제 | 상태 | 관련 파일 |
|------|------|----------|
| PopCanvas 타입 오류 | 미해결 | PopCanvas.tsx:76 |
| 팔레트 UI 없음 | 미해결 | PopDesigner.tsx |
---
*새 문제-해결 추가 시 해당 카테고리 테이블에 행 추가*

View File

@ -1,12 +1,74 @@
# POP 화면 시스템 # POP 화면 시스템
> Point of Production - 현장 작업자용 모바일/태블릿 화면 > **AI 에이전트 시작점**: 이 파일 → STATUS.md 순서로 읽으세요.
> 저장 요청 시: [SAVE_RULES.md](./SAVE_RULES.md) 참조
--- ---
## Quick Reference ## 현재 상태
### 주요 경로 | 항목 | 값 |
|------|-----|
| 버전 | **v5** (CSS Grid 기반) |
| 상태 | **기본 기능 완료** |
| 다음 | 실제 테스트, Phase 4 (실제 컴포넌트 구현) |
**마지막 업데이트**: 2026-02-05
---
## 마지막 대화 요약
> (B)(C)(D) 모두 완료. 팔레트 UI 추가, 타입 오류 수정, 문서 v5 기준 통일.
> 다음: 실제 테스트 후 Phase 4 (실제 컴포넌트 렌더링, 데이터 바인딩) 진행.
---
## 빠른 경로
| 알고 싶은 것 | 문서 |
|--------------|------|
| 지금 뭐 해야 해? | [STATUS.md](./STATUS.md) |
| 저장/조회 규칙 | [SAVE_RULES.md](./SAVE_RULES.md) |
| 왜 v5로 바꿨어? | [decisions/003-v5-grid-system.md](./decisions/003-v5-grid-system.md) |
| 이전 문제 해결 | [PROBLEMS.md](./PROBLEMS.md) |
| 코드 어디 있어? | [FILES.md](./FILES.md) |
| 기능별 색인 | [INDEX.md](./INDEX.md) |
| 변경 히스토리 | [CHANGELOG.md](./CHANGELOG.md) |
---
## 핵심 파일
| 파일 | 역할 | 경로 |
|------|------|------|
| 타입 정의 | v5 레이아웃 타입 | `frontend/components/pop/designer/types/pop-layout.ts` |
| 캔버스 | 그리드 캔버스 + DnD | `frontend/components/pop/designer/PopCanvas.tsx` |
| 렌더러 | CSS Grid 렌더링 | `frontend/components/pop/designer/renderers/PopRenderer.tsx` |
| 디자이너 | 메인 컴포넌트 | `frontend/components/pop/designer/PopDesigner.tsx` |
---
## 문서 구조
```
[Layer 1: 먼저 읽기]
README.md (지금 여기) → STATUS.md
[Layer 2: 필요시 읽기]
CHANGELOG, PROBLEMS, INDEX, FILES, ARCHITECTURE, SPEC, PLAN
[Layer 3: 심화]
decisions/, sessions/, archive/
```
**컨텍스트 효율화**: 모든 문서를 읽지 마세요. 필요한 것만 단계적으로.
---
## POP이란?
**Point of Production** - 현장 작업자용 모바일/태블릿 화면 시스템
| 용도 | 경로 | | 용도 | 경로 |
|------|------| |------|------|
@ -14,103 +76,19 @@
| 관리 | `/admin/screenMng/popScreenMngList` | | 관리 | `/admin/screenMng/popScreenMngList` |
| API | `/api/screen-management/layout-pop/:screenId` | | API | `/api/screen-management/layout-pop/:screenId` |
### 핵심 파일 ---
| 작업 | 파일 | ## v5 그리드 시스템 (현재)
|------|------|
| 타입 | `frontend/components/pop/designer/types/pop-layout.ts` |
| 렌더러 | `frontend/components/pop/designer/renderers/` |
| 디자이너 | `frontend/components/pop/designer/PopDesigner.tsx` |
### 현재 상태 | 모드 | 화면 너비 | 칸 수 |
|------|----------|-------|
| mobile_portrait | ~599px | 4칸 |
| mobile_landscape | 600~839px | 6칸 |
| tablet_portrait | 840~1023px | 8칸 |
| tablet_landscape | 1024px~ | 12칸 |
- **버전**: v4.0 (제약조건 기반) ✅ **핵심**: 컴포넌트를 칸 단위로 배치 (col, row, colSpan, rowSpan)
- **Phase**: Phase 3 완료 (visibility + 줄바꿈)
- **다음**: Phase 4 (실제 컴포넌트 구현)
--- ---
## 문서 구조 *상세: [SPEC.md](./SPEC.md) | 히스토리: [CHANGELOG.md](./CHANGELOG.md)*
| 파일 | 용도 |
|------|------|
| [SPEC.md](./SPEC.md) | 기술 스펙 |
| [V4_UNIFIED_DESIGN_SPEC.md](./V4_UNIFIED_DESIGN_SPEC.md) | v4 통합 설계 모드 |
| [PLAN.md](./PLAN.md) | 계획/로드맵 |
| [CHANGELOG.md](./CHANGELOG.md) | 변경 이력 |
| [decisions/](./decisions/) | 중요 결정 기록 (ADR) |
| [components-spec.md](./components-spec.md) | 컴포넌트 상세 |
| [archive/](./archive/) | 이전 문서 |
---
## 저장/조회 시스템
### 역할 분담
| 저장소 | 역할 | 특징 |
|--------|------|------|
| **rangraph** | AI 장기 기억 | 시맨틱 검색, 요약 저장 |
| **popdocs** | 상세 기록 | 파일 기반, 히스토리 |
### 저장 요청
| 요청 예시 | AI 행동 |
|----------|--------|
| "@CHANGELOG.md 오늘 작업 정리해줘" | 파일 형식 맞춰 추가 + rangraph 요약 |
| "이 결정 저장해줘" | rangraph save_decision + decisions/ ADR |
| "해결됐어" | rangraph save_lesson + CHANGELOG Fixed |
| "작업 완료" | rangraph workflow_submit + CHANGELOG Added |
### 조회 요청
| 요청 예시 | AI 행동 |
|----------|--------|
| "어제 뭐했지?" | rangraph 검색 |
| "지금 뭐하는 중이었지?" | rangraph workflow_status |
| "이 버그 전에도 있었어?" | rangraph search_memory |
| "v4 관련 작업들" | rangraph search_memory "v4" |
---
## v4 핵심 (요약)
```
v3: 4개 모드 각각 위치 설정 → 4배 작업량
v4: 3가지 규칙만 설정 → 자동 적응
핵심 규칙:
1. 크기: fixed(고정) / fill(채움) / hug(맞춤)
2. 배치: direction, wrap, gap
3. 반응형: breakpoint별 변경
Phase 3 추가:
4. visibility: 모드별 표시/숨김
5. pop-break: 강제 줄바꿈
```
상세: [V4_UNIFIED_DESIGN_SPEC.md](./V4_UNIFIED_DESIGN_SPEC.md)
---
## Phase 3 완료 사항 (2026-02-04) ✅
### 새 기능
- **visibility 속성**: 모드별 컴포넌트 표시/숨김 제어
- **pop-break 컴포넌트**: Flexbox 강제 줄바꿈 (`flex-basis: 100%`)
- **컴포넌트 오버라이드 병합**: 모드별 설정 변경 (리스트 컬럼 수 등)
- **오버라이드 정리 로직**: 컴포넌트 삭제 시 모든 오버라이드 자동 정리
### 사용 예시
```
태블릿: [A] [B] [C] [D] (한 줄)
모바일: [A] [B] (두 줄, 줄바꿈 적용)
[C] [D]
```
### 참고
- [decisions/002-phase3-visibility-break.md](./decisions/002-phase3-visibility-break.md) - 상세 설계
---
*최종 업데이트: 2026-02-04 (Phase 3 완료)*

539
popdocs/SAVE_RULES.md Normal file
View File

@ -0,0 +1,539 @@
# popdocs 사용 규칙
> **AI 에이전트 필독**: 이 문서는 popdocs 폴더 사용법입니다.
> 사용자가 "@popdocs"와 함께 요청하면 이 규칙을 참조하세요.
---
## 요청 유형 인식
### 키워드로 요청 유형 판별
| 유형 | 키워드 예시 | 행동 |
|------|------------|------|
| **저장** | 저장해줘, 기록해줘, 정리해줘, 추가해줘 | → 저장 규칙 따르기 |
| **조회** | 찾아줘, 검색해줘, 뭐 있어?, 어디있어? | → 조회 규칙 따르기 |
| **분석** | 분석해줘, 비교해줘, 어떻게 달라? | → 분석 규칙 따르기 |
| **수정** | 수정해줘, 업데이트해줘, 고쳐줘 | → 수정 규칙 따르기 |
| **요약** | 요약해줘, 정리해서 보여줘, 보고서 | → 요약 규칙 따르기 |
| **작업시작** | 시작하자, 이어서 하자, 뭐 해야 해? | → 작업 시작 규칙 |
### 요청 유형별 행동
```
[저장 요청]
"@popdocs 오늘 작업 저장해줘"
→ SAVE_RULES.md 저장 섹션 → 적절한 파일에 저장 → 동기화
[조회 요청]
"@popdocs 이전에 DnD 문제 어떻게 해결했어?"
→ PROBLEMS.md 검색 → 관련 내용만 반환
[분석 요청]
"@popdocs v4랑 v5 뭐가 달라?"
→ decisions/ 또는 CHANGELOG 검색 → 비교표 생성
[수정 요청]
"@popdocs STATUS 업데이트해줘"
→ STATUS.md 수정 → README.md 동기화
[요약 요청]
"@popdocs 이번 주 작업 요약해줘"
→ sessions/ 해당 기간 검색 → 요약 생성
[작업 시작]
"@popdocs 오늘 작업 시작하자"
→ README → STATUS → 중단점 확인 → 작업 시작
```
---
## 컨텍스트 효율화 원칙
### Progressive Disclosure (점진적 공개)
**핵심**: 모든 문서를 한 번에 읽지 마세요. 필요한 것만 단계적으로.
```
Layer 1 (진입점) → README.md, STATUS.md (먼저 읽기, ~100줄)
Layer 2 (상세) → 필요한 문서만 선택적으로
Layer 3 (심화) → 코드 파일, archive/ (필요시만)
```
### Token as Currency (토큰은 자원)
| 원칙 | 설명 |
|------|------|
| **관련성 > 최신성** | 모든 히스토리 대신 관련 있는 것만 |
| **요약 > 전문** | 긴 내용 대신 요약 먼저 확인 |
| **링크 > 복사** | 내용 복사 대신 파일 경로 참조 |
| **테이블 > 산문** | 긴 설명 대신 표로 압축 |
| **검색 > 전체읽기** | Ctrl+F 키워드 검색 활용 |
### Context Bloat 방지
```
❌ 잘못된 방법:
"모든 문서를 읽고 파악한 후 작업하겠습니다"
→ 1,300줄 이상 낭비
✅ 올바른 방법:
"README → STATUS → 필요한 섹션만"
→ 평균 50~100줄로 작업 가능
```
---
## 문서 구조 (3계층)
```
popdocs/
├── [Layer 1: 진입점] ─────────────────────────
│ ├── README.md ← 시작점 (현재 상태 요약)
│ ├── STATUS.md ← 진행 상태, 다음 작업
│ └── SAVE_RULES.md ← 사용 규칙 (지금 읽는 문서)
├── [Layer 2: 상세 문서] ─────────────────────────
│ ├── CHANGELOG.md ← 변경 이력 (날짜별)
│ ├── PROBLEMS.md ← 문제-해결 색인
│ ├── INDEX.md ← 기능별 색인
│ ├── ARCHITECTURE.md ← 코드 구조
│ ├── FILES.md ← 파일 목록
│ ├── SPEC.md ← 기술 스펙
│ └── PLAN.md ← 계획
├── [Layer 3: 심화/기록] ─────────────────────────
│ ├── decisions/ ← ADR (결정 기록)
│ ├── sessions/ ← 날짜별 작업 기록
│ └── archive/ ← 보관 (레거시)
└── [외부 참조] ─────────────────────────
└── 실제 코드 → frontend/components/pop/designer/
```
---
## 조회 규칙 (읽기)
### 작업 시작 시
```
1. README.md 읽기 (~60줄)
└→ 현재 상태, 다음 작업 확인
2. STATUS.md 읽기 (~40줄)
└→ 상세 진행 상황, 중단점 확인
3. 필요한 문서만 선택적으로
```
### 요청별 조회 경로
| 사용자 요청 | 조회 경로 |
|-------------|----------|
| "지금 뭐 해야 해?" | README → STATUS |
| "어제 뭐 했어?" | sessions/어제날짜.md |
| "이전에 비슷한 문제?" | PROBLEMS.md (키워드 검색) |
| "이 기능 어디있어?" | INDEX.md 또는 FILES.md |
| "왜 이렇게 결정했어?" | decisions/ |
| "전체 히스토리" | CHANGELOG.md (기간 한정) |
| "코드 구조 알려줘" | ARCHITECTURE.md |
| "v4랑 v5 뭐가 달라?" | decisions/003 또는 CHANGELOG |
### 효율적 검색
```
# 전체 파일 읽지 말고 키워드 검색
PROBLEMS.md에서 "DnD" 검색 → 관련 행만
CHANGELOG.md에서 "2026-02-05" 검색 → 해당 날짜만
FILES.md에서 "렌더러" 검색 → 관련 파일만
```
---
## 저장 규칙 (쓰기)
### 저장 유형별 위치
| 요청 패턴 | 저장 위치 | 형식 |
|----------|----------|------|
| "오늘 작업 저장/정리해줘" | sessions/YYYY-MM-DD.md | 세션 템플릿 |
| "이 결정 기록해줘" | decisions/NNN-제목.md | ADR 템플릿 |
| "이 문제 해결 기록해줘" | PROBLEMS.md | 행 추가 |
| "작업 내용 추가해줘" | CHANGELOG.md | 섹션 추가 |
| "현재 상태 업데이트" | STATUS.md | 상태 수정 |
| "기능 색인 추가해줘" | INDEX.md | 행 추가 |
### 저장 후 필수 동기화
```
저장 완료 후 항상:
1. STATUS.md 업데이트 (진행 상태, 다음 작업)
2. README.md "마지막 대화 요약" 업데이트 (1-2줄)
```
---
## 분석/비교 규칙
### 비교 요청 시
```
사용자: "@popdocs v4랑 v5 뭐가 달라?"
AI 행동:
1. decisions/003-v5-grid-system.md 확인 (있으면)
2. 없으면 CHANGELOG에서 관련 날짜 검색
3. 비교표 형식으로 응답
응답 형식:
| 항목 | v4 | v5 |
|------|----|----|
| 배치 | Flexbox | CSS Grid |
| ... | ... | ... |
```
### 분석 요청 시
```
사용자: "@popdocs 이번 달 작업 분석해줘"
AI 행동:
1. sessions/ 폴더에서 해당 기간 파일 목록
2. 각 파일의 "요약" 섹션만 추출
3. 종합 분석 제공
```
---
## 수정 규칙
### 문서 수정 요청 시
```
사용자: "@popdocs STATUS 업데이트해줘"
AI 행동:
1. STATUS.md 읽기
2. 변경 내용 적용
3. README.md 동기화 (마지막 대화 요약)
4. 변경 내용 사용자에게 확인
```
### 여러 문서 수정 시
```
수정 순서:
상세 문서 먼저 → STATUS.md → README.md
(역방향: 진입점이 항상 최신 상태 유지)
```
---
## 요약/보고서 규칙
### 요약 요청 시
```
사용자: "@popdocs 이번 주 요약해줘"
AI 행동:
1. sessions/ 해당 기간 파일 확인
2. 각 파일의 "요약" + "완료" 섹션 추출
3. 압축된 형식으로 응답
응답 형식:
## 이번 주 요약 (02-01 ~ 02-05)
| 날짜 | 주요 작업 |
|------|----------|
| 02-05 | v5 통합 완료 |
| 02-04 | ... |
```
### 보고서 생성 요청 시
```
사용자: "@popdocs 진행 보고서 만들어줘"
AI 행동:
1. STATUS.md (현재 상태)
2. CHANGELOG.md (최근 작업)
3. sessions/ (상세 기록)
4. 보고서 형식으로 조합
→ 새 파일 생성하지 말고 응답으로 제공
→ 필요시 사용자가 저장 요청
```
---
## 예외 처리
### 분류 불가 시
```
사용자: "@popdocs 이거 저장해줘" (유형 불명확)
AI 행동:
→ 사용자에게 질문: "어떤 유형으로 저장할까요?"
- 오늘 작업 기록 (sessions/)
- 문제-해결 기록 (PROBLEMS.md)
- 결정 사항 (decisions/)
- 변경 이력 (CHANGELOG.md)
```
### 복합 요청 시
```
사용자: "@popdocs 문제 해결했고 결정도 내렸어. 저장해줘"
AI 행동 (순서):
1. PROBLEMS.md에 문제-해결 추가
2. decisions/에 ADR 생성 (필요시)
3. CHANGELOG.md에 섹션 추가
4. STATUS.md 업데이트
5. README.md 동기화
```
### 전체 조회 요청 시
```
사용자: "@popdocs 전체 히스토리 보여줘"
AI 행동:
→ "기간을 지정해주시면 더 정확히 찾아드릴게요."
- 예: "최근 1주일", "2월 작업", "v5 관련"
→ 기간 없이 강행 시: CHANGELOG.md 최근 5개 항목만
```
### 파일 없음 시
```
사용자: "@popdocs 어제 작업 보여줘" (sessions/어제.md 없음)
AI 행동:
→ "어제 작업 기록이 없습니다. CHANGELOG.md에서 찾아볼까요?"
```
### 키워드 검색 실패 시
```
사용자: "@popdocs DnD 문제 찾아줘" (PROBLEMS.md에 없음)
AI 행동:
→ "PROBLEMS.md에서 못 찾았습니다. 다른 곳도 검색할까요?"
- CHANGELOG.md
- INDEX.md
- sessions/
```
---
## 동기화 규칙
### 항상 동기화해야 하는 쌍
| 변경 문서 | 동기화 대상 |
|----------|-----------|
| sessions/ 생성 | STATUS.md (최근 세션) |
| PROBLEMS.md 추가 | - |
| decisions/ 생성 | STATUS.md (관련 결정), CHANGELOG.md |
| CHANGELOG.md 추가 | STATUS.md (진행 상태) |
| STATUS.md 수정 | README.md (마지막 요약) |
### 불일치 발견 시
```
README.md와 STATUS.md 내용이 다르면:
→ STATUS.md를 정본(正本)으로
→ README.md를 STATUS.md 기준으로 업데이트
```
---
## 정리 규칙
### 주기적 정리 (수동 요청 시)
| 대상 | 조건 | 조치 |
|------|------|------|
| sessions/ | 30일 이상 | archive/sessions/로 이동 |
| PROBLEMS.md | 100행 초과 | 카테고리별 분리 검토 |
| CHANGELOG.md | 연도 변경 | 이전 연도 archive/로 |
### 정리 요청 패턴
```
사용자: "@popdocs 오래된 파일 정리해줘"
AI 행동:
1. sessions/ 30일 이상 파일 목록 제시
2. 사용자 확인 후 archive/로 이동
3. 강제 삭제하지 않음
```
---
## 템플릿
### 세션 기록 (sessions/YYYY-MM-DD.md)
```markdown
# YYYY-MM-DD 작업 기록
## 요약
(한 줄 요약 - 50자 이내)
## 완료
- [x] 작업1
- [x] 작업2
## 미완료
- [ ] 작업3 (이유: ...)
## 중단점
> (내일 이어서 할 때 바로 시작할 수 있는 정보)
## 대화 핵심
- 키워드1: 설명
- 키워드2: 설명
## 관련 링크
- CHANGELOG: #YYYY-MM-DD
- ADR: decisions/NNN (있으면)
```
### 문제-해결 (PROBLEMS.md 행 추가)
```markdown
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| (에러/문제 설명) | (해결 방법) | YYYY-MM-DD | 검색용 |
```
### ADR (decisions/NNN-제목.md)
```markdown
# ADR-NNN: 제목
**날짜**: YYYY-MM-DD
**상태**: 채택됨
## 배경 (왜)
(2-3문장)
## 결정 (무엇)
(핵심 결정 사항)
## 대안
| 옵션 | 장점 | 단점 | 결과 |
|------|------|------|------|
## 교훈
- (배운 점)
```
### CHANGELOG 섹션
```markdown
## [YYYY-MM-DD] 제목
### 배경
(왜 - 2-3문장)
### 작업
- [x] 완료1
- [ ] 미완료 → STATUS.md로
### 다음
→ STATUS.md 참조
```
---
## 토큰 비용 가이드
| 문서 | 평균 줄 수 | 언제 읽나 |
|------|-----------|----------|
| README.md | ~60 | 항상 (진입점) |
| STATUS.md | ~40 | 항상 (진입점) |
| SAVE_RULES.md | ~350 | 저장/조회 요청 시 |
| sessions/날짜.md | ~30 | 해당 날짜만 |
| PROBLEMS.md | ~100 | 키워드 검색 |
| INDEX.md | ~80 | 키워드 검색 |
| CHANGELOG.md | ~600 | 날짜/키워드 검색 |
| ARCHITECTURE.md | ~300 | 구조 파악 시 |
| FILES.md | ~200 | 파일 찾을 때 |
**권장**: README + STATUS = ~100줄로 작업 시작
---
## 예시 워크플로우
### 1. 작업 시작
```
사용자: "@popdocs 오늘 작업 시작하자"
AI:
1. README.md 읽기 (60줄)
2. STATUS.md 읽기 (40줄)
3. 중단점 확인
4. "중단점: PopCanvas.tsx line 76 타입 오류. 여기서 시작할까요?"
```
### 2. 자료 검색
```
사용자: "@popdocs DnD 관련 자료 찾아줘"
AI:
1. PROBLEMS.md에서 "DnD" 검색
2. INDEX.md에서 "DnD" 검색
3. 관련 내용 종합해서 응답
4. 전체 파일 읽지 않음
```
### 3. 비교 요청
```
사용자: "@popdocs v4랑 v5 비교해줘"
AI:
1. decisions/003-v5-grid-system.md 확인
2. "대안 검토" 섹션에서 비교표 추출
3. 필요시 CHANGELOG 보충
```
### 4. 작업 저장
```
사용자: "@popdocs 오늘 작업 저장해줘"
AI:
1. sessions/2026-02-05.md 생성
2. STATUS.md 업데이트
3. README.md "마지막 대화 요약" 업데이트
4. "저장 완료. 내일 이어서 할 중단점도 기록했습니다."
```
### 5. 불명확한 요청
```
사용자: "@popdocs 이거 저장해줘"
AI:
"어떤 유형으로 저장할까요?
1. 오늘 작업 기록 (sessions/)
2. 문제-해결 (PROBLEMS.md)
3. 결정 사항 (decisions/)
4. 변경 이력 (CHANGELOG.md)"
```
---
*2025-2026 AI 컨텍스트 엔지니어링 기반*
*Progressive Disclosure, Agentic Context Engineering, Token as Currency*

View File

@ -1,43 +1,122 @@
# POP 기술 스펙 # POP 기술 스펙
**버전: v5 (CSS Grid 기반)**
--- ---
## v4 핵심 규칙 (3가지) ## v5 핵심 규칙
### 1. 크기 규칙 (Size) ### 1. 그리드 시스템
| 모드 | 설명 | 예시 | | 모드 | 화면 너비 | 칸 수 | 대상 |
|------|------|------| |------|----------|-------|------|
| `fixed` | 고정 px | 버튼 48px | | mobile_portrait | ~599px | 4칸 | 4~6인치 |
| `fill` | 부모 채움 | 입력창 100% | | mobile_landscape | 600~839px | 6칸 | 7인치 |
| `hug` | 내용 맞춤 | 라벨 | | tablet_portrait | 840~1023px | 8칸 | 8~10인치 |
| tablet_landscape | 1024px~ | 12칸 | 10~14인치 (기본) |
### 2. 배치 규칙 (Layout) ### 2. 위치 지정
| 항목 | 옵션 |
|------|------|
| direction | horizontal / vertical |
| wrap | true / false |
| gap | 8 / 16 / 24 px |
| alignItems | start / center / end / stretch |
### 3. 반응형 규칙 (Responsive)
```typescript ```typescript
{ interface PopGridPosition {
direction: "horizontal", col: number; // 시작 열 (1부터)
responsive: [{ breakpoint: 768, direction: "vertical" }] row: number; // 시작 행 (1부터)
colSpan: number; // 열 크기 (1~12)
rowSpan: number; // 행 크기 (1~)
} }
``` ```
### 3. 브레이크포인트 설정
```typescript
const GRID_BREAKPOINTS = {
mobile_portrait: {
columns: 4,
rowHeight: 48,
gap: 8,
padding: 12
},
mobile_landscape: {
columns: 6,
rowHeight: 44,
gap: 8,
padding: 16
},
tablet_portrait: {
columns: 8,
rowHeight: 52,
gap: 12,
padding: 20
},
tablet_landscape: {
columns: 12,
rowHeight: 56,
gap: 12,
padding: 24
},
};
```
---
## 데이터 구조
### v5 레이아웃
```typescript
interface PopLayoutDataV5 {
version: "pop-5.0";
metadata: {
screenId: number;
createdAt: string;
updatedAt: string;
};
gridConfig: {
defaultMode: GridMode;
maxRows: number;
};
components: PopComponentDefinitionV5[];
globalSettings: {
backgroundColor: string;
padding: number;
};
}
```
### v5 컴포넌트
```typescript
interface PopComponentDefinitionV5 {
id: string;
type: PopComponentType; // "pop-label" | "pop-button" | ...
label: string;
gridPosition: PopGridPosition;
config: PopComponentConfig;
visibility: Record<GridMode, boolean>; // 모드별 표시/숨김
modeOverrides?: Record<GridMode, PopModeOverrideV5>; // 모드별 오버라이드
}
```
### 컴포넌트 타입
```typescript
type PopComponentType =
| "pop-label" // 텍스트 라벨
| "pop-button" // 버튼
| "pop-input" // 입력 필드
| "pop-select" // 선택 박스
| "pop-grid" // 데이터 그리드
| "pop-container"; // 컨테이너
```
--- ---
## 크기 프리셋 ## 크기 프리셋
### 터치 요소 ### 터치 요소
| 요소 | 일반 | 산업 | | 요소 | 일반 | 산업 |
|------|-----|------| |------|-----|-------|
| 버튼 높이 | 48px | 60px | | 버튼 높이 | 48px | 60px |
| 입력창 높이 | 48px | 56px | | 입력창 높이 | 48px | 56px |
| 터치 영역 | 48px | 60px | | 터치 영역 | 48px | 60px |
@ -62,60 +141,64 @@
## 반응형 원칙 ## 반응형 원칙
``` ```
누르는 것 → 고정 (48px) 누르는 것 → 고정 (48px) - 버튼, 터치 영역
읽는 것 → 범위 (clamp) 읽는 것 → 범위 (clamp) - 텍스트
담는 것 → 비율 (%) 담는 것 → 칸 (colSpan) - 컨테이너
``` ```
--- ---
## 데이터 구조 ## 위치 변환
### v3 (현재) 12칸 기준으로 설계 → 다른 모드에서 자동 변환
```typescript ```typescript
interface PopLayoutDataV3 { // 12칸 → 4칸 변환 예시
version: "pop-3.0"; const ratio = 4 / 12; // = 0.333
layouts: {
tablet_landscape: { componentPositions: Record<string, GridPosition> };
// ... 4개 모드
};
components: Record<string, PopComponentDefinition>;
}
```
### v4 (계획) original: { col: 1, colSpan: 6 } // 12칸에서 절반
converted: { col: 1, colSpan: 2 } // 4칸에서 절반
```typescript
interface PopLayoutDataV4 {
version: "pop-4.0";
root: PopContainer;
components: Record<string, PopComponentDefinitionV4>;
}
interface PopContainer {
type: "stack";
direction: "horizontal" | "vertical";
children: (string | PopContainer)[];
responsive?: { breakpoint: number; direction?: string }[];
}
``` ```
--- ---
## Troubleshooting ## Troubleshooting
### 캔버스 rowSpan 문제 ### 컴포넌트가 얇게 보임
- **증상**: 컴포넌트가 얇게 보임 - **증상**: rowSpan이 적용 안됨
- **원인**: gridTemplateRows 고정 px - **원인**: gridTemplateRows 고정 px
- **해결**: `1fr` 사용 - **해결**: `1fr` 사용
### 4모드 전환 안 됨 ### 모드 전환 안 됨
- **증상**: 크기 줄여도 레이아웃 유지 - **증상**: 화면 크기 변경해도 레이아웃 유지
- **해결**: useResponsiveMode 훅 사용 - **해결**: `detectGridMode()` 사용
### 겹침 발생
- **증상**: 컴포넌트끼리 겹침
- **해결**: `resolveOverlaps()` 호출
--- ---
*상세 archive 참조: `archive/V4_CORE_RULES.md`, `archive/SIZE_PRESETS.md`* ## 타입 가드
```typescript
// v5 레이아웃 판별
function isV5Layout(data: any): data is PopLayoutDataV5 {
return data?.version === "pop-5.0";
}
// 사용 예시
if (isV5Layout(savedData)) {
setLayout(savedData);
} else {
setLayout(createEmptyPopLayoutV5());
}
```
---
*상세 아키텍처: [ARCHITECTURE.md](./ARCHITECTURE.md)*
*파일 목록: [FILES.md](./FILES.md)*

68
popdocs/STATUS.md Normal file
View File

@ -0,0 +1,68 @@
# 현재 상태
> **마지막 업데이트**: 2026-02-05
> **담당**: POP 화면 디자이너
---
## 진행 상태
| 단계 | 상태 | 설명 |
|------|------|------|
| v5 타입 정의 | 완료 | `pop-layout.ts` |
| v5 렌더러 | 완료 | `PopRenderer.tsx` |
| v5 캔버스 | 완료 | `PopCanvas.tsx` |
| v5 편집 패널 | 완료 | `ComponentEditorPanel.tsx` |
| v5 유틸리티 | 완료 | `gridUtils.ts` |
| 레거시 삭제 | 완료 | v1~v4 코드, 데이터 |
| 문서 정리 | **완료** | popdocs v5 기준 재정비 |
| 컴포넌트 팔레트 | **완료** | `ComponentPalette.tsx` |
| 타입 오류 수정 | **완료** | PopCanvas.tsx:76 |
| 드래그앤드롭 | **완료** | 팔레트 → 캔버스 연결 |
---
## 다음 작업 (우선순위)
1. **실제 테스트**
- 디자이너 페이지에서 컴포넌트 드래그앤드롭 테스트
- 저장/로드 동작 확인
2. **실제 컴포넌트 구현** (Phase 4)
- pop-label, pop-button 등 실제 렌더링
- 데이터 바인딩 연결
3. **추가 기능**
- 컴포넌트 복사/붙여넣기
- 다중 선택
- 정렬 도우미
---
## 알려진 문제
| 문제 | 상태 | 비고 |
|------|------|------|
| 타입 이름 불일치 | 해결됨 | V5 접미사 제거 |
| 팔레트 없음 | 해결됨 | ComponentPalette.tsx 추가 |
---
## 최근 세션
| 날짜 | 요약 | 상세 |
|------|------|------|
| 2026-02-05 | v5 통합, 문서 재정비, 팔레트 UI 추가 | [sessions/2026-02-05.md](./sessions/2026-02-05.md) |
---
## 관련 결정
| ADR | 제목 | 날짜 |
|-----|------|------|
| 003 | v5 CSS Grid 채택 | 2026-02-05 |
| 001 | v4 제약조건 기반 | 2026-02-03 |
---
*전체 히스토리: [CHANGELOG.md](./CHANGELOG.md)*

View File

@ -0,0 +1,143 @@
# ADR-003: v5 CSS Grid 기반 그리드 시스템 채택
**날짜**: 2026-02-05
**상태**: 채택됨
**의사결정자**: 프로젝트 담당자, 상급자
---
## 배경
### 문제 상황
v4 Flexbox 기반 레이아웃으로 반응형 구현을 시도했으나 실패:
1. **배치 예측 불가능**: 컴포넌트가 자유롭게 움직이지만 원하는 위치에 안 감
2. **캔버스 방식의 한계**: "그리듯이" 배치하면 화면 크기별로 깨짐
3. **규칙 부재**: 어디에 뭘 배치해야 하는지 기준이 없음
### 상급자 피드백
> "이런 식이면 나중에 문제가 생긴다."
>
> "스크린의 픽셀 규격과 마진 간격 규칙을 설정해라.
> 큰 화면 디자인의 전체 프레임 규격과 사이즈 간격 규칙을 정한 다음에
> 거기에 컴포넌트를 끼워 맞추듯 우리의 규칙 내로 움직이게 바탕을 잡아라."
### 연구 내용
| 도구 | 핵심 특징 | 적용 가능 요소 |
|------|----------|---------------|
| **Softr** | 블록 기반, 제약 기반 레이아웃 | 컨테이너 슬롯 방식 |
| **Ant Design** | 24열 그리드, 8px 간격 | 그리드 시스템, 간격 규칙 |
| **Material Design** | 4/8/12열, 반응형 브레이크포인트 | 디바이스별 칸 수 |
---
## 결정
**CSS Grid 기반 그리드 시스템 (v5) 채택**
### 핵심 규칙
| 모드 | 화면 너비 | 칸 수 | 대상 디바이스 |
|------|----------|-------|--------------|
| mobile_portrait | ~599px | 4칸 | 4~6인치 모바일 |
| mobile_landscape | 600~839px | 6칸 | 7인치 모바일 |
| tablet_portrait | 840~1023px | 8칸 | 8~10인치 태블릿 |
| tablet_landscape | 1024px~ | 12칸 | 10~14인치 태블릿 (기본) |
### 컴포넌트 배치
```typescript
interface PopGridPosition {
col: number; // 시작 열 (1부터)
row: number; // 시작 행 (1부터)
colSpan: number; // 열 크기 (1~12)
rowSpan: number; // 행 크기 (1~)
}
```
### v4 대비 변경점
| 항목 | v4 (Flexbox) | v5 (Grid) |
|------|-------------|-----------|
| 배치 방식 | 흐름 기반 (자동) | 좌표 기반 (명시적) |
| 크기 단위 | 픽셀 (200px) | 칸 (colSpan: 3) |
| 예측성 | 낮음 | 높음 |
| 반응형 | 복잡한 규칙 | 칸 수 변환 |
---
## 대안 검토
### A. v4 Flexbox 유지 (기각)
- **장점**: 기존 코드 활용 가능
- **단점**: 상급자 지적한 문제 해결 안됨 (규칙 부재)
- **결과**: 기각
### B. 자유 배치 (절대 좌표) (기각)
- **장점**: 완전한 자유도
- **단점**: 반응형 불가능, 화면별로 전부 다시 배치 필요
- **결과**: 기각
### C. CSS Grid 그리드 시스템 (채택)
- **장점**:
- 규칙 기반으로 예측 가능
- 반응형 자동화 (12칸 → 4칸 변환)
- Material Design 표준 준수
- **단점**:
- 기존 v4 데이터 호환 불가
- 자유도 제한 (칸 단위로만)
- **결과**: **채택**
---
## 영향
### 변경 필요
- [x] 타입 정의 (`PopLayoutDataV5`, `PopGridPosition`)
- [x] 렌더러 (`PopRenderer.tsx` - CSS Grid)
- [x] 캔버스 (`PopCanvas.tsx` - 그리드 표시)
- [x] 유틸리티 (`gridUtils.ts` - 좌표 계산)
- [x] 레거시 삭제 (v1~v4 코드, 데이터)
### 호환성
- v1~v4 레이아웃: **삭제** (마이그레이션 없이 초기화)
- 새 화면: v5로만 생성
### 제한 사항
- 컴포넌트는 칸 단위로만 배치 (칸 사이 배치 불가)
- 12칸 기준으로 설계 후 다른 모드는 자동 변환
---
## 교훈
1. **규칙이 자유를 만든다**: 제약이 있어야 일관된 디자인 가능
2. **상급자 피드백 중요**: "프레임 규격 먼저" 조언이 핵심 방향 제시
3. **연구 후 결정**: Softr, Ant Design 분석이 구체적 방향 제시
4. **과감한 삭제**: 레거시 유지보다 깔끔한 재시작이 나음
---
## 참조
- Softr: https://www.softr.io
- Ant Design Grid: https://ant.design/components/grid
- Material Design Layout: https://m3.material.io/foundations/layout
- GRID_SYSTEM_DESIGN.md: 상세 설계 스펙
---
## 관련
- [ADR-001](./001-v4-constraint-based.md): v4 제약조건 기반 (이전 시도)
- [CHANGELOG 2026-02-05](../CHANGELOG.md#2026-02-05): 작업 내역
- [sessions/2026-02-05](../sessions/2026-02-05.md): 대화 기록

View File

@ -0,0 +1,85 @@
# 2026-02-05 작업 기록
## 요약
v5 그리드 시스템 통합 완료, popdocs 문서 구조 재정비
---
## 완료
### v5 통합 작업
- [x] 레거시 파일 삭제 (PopCanvasV4, PopFlexRenderer, PopLayoutRenderer 등)
- [x] 파일명 정규화 (V5 접미사 제거)
- [x] 뷰어 페이지 v5 전용으로 업데이트
- [x] 백엔드 screenManagementService v5 전용 단순화
- [x] DB 기존 레이아웃 데이터 삭제
### 문서 재정비 작업
- [x] SAVE_RULES.md 생성 (AI 저장/조회 규칙)
- [x] README.md 재작성 (진입점 역할)
- [x] STATUS.md 생성 (현재 상태)
- [x] PROBLEMS.md 생성 (문제-해결 색인)
- [x] INDEX.md 생성 (기능별 색인)
- [x] sessions/ 폴더 구조 도입
---
## 미완료
- [ ] 컴포넌트 팔레트 UI 추가 (PopDesigner.tsx 좌측)
- [ ] PopCanvas.tsx 타입 오류 수정 (line 76)
- [ ] ARCHITECTURE.md v5 기준 업데이트
- [ ] CHANGELOG.md 오늘 작업 추가
---
## 중단점
> **다음 작업자 참고**:
>
> 1. **타입 오류**: PopCanvas.tsx line 76
> - `}: PopCanvasV5Props)``}: PopCanvasProps)`로 변경
> - 인터페이스는 이미 `PopCanvasProps`로 정의됨 (line 48)
>
> 2. **팔레트 UI**: PopDesigner.tsx에 컴포넌트 팔레트 추가 필요
> - 위치: 좌측 ResizablePanel (현재 비어있음)
> - 참고: 이전 ComponentPaletteV4.tsx (삭제됨, archive에서 참고 가능)
> - DnD 타입: PopCanvas.tsx에 `DND_ITEM_TYPES` 인라인 정의됨
>
> 3. **문서**: ARCHITECTURE.md가 아직 v3/v4 기준임
---
## 대화 핵심
### v5 전환 배경
- **문제**: v4 Flexbox로 반응형 시도 → 배치 예측 불가능
- **상급자 피드백**: "스크린 규격과 마진 간격 규칙을 먼저 정해라"
- **연구**: Softr, Ant Design, Material Design 분석
- **결정**: CSS Grid 기반 그리드 시스템 채택
### popdocs 재정비 배경
- **문제**: 문서 구조가 AI 에이전트 진입점 역할 못함
- **해결**: Progressive Disclosure 적용, 저장/조회 규칙 명시화
- **참고**: 2025-2026 AI 컨텍스트 엔지니어링 최신 기법
### 핵심 결정
- Layer 1 (진입점): README, STATUS, SAVE_RULES
- Layer 2 (상세): CHANGELOG, PROBLEMS, INDEX 등
- Layer 3 (심화): decisions/, sessions/, archive/
---
## 관련 링크
- ADR: [decisions/003-v5-grid-system.md](../decisions/003-v5-grid-system.md)
- CHANGELOG: 오늘 작업 추가 필요
- 삭제된 파일 목록: FILES.md 하단 "삭제된 파일" 섹션
---
## 메모
- POPUPDATE.md (루트)는 별도로 유지 (전체 프로젝트 기록용)
- popdocs/는 POP 디자이너 개발 전용
- rangraph 연동 고려 (장기 기억 검색용)