281 lines
8.2 KiB
TypeScript
281 lines
8.2 KiB
TypeScript
"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>
|
||
);
|
||
}
|