"use client"; import { useState, useCallback, useEffect } from "react"; import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; import { ArrowLeft, Save, Smartphone, Tablet, Undo2, Redo2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable"; import { toast } from "sonner"; import { PopCanvas } from "./PopCanvas"; import { PopCanvasV4 } from "./PopCanvasV4"; import { PopPanel } from "./panels/PopPanel"; import { ComponentPaletteV4 } from "./panels/ComponentPaletteV4"; import { ComponentEditorPanelV4 } from "./panels/ComponentEditorPanelV4"; import { PopLayoutDataV3, PopLayoutDataV4, PopLayoutModeKey, PopComponentType, GridPosition, PopComponentDefinition, PopComponentDefinitionV4, PopContainerV4, PopSizeConstraintV4, createEmptyPopLayoutV3, createEmptyPopLayoutV4, ensureV3Layout, addComponentToV3Layout, removeComponentFromV3Layout, updateComponentPositionInModeV3, addComponentToV4Layout, removeComponentFromV4Layout, updateComponentInV4Layout, updateContainerV4, findContainerV4, isV3Layout, isV4Layout, } from "./types/pop-layout"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; // ======================================== // 레이아웃 모드 타입 // ======================================== type LayoutMode = "v3" | "v4"; type DeviceType = "mobile" | "tablet"; // ======================================== // Props // ======================================== interface PopDesignerProps { selectedScreen: ScreenDefinition; onBackToList: () => void; onScreenUpdate?: (updatedScreen: Partial) => void; } // ======================================== // 메인 컴포넌트 (v3/v4 통합) // - 새 화면: v4로 시작 // - 기존 v3 화면: v3로 로드 (하위 호환) // ======================================== export default function PopDesigner({ selectedScreen, onBackToList, onScreenUpdate, }: PopDesignerProps) { // ======================================== // 레이아웃 모드 (데이터에 따라 자동 결정) // ======================================== const [layoutMode, setLayoutMode] = useState("v4"); // ======================================== // 레이아웃 상태 (데스크탑 모드와 동일한 방식) // ======================================== const [layoutV4, setLayoutV4] = useState(createEmptyPopLayoutV4()); const [layoutV3, setLayoutV3] = useState(createEmptyPopLayoutV3()); // 히스토리 (v4용) const [historyV4, setHistoryV4] = useState([]); const [historyIndexV4, setHistoryIndexV4] = useState(-1); // 히스토리 (v3용) const [historyV3, setHistoryV3] = useState([]); const [historyIndexV3, setHistoryIndexV3] = useState(-1); const [idCounter, setIdCounter] = useState(1); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [hasChanges, setHasChanges] = useState(false); // ======================================== // 히스토리 저장 함수 // ======================================== 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("tablet"); const [activeModeKey, setActiveModeKey] = useState("tablet_landscape"); // ======================================== // v4용 뷰포트 모드 상태 // ======================================== type ViewportMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape"; const [currentViewportMode, setCurrentViewportMode] = useState("tablet_landscape"); // v4: 임시 레이아웃 (고정 전 배치) - 다른 모드에서만 사용 const [tempLayout, setTempLayout] = useState(null); // ======================================== // 선택 상태 // ======================================== const [selectedComponentId, setSelectedComponentId] = useState(null); const [selectedContainerId, setSelectedContainerId] = useState(null); // 선택된 컴포넌트/컨테이너 const selectedComponentV3: PopComponentDefinition | null = selectedComponentId ? layoutV3.components[selectedComponentId] || null : null; const selectedComponentV4: PopComponentDefinitionV4 | null = selectedComponentId ? layoutV4.components[selectedComponentId] || null : null; const selectedContainer: PopContainerV4 | null = selectedContainerId ? findContainerV4(layoutV4.root, selectedContainerId) : null; // ======================================== // 레이아웃 로드 // ======================================== useEffect(() => { const loadLayout = async () => { if (!selectedScreen?.screenId) return; setIsLoading(true); try { const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId); // 유효한 레이아웃인지 확인: // 1. version 필드 필수 // 2. 컴포넌트가 있어야 함 (빈 레이아웃은 새 화면 취급) const hasValidLayout = loadedLayout && loadedLayout.version; const hasComponents = loadedLayout?.components && Object.keys(loadedLayout.components).length > 0; 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 { // v1/v2/v3 → v3로 변환 const v3Layout = ensureV3Layout(loadedLayout); setLayoutV3(v3Layout); setHistoryV3([v3Layout]); setHistoryIndexV3(0); setLayoutMode("v3"); console.log(`POP v3 레이아웃 로드: ${Object.keys(v3Layout.components).length}개 컴포넌트`); } } else { // 새 화면 또는 빈 레이아웃 → v4로 시작 const emptyLayout = createEmptyPopLayoutV4(); setLayoutV4(emptyLayout); setHistoryV4([emptyLayout]); setHistoryIndexV4(0); setLayoutMode("v4"); } } catch (error) { console.error("레이아웃 로드 실패:", error); toast.error("레이아웃을 불러오는데 실패했습니다"); const emptyLayout = createEmptyPopLayoutV4(); setLayoutV4(emptyLayout); setHistoryV4([emptyLayout]); setHistoryIndexV4(0); setLayoutMode("v4"); } finally { setIsLoading(false); } }; loadLayout(); }, [selectedScreen?.screenId]); // ======================================== // 저장 // ======================================== const handleSave = useCallback(async () => { if (!selectedScreen?.screenId) return; setIsSaving(true); try { const layoutToSave = layoutMode === "v3" ? layoutV3 : layoutV4; await screenApi.saveLayoutPop(selectedScreen.screenId, layoutToSave); toast.success("저장되었습니다"); setHasChanges(false); } catch (error) { console.error("저장 실패:", error); toast.error("저장에 실패했습니다"); } finally { setIsSaving(false); } }, [selectedScreen?.screenId, layoutMode, layoutV3, layoutV4]); // ======================================== // v3: 컴포넌트 핸들러 // ======================================== const handleDropComponentV3 = useCallback( (type: PopComponentType, gridPosition: GridPosition) => { 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) => { 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}`; setIdCounter((prev) => prev + 1); const newLayout = addComponentToV4Layout(layoutV4, componentId, type, containerId, `${type} ${idCounter}`); setLayoutV4(newLayout); saveToHistoryV4(newLayout); setSelectedComponentId(componentId); setSelectedContainerId(null); setHasChanges(true); console.log("[V4] 컴포넌트 추가, 히스토리 저장됨"); }, [idCounter, layoutV4, saveToHistoryV4] ); const handleUpdateComponentV4 = useCallback( (componentId: string, updates: Partial) => { const newLayout = updateComponentInV4Layout(layoutV4, componentId, updates); setLayoutV4(newLayout); saveToHistoryV4(newLayout); setHasChanges(true); }, [layoutV4, saveToHistoryV4] ); const handleUpdateContainerV4 = useCallback( (containerId: string, updates: Partial) => { if (currentViewportMode === "tablet_landscape") { // 기본 모드 (태블릿 가로) → 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) => { const existingComponent = layoutV4.components[componentId]; if (!existingComponent) return; const newLayout = { ...layoutV4, components: { ...layoutV4.components, [componentId]: { ...existingComponent, size: { ...existingComponent.size, ...sizeUpdates, }, }, }, }; setLayoutV4(newLayout); // 리사이즈 중에는 히스토리 저장 안 함 (너무 많아짐) // saveToHistoryV4(newLayout); setHasChanges(true); }, [layoutV4] ); // v4: 컴포넌트 순서 변경 (드래그 앤 드롭) const handleReorderComponentV4 = useCallback( (containerId: string, fromIndex: number, toIndex: number) => { // 컨테이너 찾기 (재귀) 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 = { ...layoutV4, root: reorderInContainer(layoutV4.root), }; setLayoutV4(newLayout); saveToHistoryV4(newLayout); setHasChanges(true); console.log("[기본 모드] 컴포넌트 순서 변경 (root 저장)"); } else { // 다른 모드 → 임시 레이아웃에만 저장 (화면에만 표시, layoutV4는 안 건드림) 🔥 const reorderedRoot = reorderInContainer(layoutV4.root); setTempLayout(reorderedRoot); console.log(`[${currentViewportMode}] 컴포넌트 순서 변경 (임시, 고정 필요)`); toast.info("배치 변경됨. '고정' 버튼을 클릭하여 저장하세요", { duration: 2000 }); } }, [layoutV4, currentViewportMode, saveToHistoryV4] ); // ======================================== // 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(() => { if (hasChanges) { if (confirm("저장하지 않은 변경사항이 있습니다. 나가시겠습니까?")) { onBackToList(); } } else { onBackToList(); } }, [hasChanges, onBackToList]); // ======================================== // 단축키 처리 (Delete, Undo, Redo) // ======================================== 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) { layoutMode === "v3" ? handleDeleteComponentV3(selectedComponentId) : handleDeleteComponentV4(selectedComponentId); } } // Ctrl+Z / Cmd+Z: Undo (Shift 안 눌림) if (isCtrlOrCmd && key === "z" && !e.shiftKey) { e.preventDefault(); console.log("Undo 시도:", { canUndo, layoutMode }); if (canUndo) { handleUndo(); setHasChanges(true); toast.success("실행 취소됨"); } else { toast.info("실행 취소할 내용이 없습니다"); } return; } // Ctrl+Shift+Z / Cmd+Shift+Z: Redo if (isCtrlOrCmd && key === "z" && e.shiftKey) { e.preventDefault(); console.log("Redo 시도:", { canRedo, layoutMode }); if (canRedo) { handleRedo(); setHasChanges(true); toast.success("다시 실행됨"); } else { toast.info("다시 실행할 내용이 없습니다"); } return; } // Ctrl+Y / Cmd+Y: Redo (대체) if (isCtrlOrCmd && key === "y") { e.preventDefault(); if (canRedo) { handleRedo(); setHasChanges(true); toast.success("다시 실행됨"); } return; } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [selectedComponentId, layoutMode, handleDeleteComponentV3, handleDeleteComponentV4, canUndo, canRedo, handleUndo, handleRedo]); // ======================================== // 로딩 // ======================================== if (isLoading) { return (
로딩 중...
); } // ======================================== // 렌더링 // ======================================== return (
{/* 툴바 */}
{/* 왼쪽: 뒤로가기 + 화면명 */}
{selectedScreen?.screenName || "POP 화면"} {hasChanges && *변경됨}
{/* 중앙: 레이아웃 버전 + v3 디바이스 전환 */}
{layoutMode === "v4" ? "자동 레이아웃 (v4)" : "4모드 레이아웃 (v3)"} {layoutMode === "v3" && ( handleDeviceChange(v as DeviceType)}> 태블릿 모바일 )}
{/* 오른쪽: Undo/Redo + 저장 */}
{/* Undo/Redo 버튼 */}
{/* 저장 버튼 */}
{/* 메인 영역 */} {/* 왼쪽: 컴포넌트 패널 */} {layoutMode === "v3" ? ( ) : ( )} {/* 중앙: 캔버스 */} {layoutMode === "v3" ? ( ) : ( )} {/* 오른쪽: 속성 패널 (v4만) */} {layoutMode === "v4" && ( <> handleUpdateComponentV4(selectedComponentId, updates) : undefined } onUpdateContainer={ selectedContainerId ? (updates) => handleUpdateContainerV4(selectedContainerId, updates) : undefined } /> )}
); }