659 lines
21 KiB
TypeScript
659 lines
21 KiB
TypeScript
"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";
|
|
|
|
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<ScreenDefinition>) => void;
|
|
}
|
|
|
|
// ========================================
|
|
// 메인 컴포넌트 (v5 그리드 시스템 전용)
|
|
// ========================================
|
|
export default function PopDesigner({
|
|
selectedScreen,
|
|
onBackToList,
|
|
onScreenUpdate,
|
|
}: PopDesignerProps) {
|
|
// ========================================
|
|
// 레이아웃 상태
|
|
// ========================================
|
|
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
|
|
|
|
// 히스토리
|
|
const [history, setHistory] = useState<PopLayoutDataV5[]>([]);
|
|
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<string | null>(null);
|
|
|
|
// 그리드 모드 (4개 프리셋)
|
|
const [currentMode, setCurrentMode] = useState<GridMode>("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<PopComponentDefinitionV5>) => {
|
|
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<string, PopGridPosition> = {};
|
|
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 (
|
|
<div className="flex h-screen items-center justify-center">
|
|
<div className="text-muted-foreground">로딩 중...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// 렌더링
|
|
// ========================================
|
|
return (
|
|
<DndProvider backend={HTML5Backend}>
|
|
<div className="flex h-screen flex-col">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between border-b bg-white px-4 py-2">
|
|
{/* 왼쪽: 뒤로가기 + 화면명 */}
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleBack}
|
|
className="h-8 w-8"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</Button>
|
|
<div>
|
|
<h2 className="text-sm font-medium">{selectedScreen?.screenName}</h2>
|
|
<p className="text-xs text-muted-foreground">
|
|
그리드 레이아웃 (v5)
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 오른쪽: Undo/Redo + 저장 */}
|
|
<div className="flex items-center gap-2">
|
|
{/* Undo/Redo 버튼 */}
|
|
<div className="flex items-center gap-1 border-r pr-2 mr-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={undo}
|
|
disabled={!canUndo}
|
|
title="실행 취소 (Ctrl+Z)"
|
|
>
|
|
<Undo2 className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={redo}
|
|
disabled={!canRedo}
|
|
title="다시 실행 (Ctrl+Shift+Z)"
|
|
>
|
|
<Redo2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 저장 버튼 */}
|
|
<Button size="sm" onClick={handleSave} disabled={isSaving || !hasChanges}>
|
|
<Save className="mr-1 h-4 w-4" />
|
|
{isSaving ? "저장 중..." : "저장"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 메인 영역 */}
|
|
<ResizablePanelGroup direction="horizontal" className="flex-1">
|
|
{/* 왼쪽: 컴포넌트 팔레트 */}
|
|
<ResizablePanel defaultSize={15} minSize={12} maxSize={20}>
|
|
<ComponentPalette />
|
|
</ResizablePanel>
|
|
|
|
<ResizableHandle withHandle />
|
|
|
|
{/* 중앙: 캔버스 */}
|
|
<ResizablePanel defaultSize={65}>
|
|
<PopCanvas
|
|
layout={layout}
|
|
selectedComponentId={selectedComponentId}
|
|
currentMode={currentMode}
|
|
onModeChange={setCurrentMode}
|
|
onSelectComponent={setSelectedComponentId}
|
|
onDropComponent={handleDropComponent}
|
|
onUpdateComponent={handleUpdateComponent}
|
|
onDeleteComponent={handleDeleteComponent}
|
|
onMoveComponent={handleMoveComponent}
|
|
onResizeComponent={handleResizeComponent}
|
|
onResizeEnd={handleResizeEnd}
|
|
onHideComponent={handleHideComponent}
|
|
onUnhideComponent={handleUnhideComponent}
|
|
onLockLayout={handleLockLayout}
|
|
onResetOverride={handleResetOverride}
|
|
onChangeGapPreset={handleChangeGapPreset}
|
|
/>
|
|
</ResizablePanel>
|
|
|
|
<ResizableHandle withHandle />
|
|
|
|
{/* 오른쪽: 속성 패널 */}
|
|
<ResizablePanel defaultSize={20} minSize={15} maxSize={30}>
|
|
<ComponentEditorPanel
|
|
component={selectedComponent}
|
|
currentMode={currentMode}
|
|
onUpdateComponent={
|
|
selectedComponentId
|
|
? (updates) => handleUpdateComponent(selectedComponentId, updates)
|
|
: undefined
|
|
}
|
|
/>
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
</div>
|
|
</DndProvider>
|
|
);
|
|
}
|