"use client"; import { useState, useCallback, useEffect } from "react"; import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; import { ArrowLeft, Save, Undo2, Redo2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable"; import { toast } from "sonner"; // POP 컴포넌트 자동 등록 (반드시 다른 import보다 먼저) import "@/lib/registry/pop-components"; import PopCanvas from "./PopCanvas"; import ComponentEditorPanel from "./panels/ComponentEditorPanel"; import ComponentPalette from "./panels/ComponentPalette"; import { PopLayoutDataV5, PopComponentType, PopComponentDefinitionV5, PopGridPosition, GridMode, GapPreset, createEmptyPopLayoutV5, isV5Layout, addComponentToV5Layout, GRID_BREAKPOINTS, } from "./types/pop-layout"; import { getAllEffectivePositions } from "./utils/gridUtils"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; // ======================================== // Props // ======================================== interface PopDesignerProps { selectedScreen: ScreenDefinition; onBackToList: () => void; onScreenUpdate?: (updatedScreen: Partial) => void; } // ======================================== // 메인 컴포넌트 (v5 그리드 시스템 전용) // ======================================== export default function PopDesigner({ selectedScreen, onBackToList, onScreenUpdate, }: PopDesignerProps) { // ======================================== // 레이아웃 상태 // ======================================== const [layout, setLayout] = useState(createEmptyPopLayoutV5()); // 히스토리 const [history, setHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(-1); // UI 상태 const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [hasChanges, setHasChanges] = useState(false); const [idCounter, setIdCounter] = useState(1); // 선택 상태 const [selectedComponentId, setSelectedComponentId] = useState(null); // 그리드 모드 (4개 프리셋) const [currentMode, setCurrentMode] = useState("tablet_landscape"); // 선택된 컴포넌트 const selectedComponent: PopComponentDefinitionV5 | null = selectedComponentId ? layout.components[selectedComponentId] || 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; // ======================================== // 레이아웃 로드 // ======================================== useEffect(() => { const loadLayout = async () => { if (!selectedScreen?.screenId) return; setIsLoading(true); try { const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId); if (loadedLayout && isV5Layout(loadedLayout) && Object.keys(loadedLayout.components).length > 0) { // v5 레이아웃 로드 // 기존 레이아웃 호환성: gapPreset이 없으면 기본값 추가 if (!loadedLayout.settings.gapPreset) { loadedLayout.settings.gapPreset = "medium"; } setLayout(loadedLayout); setHistory([loadedLayout]); setHistoryIndex(0); // 기존 컴포넌트 ID에서 최대 숫자 추출하여 idCounter 설정 (중복 방지) const existingIds = Object.keys(loadedLayout.components); const maxId = existingIds.reduce((max, id) => { const match = id.match(/comp_(\d+)/); if (match) { const num = parseInt(match[1], 10); return num > max ? num : max; } return max; }, 0); setIdCounter(maxId + 1); console.log(`POP 레이아웃 로드: ${existingIds.length}개 컴포넌트, idCounter: ${maxId + 1}`); } else { // 새 화면 또는 빈 레이아웃 const emptyLayout = createEmptyPopLayoutV5(); setLayout(emptyLayout); setHistory([emptyLayout]); setHistoryIndex(0); console.log("새 POP 화면 생성 (v5 그리드)"); } } catch (error) { console.error("레이아웃 로드 실패:", error); toast.error("레이아웃을 불러오는데 실패했습니다"); const emptyLayout = createEmptyPopLayoutV5(); setLayout(emptyLayout); setHistory([emptyLayout]); setHistoryIndex(0); } finally { setIsLoading(false); } }; loadLayout(); }, [selectedScreen?.screenId]); // ======================================== // 저장 // ======================================== const handleSave = useCallback(async () => { if (!selectedScreen?.screenId) return; setIsSaving(true); try { await screenApi.saveLayoutPop(selectedScreen.screenId, layout); toast.success("저장되었습니다"); setHasChanges(false); } catch (error) { console.error("저장 실패:", error); toast.error("저장에 실패했습니다"); } finally { setIsSaving(false); } }, [selectedScreen?.screenId, layout]); // ======================================== // 컴포넌트 핸들러 // ======================================== const handleDropComponent = useCallback( (type: PopComponentType, position: PopGridPosition) => { const componentId = `comp_${idCounter}`; setIdCounter((prev) => prev + 1); const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`); setLayout(newLayout); saveToHistory(newLayout); setSelectedComponentId(componentId); setHasChanges(true); }, [idCounter, layout, saveToHistory] ); const handleUpdateComponent = useCallback( (componentId: string, updates: Partial) => { const existingComponent = layout.components[componentId]; if (!existingComponent) return; const newLayout = { ...layout, components: { ...layout.components, [componentId]: { ...existingComponent, ...updates, }, }, }; setLayout(newLayout); saveToHistory(newLayout); setHasChanges(true); }, [layout, saveToHistory] ); const handleDeleteComponent = useCallback( (componentId: string) => { const newComponents = { ...layout.components }; delete newComponents[componentId]; const newLayout = { ...layout, components: newComponents, }; setLayout(newLayout); saveToHistory(newLayout); setSelectedComponentId(null); setHasChanges(true); }, [layout, saveToHistory] ); const handleMoveComponent = useCallback( (componentId: string, newPosition: PopGridPosition) => { const component = layout.components[componentId]; if (!component) return; // 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정 if (currentMode === "tablet_landscape") { const newLayout = { ...layout, components: { ...layout.components, [componentId]: { ...component, position: newPosition, }, }, }; setLayout(newLayout); saveToHistory(newLayout); setHasChanges(true); } else { // 다른 모드인 경우: 오버라이드에 저장 // 숨김 상태였던 컴포넌트를 이동하면 숨김 해제도 함께 처리 const currentHidden = layout.overrides?.[currentMode]?.hidden || []; const isHidden = currentHidden.includes(componentId); const newHidden = isHidden ? currentHidden.filter(id => id !== componentId) : currentHidden; const newLayout = { ...layout, overrides: { ...layout.overrides, [currentMode]: { ...layout.overrides?.[currentMode], positions: { ...layout.overrides?.[currentMode]?.positions, [componentId]: newPosition, }, // 숨김 배열 업데이트 (빈 배열이면 undefined로) hidden: newHidden.length > 0 ? newHidden : undefined, }, }, }; setLayout(newLayout); saveToHistory(newLayout); setHasChanges(true); } }, [layout, saveToHistory, currentMode] ); const handleResizeComponent = useCallback( (componentId: string, newPosition: PopGridPosition) => { const component = layout.components[componentId]; if (!component) return; // 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정 if (currentMode === "tablet_landscape") { const newLayout = { ...layout, components: { ...layout.components, [componentId]: { ...component, position: newPosition, }, }, }; setLayout(newLayout); // 리사이즈는 드래그 중 계속 호출되므로 히스토리는 마우스업 시에만 저장 // 현재는 간단히 매번 저장 (최적화 가능) setHasChanges(true); } else { // 다른 모드인 경우: 오버라이드에 저장 const newLayout = { ...layout, overrides: { ...layout.overrides, [currentMode]: { ...layout.overrides?.[currentMode], positions: { ...layout.overrides?.[currentMode]?.positions, [componentId]: newPosition, }, }, }, }; setLayout(newLayout); setHasChanges(true); } }, [layout, currentMode] ); const handleResizeEnd = useCallback( (componentId: string) => { // 리사이즈 완료 시 현재 레이아웃을 히스토리에 저장 saveToHistory(layout); }, [layout, saveToHistory] ); // ======================================== // Gap 프리셋 관리 // ======================================== const handleChangeGapPreset = useCallback((preset: GapPreset) => { const newLayout = { ...layout, settings: { ...layout.settings, gapPreset: preset, }, }; setLayout(newLayout); saveToHistory(newLayout); setHasChanges(true); }, [layout, saveToHistory]); // ======================================== // 모드별 오버라이드 관리 // ======================================== const handleLockLayout = useCallback(() => { // 현재 화면에 보이는 유효 위치들을 저장 (오버라이드 또는 자동 재배치 위치) const effectivePositions = getAllEffectivePositions(layout, currentMode); const positionsToSave: Record = {}; effectivePositions.forEach((position, componentId) => { positionsToSave[componentId] = position; }); const newLayout = { ...layout, overrides: { ...layout.overrides, [currentMode]: { ...layout.overrides?.[currentMode], positions: positionsToSave, }, }, }; setLayout(newLayout); saveToHistory(newLayout); setHasChanges(true); toast.success("현재 배치가 고정되었습니다"); }, [layout, currentMode, saveToHistory]); const handleResetOverride = useCallback((mode: GridMode) => { const newOverrides = { ...layout.overrides }; delete newOverrides[mode]; const newLayout = { ...layout, overrides: Object.keys(newOverrides).length > 0 ? newOverrides : undefined, }; setLayout(newLayout); saveToHistory(newLayout); setHasChanges(true); toast.success("자동 배치로 되돌렸습니다"); }, [layout, saveToHistory]); // ======================================== // 숨김 관리 // ======================================== const handleHideComponent = useCallback((componentId: string) => { // 12칸 모드에서는 숨기기 불가 if (currentMode === "tablet_landscape") return; const currentHidden = layout.overrides?.[currentMode]?.hidden || []; // 이미 숨겨져 있으면 무시 if (currentHidden.includes(componentId)) return; const newHidden = [...currentHidden, componentId]; const newLayout = { ...layout, overrides: { ...layout.overrides, [currentMode]: { ...layout.overrides?.[currentMode], hidden: newHidden, }, }, }; setLayout(newLayout); saveToHistory(newLayout); setHasChanges(true); setSelectedComponentId(null); }, [layout, currentMode, saveToHistory]); const handleUnhideComponent = useCallback((componentId: string) => { const currentHidden = layout.overrides?.[currentMode]?.hidden || []; // 숨겨져 있지 않으면 무시 if (!currentHidden.includes(componentId)) return; const newHidden = currentHidden.filter(id => id !== componentId); const newLayout = { ...layout, overrides: { ...layout.overrides, [currentMode]: { ...layout.overrides?.[currentMode], hidden: newHidden.length > 0 ? newHidden : undefined, }, }, }; setLayout(newLayout); saveToHistory(newLayout); setHasChanges(true); }, [layout, currentMode, saveToHistory]); // ======================================== // 뒤로가기 // ======================================== const handleBack = useCallback(() => { if (hasChanges) { if (confirm("저장하지 않은 변경사항이 있습니다. 정말 나가시겠습니까?")) { onBackToList(); } } else { onBackToList(); } }, [hasChanges, onBackToList]); // ======================================== // 단축키 처리 // ======================================== useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement; if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) { return; } const key = e.key.toLowerCase(); const isCtrlOrCmd = e.ctrlKey || e.metaKey; // Delete / Backspace: 컴포넌트 삭제 if (e.key === "Delete" || e.key === "Backspace") { e.preventDefault(); if (selectedComponentId) { handleDeleteComponent(selectedComponentId); } } // Ctrl+Z: Undo if (isCtrlOrCmd && key === "z" && !e.shiftKey) { e.preventDefault(); if (canUndo) undo(); return; } // Ctrl+Shift+Z or Ctrl+Y: Redo if ((isCtrlOrCmd && key === "z" && e.shiftKey) || (isCtrlOrCmd && key === "y")) { e.preventDefault(); if (canRedo) redo(); return; } // Ctrl+S: 저장 if (isCtrlOrCmd && key === "s") { e.preventDefault(); handleSave(); return; } // H키: 선택된 컴포넌트 숨김 (12칸 모드가 아닐 때만) if (key === "h" && !isCtrlOrCmd && selectedComponentId) { e.preventDefault(); handleHideComponent(selectedComponentId); return; } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [selectedComponentId, handleDeleteComponent, handleHideComponent, canUndo, canRedo, undo, redo, handleSave]); // ======================================== // 로딩 // ======================================== if (isLoading) { return (
로딩 중...
); } // ======================================== // 렌더링 // ======================================== return (
{/* 헤더 */}
{/* 왼쪽: 뒤로가기 + 화면명 */}

{selectedScreen?.screenName}

그리드 레이아웃 (v5)

{/* 오른쪽: Undo/Redo + 저장 */}
{/* Undo/Redo 버튼 */}
{/* 저장 버튼 */}
{/* 메인 영역 */} {/* 왼쪽: 컴포넌트 팔레트 */} {/* 중앙: 캔버스 */} {/* 오른쪽: 속성 패널 */} handleUpdateComponent(selectedComponentId, updates) : undefined } />
); }