2026-02-02 15:15:01 +09:00
|
|
|
"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, Columns2, RotateCcw } 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 { PopPanel } from "./panels/PopPanel";
|
|
|
|
|
import {
|
|
|
|
|
PopLayoutData,
|
|
|
|
|
PopSectionData,
|
|
|
|
|
PopComponentData,
|
|
|
|
|
PopComponentType,
|
|
|
|
|
createEmptyPopLayout,
|
|
|
|
|
createPopSection,
|
|
|
|
|
createPopComponent,
|
|
|
|
|
GridPosition,
|
|
|
|
|
} from "./types/pop-layout";
|
|
|
|
|
import { screenApi } from "@/lib/api/screen";
|
|
|
|
|
import { ScreenDefinition } from "@/types/screen";
|
|
|
|
|
|
|
|
|
|
// 디바이스 타입
|
|
|
|
|
type DeviceType = "mobile" | "tablet";
|
|
|
|
|
|
|
|
|
|
interface PopDesignerProps {
|
|
|
|
|
selectedScreen: ScreenDefinition;
|
|
|
|
|
onBackToList: () => void;
|
|
|
|
|
onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function PopDesigner({
|
|
|
|
|
selectedScreen,
|
|
|
|
|
onBackToList,
|
|
|
|
|
onScreenUpdate,
|
|
|
|
|
}: PopDesignerProps) {
|
|
|
|
|
// 레이아웃 상태
|
|
|
|
|
const [layout, setLayout] = useState<PopLayoutData>(createEmptyPopLayout());
|
|
|
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
|
const [hasChanges, setHasChanges] = useState(false);
|
|
|
|
|
|
|
|
|
|
// 디바이스 프리뷰 상태
|
|
|
|
|
const [activeDevice, setActiveDevice] = useState<DeviceType>("tablet");
|
|
|
|
|
const [showBothDevices, setShowBothDevices] = useState(false);
|
|
|
|
|
const [isLandscape, setIsLandscape] = useState(true);
|
|
|
|
|
|
|
|
|
|
// 선택된 섹션/컴포넌트
|
|
|
|
|
const [selectedSectionId, setSelectedSectionId] = useState<string | null>(null);
|
|
|
|
|
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
// 선택된 섹션 객체
|
|
|
|
|
const selectedSection = selectedSectionId
|
|
|
|
|
? layout.sections.find((s) => s.id === selectedSectionId) || null
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
// 레이아웃 로드
|
2026-02-02 18:01:05 +09:00
|
|
|
// API는 이미 언래핑된 layout_data를 반환하므로 response 자체가 레이아웃 데이터
|
2026-02-02 15:15:01 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
const loadLayout = async () => {
|
|
|
|
|
if (!selectedScreen?.screenId) return;
|
|
|
|
|
|
|
|
|
|
setIsLoading(true);
|
|
|
|
|
try {
|
2026-02-02 18:01:05 +09:00
|
|
|
// API가 layout_data 내용을 직접 반환함 (언래핑된 상태)
|
|
|
|
|
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
|
2026-02-02 15:15:01 +09:00
|
|
|
|
2026-02-02 18:01:05 +09:00
|
|
|
if (loadedLayout && loadedLayout.version === "pop-1.0") {
|
|
|
|
|
// 유효한 POP 레이아웃
|
|
|
|
|
setLayout(loadedLayout as PopLayoutData);
|
|
|
|
|
console.log("POP 레이아웃 로드 성공:", loadedLayout.sections?.length || 0, "개 섹션");
|
|
|
|
|
} else if (loadedLayout && loadedLayout.sections) {
|
|
|
|
|
// 버전 태그 없지만 sections 구조가 있으면 사용
|
|
|
|
|
console.warn("버전 태그 없음, sections 구조 감지하여 사용");
|
|
|
|
|
setLayout({
|
|
|
|
|
...createEmptyPopLayout(),
|
|
|
|
|
...loadedLayout,
|
|
|
|
|
version: "pop-1.0",
|
|
|
|
|
} as PopLayoutData);
|
2026-02-02 15:15:01 +09:00
|
|
|
} else {
|
2026-02-02 18:01:05 +09:00
|
|
|
// 레이아웃 없음 - 빈 레이아웃 생성
|
|
|
|
|
console.log("POP 레이아웃 없음, 빈 레이아웃 생성");
|
2026-02-02 15:15:01 +09:00
|
|
|
setLayout(createEmptyPopLayout());
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("레이아웃 로드 실패:", error);
|
|
|
|
|
toast.error("레이아웃을 불러오는데 실패했습니다");
|
|
|
|
|
setLayout(createEmptyPopLayout());
|
|
|
|
|
} 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 handleDropSection = useCallback((gridPosition: GridPosition) => {
|
|
|
|
|
const newId = `section-${Date.now()}`;
|
|
|
|
|
const newSection = createPopSection(newId, gridPosition);
|
|
|
|
|
|
|
|
|
|
setLayout((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
sections: [...prev.sections, newSection],
|
|
|
|
|
}));
|
|
|
|
|
setSelectedSectionId(newId);
|
|
|
|
|
setHasChanges(true);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 컴포넌트 드롭 (팔레트 → 섹션)
|
|
|
|
|
const handleDropComponent = useCallback(
|
|
|
|
|
(sectionId: string, type: PopComponentType, gridPosition: GridPosition) => {
|
|
|
|
|
const newId = `${type}-${Date.now()}`;
|
|
|
|
|
const newComponent = createPopComponent(newId, type, gridPosition);
|
|
|
|
|
|
|
|
|
|
setLayout((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
sections: prev.sections.map((s) =>
|
|
|
|
|
s.id === sectionId
|
|
|
|
|
? { ...s, components: [...s.components, newComponent] }
|
|
|
|
|
: s
|
|
|
|
|
),
|
|
|
|
|
}));
|
|
|
|
|
setSelectedComponentId(newId);
|
|
|
|
|
setHasChanges(true);
|
|
|
|
|
},
|
|
|
|
|
[]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 섹션 업데이트
|
|
|
|
|
const handleUpdateSection = useCallback(
|
|
|
|
|
(id: string, updates: Partial<PopSectionData>) => {
|
|
|
|
|
setLayout((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
sections: prev.sections.map((s) =>
|
|
|
|
|
s.id === id ? { ...s, ...updates } : s
|
|
|
|
|
),
|
|
|
|
|
}));
|
|
|
|
|
setHasChanges(true);
|
|
|
|
|
},
|
|
|
|
|
[]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 섹션 삭제
|
|
|
|
|
const handleDeleteSection = useCallback((id: string) => {
|
|
|
|
|
setLayout((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
sections: prev.sections.filter((s) => s.id !== id),
|
|
|
|
|
}));
|
|
|
|
|
setSelectedSectionId(null);
|
|
|
|
|
setHasChanges(true);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 레이아웃 변경 (드래그/리사이즈)
|
|
|
|
|
const handleLayoutChange = useCallback((sections: PopSectionData[]) => {
|
|
|
|
|
setLayout((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
sections,
|
|
|
|
|
}));
|
|
|
|
|
setHasChanges(true);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 컴포넌트 업데이트
|
|
|
|
|
const handleUpdateComponent = useCallback(
|
|
|
|
|
(sectionId: string, componentId: string, updates: Partial<PopComponentData>) => {
|
|
|
|
|
setLayout((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
sections: prev.sections.map((s) =>
|
|
|
|
|
s.id === sectionId
|
|
|
|
|
? {
|
|
|
|
|
...s,
|
|
|
|
|
components: s.components.map((c) =>
|
|
|
|
|
c.id === componentId ? { ...c, ...updates } : c
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
: s
|
|
|
|
|
),
|
|
|
|
|
}));
|
|
|
|
|
setHasChanges(true);
|
|
|
|
|
},
|
|
|
|
|
[]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 컴포넌트 삭제
|
|
|
|
|
const handleDeleteComponent = useCallback(
|
|
|
|
|
(sectionId: string, componentId: string) => {
|
|
|
|
|
setLayout((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
sections: prev.sections.map((s) =>
|
|
|
|
|
s.id === sectionId
|
|
|
|
|
? { ...s, components: s.components.filter((c) => c.id !== componentId) }
|
|
|
|
|
: s
|
|
|
|
|
),
|
|
|
|
|
}));
|
|
|
|
|
setSelectedComponentId(null);
|
|
|
|
|
setHasChanges(true);
|
|
|
|
|
},
|
|
|
|
|
[]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 뒤로가기
|
|
|
|
|
const handleBack = useCallback(() => {
|
|
|
|
|
if (hasChanges) {
|
|
|
|
|
if (confirm("저장하지 않은 변경사항이 있습니다. 나가시겠습니까?")) {
|
|
|
|
|
onBackToList();
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
onBackToList();
|
|
|
|
|
}
|
|
|
|
|
}, [hasChanges, onBackToList]);
|
|
|
|
|
|
|
|
|
|
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 bg-background">
|
|
|
|
|
{/* 툴바 */}
|
|
|
|
|
<div className="flex h-12 items-center justify-between border-b px-4">
|
|
|
|
|
{/* 왼쪽: 뒤로가기 + 화면명 */}
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<Button variant="ghost" size="sm" onClick={handleBack}>
|
|
|
|
|
<ArrowLeft className="mr-1 h-4 w-4" />
|
|
|
|
|
목록
|
|
|
|
|
</Button>
|
|
|
|
|
<span className="text-sm font-medium">
|
|
|
|
|
{selectedScreen?.screenName || "POP 화면"}
|
|
|
|
|
</span>
|
|
|
|
|
{hasChanges && (
|
|
|
|
|
<span className="text-xs text-orange-500">*변경됨</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 중앙: 디바이스 전환 */}
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Tabs
|
|
|
|
|
value={activeDevice}
|
|
|
|
|
onValueChange={(v) => setActiveDevice(v as DeviceType)}
|
|
|
|
|
>
|
|
|
|
|
<TabsList className="h-8">
|
|
|
|
|
<TabsTrigger value="tablet" className="h-7 px-3 text-xs">
|
|
|
|
|
<Tablet className="mr-1 h-3.5 w-3.5" />
|
|
|
|
|
태블릿
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
<TabsTrigger value="mobile" className="h-7 px-3 text-xs">
|
|
|
|
|
<Smartphone className="mr-1 h-3.5 w-3.5" />
|
|
|
|
|
모바일
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
</TabsList>
|
|
|
|
|
</Tabs>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setIsLandscape(!isLandscape)}
|
|
|
|
|
title={isLandscape ? "세로 모드로 전환" : "가로 모드로 전환"}
|
|
|
|
|
>
|
|
|
|
|
<RotateCcw className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant={showBothDevices ? "secondary" : "ghost"}
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setShowBothDevices(!showBothDevices)}
|
|
|
|
|
title="나란히 보기"
|
|
|
|
|
>
|
|
|
|
|
<Columns2 className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 오른쪽: 저장 */}
|
|
|
|
|
<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={20}
|
|
|
|
|
minSize={15}
|
|
|
|
|
maxSize={30}
|
|
|
|
|
className="border-r"
|
|
|
|
|
>
|
|
|
|
|
<PopPanel
|
|
|
|
|
layout={layout}
|
|
|
|
|
selectedSectionId={selectedSectionId}
|
|
|
|
|
selectedSection={selectedSection}
|
|
|
|
|
onUpdateSection={handleUpdateSection}
|
|
|
|
|
onDeleteSection={handleDeleteSection}
|
|
|
|
|
activeDevice={activeDevice}
|
|
|
|
|
/>
|
|
|
|
|
</ResizablePanel>
|
|
|
|
|
|
|
|
|
|
<ResizableHandle withHandle />
|
|
|
|
|
|
|
|
|
|
{/* 오른쪽: 캔버스 */}
|
|
|
|
|
<ResizablePanel defaultSize={80}>
|
|
|
|
|
<PopCanvas
|
|
|
|
|
layout={layout}
|
|
|
|
|
activeDevice={activeDevice}
|
|
|
|
|
showBothDevices={showBothDevices}
|
|
|
|
|
isLandscape={isLandscape}
|
|
|
|
|
selectedSectionId={selectedSectionId}
|
|
|
|
|
selectedComponentId={selectedComponentId}
|
|
|
|
|
onSelectSection={setSelectedSectionId}
|
|
|
|
|
onSelectComponent={setSelectedComponentId}
|
|
|
|
|
onUpdateSection={handleUpdateSection}
|
|
|
|
|
onDeleteSection={handleDeleteSection}
|
|
|
|
|
onLayoutChange={handleLayoutChange}
|
|
|
|
|
onDropSection={handleDropSection}
|
|
|
|
|
onDropComponent={handleDropComponent}
|
|
|
|
|
onUpdateComponent={handleUpdateComponent}
|
|
|
|
|
onDeleteComponent={handleDeleteComponent}
|
|
|
|
|
/>
|
|
|
|
|
</ResizablePanel>
|
|
|
|
|
</ResizablePanelGroup>
|
|
|
|
|
</div>
|
|
|
|
|
</DndProvider>
|
|
|
|
|
);
|
|
|
|
|
}
|