ERP-node/frontend/components/pop/designer/PopCanvas.tsx

972 lines
33 KiB
TypeScript
Raw Normal View History

"use client";
import { useCallback, useRef, useState, useEffect, useMemo } from "react";
import { useDrop } from "react-dnd";
import { cn } from "@/lib/utils";
import {
PopLayoutDataV5,
PopComponentDefinitionV5,
PopComponentType,
PopGridPosition,
GridMode,
GapPreset,
GAP_PRESETS,
GRID_BREAKPOINTS,
DEFAULT_COMPONENT_GRID_SIZE,
} from "./types/pop-layout";
import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff } from "lucide-react";
import { useDrag } from "react-dnd";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { toast } from "sonner";
import PopRenderer from "./renderers/PopRenderer";
import { findNextEmptyPosition, isOverlapping, getAllEffectivePositions, needsReview } from "./utils/gridUtils";
import { DND_ITEM_TYPES } from "./constants";
/**
*
* @param relX X ( )
* @param relY Y ( )
*/
function calcGridPosition(
relX: number,
relY: number,
canvasWidth: number,
columns: number,
rowHeight: number,
gap: number,
padding: number
): { col: number; row: number } {
// 패딩 제외한 좌표
const x = relX - padding;
const y = relY - padding;
// 사용 가능한 너비 (패딩과 gap 제외)
const availableWidth = canvasWidth - padding * 2 - gap * (columns - 1);
const colWidth = availableWidth / columns;
// 셀+gap 단위로 계산
const cellStride = colWidth + gap;
const rowStride = rowHeight + gap;
// 그리드 좌표 (1부터 시작)
const col = Math.max(1, Math.min(columns, Math.floor(x / cellStride) + 1));
const row = Math.max(1, Math.floor(y / rowStride) + 1);
return { col, row };
}
// 드래그 아이템 타입 정의
interface DragItemComponent {
type: typeof DND_ITEM_TYPES.COMPONENT;
componentType: PopComponentType;
}
interface DragItemMoveComponent {
componentId: string;
originalPosition: PopGridPosition;
}
// ========================================
// 프리셋 해상도 (4개 모드) - 너비만 정의
// ========================================
const VIEWPORT_PRESETS = [
{ id: "mobile_portrait", label: "모바일 세로", shortLabel: "모바일↕ (4칸)", width: 375, icon: Smartphone },
{ id: "mobile_landscape", label: "모바일 가로", shortLabel: "모바일↔ (6칸)", width: 600, icon: Smartphone },
{ id: "tablet_portrait", label: "태블릿 세로", shortLabel: "태블릿↕ (8칸)", width: 820, icon: Tablet },
{ id: "tablet_landscape", label: "태블릿 가로", shortLabel: "태블릿↔ (12칸)", width: 1024, icon: Tablet },
] as const;
type ViewportPreset = GridMode;
// 기본 프리셋 (태블릿 가로)
const DEFAULT_PRESET: ViewportPreset = "tablet_landscape";
// 캔버스 세로 자동 확장 설정
const MIN_CANVAS_HEIGHT = 600; // 최소 캔버스 높이 (px)
const CANVAS_EXTRA_ROWS = 3; // 여유 행 수
// ========================================
// Props
// ========================================
interface PopCanvasProps {
layout: PopLayoutDataV5;
selectedComponentId: string | null;
currentMode: GridMode;
onModeChange: (mode: GridMode) => void;
onSelectComponent: (id: string | null) => void;
onDropComponent: (type: PopComponentType, position: PopGridPosition) => void;
onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinitionV5>) => void;
onDeleteComponent: (componentId: string) => void;
onMoveComponent?: (componentId: string, newPosition: PopGridPosition) => void;
onResizeComponent?: (componentId: string, newPosition: PopGridPosition) => void;
onResizeEnd?: (componentId: string) => void;
onHideComponent?: (componentId: string) => void;
onUnhideComponent?: (componentId: string) => void;
onLockLayout?: () => void;
onResetOverride?: (mode: GridMode) => void;
onChangeGapPreset?: (preset: GapPreset) => void;
}
// ========================================
// PopCanvas: 그리드 캔버스
// ========================================
export default function PopCanvas({
layout,
selectedComponentId,
currentMode,
onModeChange,
onSelectComponent,
onDropComponent,
onUpdateComponent,
onDeleteComponent,
onMoveComponent,
onResizeComponent,
onResizeEnd,
onHideComponent,
onUnhideComponent,
onLockLayout,
onResetOverride,
onChangeGapPreset,
}: PopCanvasProps) {
// 줌 상태
const [canvasScale, setCanvasScale] = useState(0.8);
// 커스텀 뷰포트 너비
const [customWidth, setCustomWidth] = useState(1024);
// 그리드 가이드 표시 여부
const [showGridGuide, setShowGridGuide] = useState(true);
// 패닝 상태
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
const [isSpacePressed, setIsSpacePressed] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
// 현재 뷰포트 해상도
const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!;
const breakpoint = GRID_BREAKPOINTS[currentMode];
// Gap 프리셋 적용
const currentGapPreset = layout.settings.gapPreset || "medium";
const gapMultiplier = GAP_PRESETS[currentGapPreset]?.multiplier || 1.0;
const adjustedGap = Math.round(breakpoint.gap * gapMultiplier);
const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier));
// 숨김 컴포넌트 ID 목록
const hiddenComponentIds = layout.overrides?.[currentMode]?.hidden || [];
// 동적 캔버스 높이 계산 (컴포넌트 배치 기반)
const dynamicCanvasHeight = useMemo(() => {
const visibleComps = Object.values(layout.components).filter(
comp => !hiddenComponentIds.includes(comp.id)
);
if (visibleComps.length === 0) return MIN_CANVAS_HEIGHT;
// 최대 row + rowSpan 찾기
const maxRowEnd = visibleComps.reduce((max, comp) => {
const overridePos = layout.overrides?.[currentMode]?.positions?.[comp.id];
const pos = overridePos ? { ...comp.position, ...overridePos } : comp.position;
const rowEnd = pos.row + pos.rowSpan;
return Math.max(max, rowEnd);
}, 1);
// 높이 계산: (행 수 + 여유) * (행높이 + gap) + padding
const totalRows = maxRowEnd + CANVAS_EXTRA_ROWS;
const height = totalRows * (breakpoint.rowHeight + adjustedGap) + adjustedPadding * 2;
return Math.max(MIN_CANVAS_HEIGHT, height);
}, [layout.components, layout.overrides, currentMode, hiddenComponentIds, breakpoint.rowHeight, adjustedGap, adjustedPadding]);
// 그리드 라벨 계산 (동적 행 수)
const gridLabels = useMemo(() => {
const columnLabels = Array.from({ length: breakpoint.columns }, (_, i) => i + 1);
// 동적 행 수 계산
const rowCount = Math.ceil(dynamicCanvasHeight / (breakpoint.rowHeight + adjustedGap));
const rowLabels = Array.from({ length: rowCount }, (_, i) => i + 1);
return { columnLabels, rowLabels };
}, [breakpoint.columns, breakpoint.rowHeight, dynamicCanvasHeight, adjustedGap]);
// 줌 컨트롤
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 = (mode: GridMode) => {
onModeChange(mode);
const presetData = VIEWPORT_PRESETS.find((p) => p.id === mode)!;
setCustomWidth(presetData.width);
// customHeight는 dynamicCanvasHeight로 자동 계산됨
};
// 패닝
const handlePanStart = (e: React.MouseEvent) => {
const isMiddleButton = e.button === 1;
if (isMiddleButton || isSpacePressed) {
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);
// Ctrl + 휠로 줌 조정
const handleWheel = (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 키 감지
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, DND_ITEM_TYPES.MOVE_COMPONENT],
drop: (item: DragItemComponent | DragItemMoveComponent, monitor) => {
if (!canvasRef.current) return;
const canvasRect = canvasRef.current.getBoundingClientRect();
const itemType = monitor.getItemType();
// 팔레트에서 새 컴포넌트 추가 - 마우스 위치 기준
if (itemType === DND_ITEM_TYPES.COMPONENT) {
const offset = monitor.getClientOffset();
if (!offset) return;
// 캔버스 내 상대 좌표 (스케일 보정)
// canvasRect는 scale 적용된 크기이므로, 상대 좌표를 scale로 나눠야 실제 좌표
const relX = (offset.x - canvasRect.left) / canvasScale;
const relY = (offset.y - canvasRect.top) / canvasScale;
// 그리드 좌표 계산
const gridPos = calcGridPosition(
relX,
relY,
customWidth,
breakpoint.columns,
breakpoint.rowHeight,
adjustedGap,
adjustedPadding
);
const dragItem = item as DragItemComponent;
const defaultSize = DEFAULT_COMPONENT_GRID_SIZE[dragItem.componentType];
const candidatePosition: PopGridPosition = {
col: gridPos.col,
row: gridPos.row,
colSpan: defaultSize.colSpan,
rowSpan: defaultSize.rowSpan,
};
// 현재 모드에서의 유효 위치들로 중첩 검사
const effectivePositions = getAllEffectivePositions(layout, currentMode);
const existingPositions = Array.from(effectivePositions.values());
const hasOverlap = existingPositions.some(pos =>
isOverlapping(candidatePosition, pos)
);
let finalPosition: PopGridPosition;
if (hasOverlap) {
finalPosition = findNextEmptyPosition(
existingPositions,
defaultSize.colSpan,
defaultSize.rowSpan,
breakpoint.columns
);
toast.info("겹치는 위치입니다. 빈 위치로 자동 배치됩니다.");
} else {
finalPosition = candidatePosition;
}
onDropComponent(dragItem.componentType, finalPosition);
}
// 기존 컴포넌트 이동 - 마우스 위치 기준
if (itemType === DND_ITEM_TYPES.MOVE_COMPONENT) {
const offset = monitor.getClientOffset();
if (!offset) return;
// 캔버스 내 상대 좌표 (스케일 보정)
const relX = (offset.x - canvasRect.left) / canvasScale;
const relY = (offset.y - canvasRect.top) / canvasScale;
const gridPos = calcGridPosition(
relX,
relY,
customWidth,
breakpoint.columns,
breakpoint.rowHeight,
adjustedGap,
adjustedPadding
);
const dragItem = item as DragItemMoveComponent & { fromHidden?: boolean };
// 현재 모드에서의 유효 위치들 가져오기
const effectivePositions = getAllEffectivePositions(layout, currentMode);
// 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기
// 검토 필요(ReviewPanel에서 클릭)나 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용
const currentEffectivePos = effectivePositions.get(dragItem.componentId);
const componentData = layout.components[dragItem.componentId];
if (!currentEffectivePos && !componentData) return;
const sourcePosition = currentEffectivePos || componentData.position;
// colSpan이 현재 모드의 columns를 초과하면 제한
const adjustedColSpan = Math.min(sourcePosition.colSpan, breakpoint.columns);
// 드롭 위치 + 크기가 범위를 초과하면 드롭 위치를 자동 조정
let adjustedCol = gridPos.col;
if (adjustedCol + adjustedColSpan - 1 > breakpoint.columns) {
adjustedCol = Math.max(1, breakpoint.columns - adjustedColSpan + 1);
}
const newPosition: PopGridPosition = {
col: adjustedCol,
row: gridPos.row,
colSpan: adjustedColSpan,
rowSpan: sourcePosition.rowSpan,
};
// 자기 자신 제외한 다른 컴포넌트들의 유효 위치와 겹침 체크
const hasOverlap = Array.from(effectivePositions.entries()).some(([id, pos]) => {
if (id === dragItem.componentId) return false; // 자기 자신 제외
return isOverlapping(newPosition, pos);
});
if (hasOverlap) {
toast.error("이 위치로 이동할 수 없습니다 (다른 컴포넌트와 겹침)");
return;
}
// 이동 처리 (숨김 컴포넌트의 경우 handleMoveComponent에서 숨김 해제도 함께 처리됨)
onMoveComponent?.(dragItem.componentId, newPosition);
// 숨김 패널에서 드래그한 경우 안내 메시지
if (dragItem.fromHidden) {
toast.info("컴포넌트가 다시 표시됩니다");
}
}
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
}),
[onDropComponent, onMoveComponent, onUnhideComponent, breakpoint, layout, currentMode, canvasScale, customWidth, adjustedGap, adjustedPadding]
);
drop(canvasRef);
// 빈 상태 체크
const isEmpty = Object.keys(layout.components).length === 0;
// 숨김 처리된 컴포넌트 객체 목록 (hiddenComponentIds는 라인 166에서 정의됨)
const hiddenComponents = useMemo(() => {
return hiddenComponentIds
.map(id => layout.components[id])
.filter(Boolean);
}, [hiddenComponentIds, layout.components]);
// 표시되는 컴포넌트 목록 (숨김 제외)
const visibleComponents = useMemo(() => {
return Object.values(layout.components).filter(
comp => !hiddenComponentIds.includes(comp.id)
);
}, [layout.components, hiddenComponentIds]);
// 검토 필요 컴포넌트 목록
const reviewComponents = useMemo(() => {
return visibleComponents.filter(comp => {
const hasOverride = !!layout.overrides?.[currentMode]?.positions?.[comp.id];
return needsReview(currentMode, hasOverride);
});
}, [visibleComponents, layout.overrides, currentMode]);
// 검토 패널 표시 여부 (12칸 모드가 아니고, 검토 필요 컴포넌트가 있을 때)
const showReviewPanel = currentMode !== "tablet_landscape" && reviewComponents.length > 0;
// 12칸 모드가 아닐 때만 패널 표시
// 숨김 패널: 숨김 컴포넌트가 있거나, 그리드에 컴포넌트가 있을 때 드롭 영역으로 표시
const hasGridComponents = Object.keys(layout.components).length > 0;
const showHiddenPanel = currentMode !== "tablet_landscape" && (hiddenComponents.length > 0 || hasGridComponents);
const showRightPanel = showReviewPanel || showHiddenPanel;
return (
<div className="flex h-full flex-col bg-gray-50">
{/* 상단 컨트롤 */}
<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" />
{/* 고정/되돌리기 버튼 (기본 모드 아닐 때만 표시) */}
{currentMode !== DEFAULT_PRESET && (
<>
<Button
variant="outline"
size="sm"
onClick={onLockLayout}
className="h-8 gap-1 text-xs"
>
<Lock className="h-3 w-3" />
</Button>
{layout.overrides?.[currentMode] && (
<Button
variant="ghost"
size="sm"
onClick={() => onResetOverride?.(currentMode)}
className="h-8 gap-1 text-xs"
>
<RotateCcw className="h-3 w-3" />
</Button>
)}
</>
)}
<div className="h-4 w-px bg-gray-300" />
{/* 해상도 표시 */}
<div className="text-xs text-muted-foreground">
{customWidth} × {Math.round(dynamicCanvasHeight)}
</div>
<div className="h-4 w-px bg-gray-300" />
{/* Gap 프리셋 선택 */}
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">:</span>
<Select
value={currentGapPreset}
onValueChange={(value) => onChangeGapPreset?.(value as GapPreset)}
>
<SelectTrigger className="h-8 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(GAP_PRESETS) as GapPreset[]).map((preset) => (
<SelectItem key={preset} value={preset} className="text-xs">
{GAP_PRESETS[preset].label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1" />
{/* 줌 컨트롤 */}
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">
{Math.round(canvasScale * 100)}%
</span>
<Button
variant="ghost"
size="icon"
onClick={handleZoomOut}
disabled={canvasScale <= 0.3}
className="h-7 w-7"
>
<ZoomOut className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleZoomFit}
className="h-7 w-7"
>
<Maximize2 className="h-3 w-3" />
</Button>
<Button
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>
</div>
{/* 캔버스 영역 */}
<div
ref={containerRef}
className={cn(
"canvas-scroll-area relative flex-1 overflow-auto bg-gray-100",
isSpacePressed && "cursor-grab",
isPanning && "cursor-grabbing"
)}
onMouseDown={handlePanStart}
onMouseMove={handlePanMove}
onMouseUp={handlePanEnd}
onMouseLeave={handlePanEnd}
onWheel={handleWheel}
>
<div
className="relative mx-auto my-8 origin-top overflow-visible flex gap-4"
style={{
width: showRightPanel
? `${customWidth + 32 + 220}px` // 오른쪽 패널 공간 추가
: `${customWidth + 32}px`,
minHeight: `${dynamicCanvasHeight + 32}px`,
transform: `scale(${canvasScale})`,
}}
>
{/* 그리드 + 라벨 영역 */}
<div className="relative">
{/* 그리드 라벨 영역 */}
{showGridGuide && (
<>
{/* 열 라벨 (상단) */}
<div
className="flex absolute top-0 left-8"
style={{
gap: `${adjustedGap}px`,
paddingLeft: `${adjustedPadding}px`,
}}
>
{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 - ${adjustedPadding * 2}px - ${adjustedGap * (breakpoint.columns - 1)}px) / ${breakpoint.columns})`,
height: "24px",
}}
>
{num}
</div>
))}
</div>
{/* 행 라벨 (좌측) */}
<div
className="flex flex-col absolute top-8 left-0"
style={{
gap: `${adjustedGap}px`,
paddingTop: `${adjustedPadding}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
ref={canvasRef}
className={cn(
"relative rounded-lg border-2 bg-white shadow-xl overflow-visible",
canDrop && isOver && "ring-4 ring-primary/20"
)}
style={{
width: `${customWidth}px`,
minHeight: `${dynamicCanvasHeight}px`,
marginLeft: "32px",
marginTop: "32px",
}}
>
{isEmpty ? (
// 빈 상태
<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 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)}
onComponentMove={onMoveComponent}
onComponentResize={onResizeComponent}
onComponentResizeEnd={onResizeEnd}
overrideGap={adjustedGap}
overridePadding={adjustedPadding}
/>
)}
</div>
</div>
{/* 오른쪽 패널 영역 (초과 컴포넌트 + 숨김 컴포넌트) */}
{showRightPanel && (
<div
className="flex flex-col gap-3"
style={{ marginTop: "32px" }}
>
{/* 검토 필요 패널 */}
{showReviewPanel && (
<ReviewPanel
components={reviewComponents}
selectedComponentId={selectedComponentId}
onSelectComponent={onSelectComponent}
/>
)}
{/* 숨김 컴포넌트 패널 */}
{showHiddenPanel && (
<HiddenPanel
components={hiddenComponents}
selectedComponentId={selectedComponentId}
onSelectComponent={onSelectComponent}
onHideComponent={onHideComponent}
/>
)}
</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>
);
}
// ========================================
// 검토 필요 영역 (오른쪽 패널)
// ========================================
interface ReviewPanelProps {
components: PopComponentDefinitionV5[];
selectedComponentId: string | null;
onSelectComponent: (id: string | null) => void;
}
function ReviewPanel({
components,
selectedComponentId,
onSelectComponent,
}: ReviewPanelProps) {
return (
<div
className="flex flex-col rounded-lg border-2 border-dashed border-blue-300 bg-blue-50/50"
style={{
width: "200px",
maxHeight: "300px",
}}
>
{/* 헤더 */}
<div className="flex items-center gap-2 border-b border-blue-200 bg-blue-100/50 px-3 py-2 rounded-t-lg">
<AlertTriangle className="h-4 w-4 text-blue-600" />
<span className="text-xs font-semibold text-blue-700">
({components.length})
</span>
</div>
{/* 컴포넌트 목록 */}
<div className="flex-1 overflow-auto p-2 space-y-2">
{components.map((comp) => (
<ReviewItem
key={comp.id}
component={comp}
isSelected={selectedComponentId === comp.id}
onSelect={() => onSelectComponent(comp.id)}
/>
))}
</div>
{/* 안내 문구 */}
<div className="border-t border-blue-200 px-3 py-2 bg-blue-50/80 rounded-b-lg">
<p className="text-[10px] text-blue-600 leading-tight">
.
</p>
</div>
</div>
);
}
// ========================================
// 검토 필요 아이템 (ReviewPanel 내부)
// ========================================
interface ReviewItemProps {
component: PopComponentDefinitionV5;
isSelected: boolean;
onSelect: () => void;
}
function ReviewItem({
component,
isSelected,
onSelect,
}: ReviewItemProps) {
return (
<div
className={cn(
"flex flex-col gap-1 rounded-md border-2 p-2 cursor-pointer transition-all",
isSelected
? "border-blue-500 bg-blue-100 shadow-sm"
: "border-blue-200 bg-white hover:border-blue-400 hover:bg-blue-50"
)}
onClick={(e) => {
e.stopPropagation();
onSelect();
}}
>
<span className="text-xs font-medium text-blue-800 line-clamp-1">
{component.label || component.id}
</span>
<span className="text-[10px] text-blue-600 bg-blue-50 rounded px-1.5 py-0.5 self-start">
</span>
</div>
);
}
// ========================================
// 숨김 컴포넌트 영역 (오른쪽 패널)
// ========================================
interface HiddenPanelProps {
components: PopComponentDefinitionV5[];
selectedComponentId: string | null;
onSelectComponent: (id: string | null) => void;
onHideComponent?: (componentId: string) => void;
}
function HiddenPanel({
components,
selectedComponentId,
onSelectComponent,
onHideComponent,
}: HiddenPanelProps) {
// 그리드에서 컴포넌트를 드래그하여 이 패널에 드롭하면 숨김 처리
const [{ isOver, canDrop }, drop] = useDrop(
() => ({
accept: DND_ITEM_TYPES.MOVE_COMPONENT,
drop: (item: { componentId: string; fromHidden?: boolean }) => {
// 이미 숨김 패널에서 온 아이템은 무시
if (item.fromHidden) return;
// 숨김 처리
onHideComponent?.(item.componentId);
toast.info("컴포넌트가 숨김 처리되었습니다");
},
canDrop: (item: { componentId: string; fromHidden?: boolean }) => {
// 숨김 패널에서 온 아이템은 드롭 불가
return !item.fromHidden;
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
}),
[onHideComponent]
);
return (
<div
ref={drop}
className={cn(
"flex flex-col rounded-lg border-2 border-dashed bg-gray-100/50 transition-colors",
isOver && canDrop
? "border-gray-600 bg-gray-200/70"
: "border-gray-400"
)}
style={{
width: "200px",
maxHeight: "300px",
}}
>
{/* 헤더 */}
<div className="flex items-center gap-2 border-b border-gray-300 bg-gray-200/50 px-3 py-2 rounded-t-lg">
<EyeOff className="h-4 w-4 text-gray-600" />
<span className="text-xs font-semibold text-gray-700">
({components.length})
</span>
</div>
{/* 컴포넌트 목록 */}
<div className="flex-1 overflow-auto p-2 space-y-2">
{components.map((comp) => (
<HiddenItem
key={comp.id}
component={comp}
isSelected={selectedComponentId === comp.id}
onSelect={() => onSelectComponent(comp.id)}
/>
))}
</div>
{/* 안내 문구 */}
<div className="border-t border-gray-300 px-3 py-2 bg-gray-100/80 rounded-b-lg">
<p className="text-[10px] text-gray-600 leading-tight">
</p>
</div>
</div>
);
}
// ========================================
// 숨김 컴포넌트 아이템 (드래그 가능)
// ========================================
interface HiddenItemProps {
component: PopComponentDefinitionV5;
isSelected: boolean;
onSelect: () => void;
}
function HiddenItem({
component,
isSelected,
onSelect,
}: HiddenItemProps) {
const [{ isDragging }, drag] = useDrag(
() => ({
type: DND_ITEM_TYPES.MOVE_COMPONENT,
item: {
componentId: component.id,
originalPosition: component.position,
fromHidden: true, // 숨김 패널에서 왔음을 표시
},
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}),
[component.id, component.position]
);
return (
<div
ref={drag}
className={cn(
"rounded-md border-2 bg-white p-2 cursor-move transition-all opacity-60",
isSelected
? "border-primary ring-2 ring-primary/30"
: "border-gray-400 hover:border-gray-500",
isDragging && "opacity-30"
)}
onClick={onSelect}
>
{/* 컴포넌트 이름 */}
<div className="flex items-center gap-1 text-xs font-medium text-gray-600 truncate">
<EyeOff className="h-3 w-3" />
{component.label || component.type}
</div>
{/* 원본 위치 정보 */}
<div className="text-[10px] text-gray-500 mt-1">
: {component.position.col}, {component.position.row}
</div>
</div>
);
}