ERP-node/frontend/components/pop/designer/renderers/PopRenderer.tsx

281 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}