레이아웃 추가기능
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
Cog,
|
||||
Layout,
|
||||
Monitor,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -33,6 +34,8 @@ interface DesignerToolbarProps {
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
isSaving?: boolean;
|
||||
showZoneBorders?: boolean;
|
||||
onToggleZoneBorders?: () => void;
|
||||
}
|
||||
|
||||
export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
||||
@@ -48,6 +51,8 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
||||
canUndo,
|
||||
canRedo,
|
||||
isSaving = false,
|
||||
showZoneBorders = true,
|
||||
onToggleZoneBorders,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-gray-200 bg-white px-4 py-3 shadow-sm">
|
||||
@@ -154,6 +159,23 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
||||
</Badge>
|
||||
</Button>
|
||||
|
||||
{/* 구역 경계 표시 토글 버튼 */}
|
||||
{onToggleZoneBorders && (
|
||||
<Button
|
||||
variant={showZoneBorders ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={onToggleZoneBorders}
|
||||
className={cn("flex items-center space-x-2", showZoneBorders && "bg-green-600 text-white")}
|
||||
title="구역 경계 표시/숨김"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
<span>구역</span>
|
||||
<Badge variant="secondary" className="ml-1 text-xs">
|
||||
Z
|
||||
</Badge>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant={panelStates.detailSettings?.isOpen ? "default" : "outline"}
|
||||
size="sm"
|
||||
|
||||
@@ -46,12 +46,16 @@ import DesignerToolbar from "./DesignerToolbar";
|
||||
import TablesPanel from "./panels/TablesPanel";
|
||||
import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
|
||||
import ComponentsPanel from "./panels/ComponentsPanel";
|
||||
import LayoutsPanel from "./panels/LayoutsPanel";
|
||||
import PropertiesPanel from "./panels/PropertiesPanel";
|
||||
import DetailSettingsPanel from "./panels/DetailSettingsPanel";
|
||||
import GridPanel from "./panels/GridPanel";
|
||||
import ResolutionPanel from "./panels/ResolutionPanel";
|
||||
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
|
||||
|
||||
// 레이아웃 초기화
|
||||
import "@/lib/registry/layouts";
|
||||
|
||||
interface ScreenDesignerProps {
|
||||
selectedScreen: ScreenDefinition | null;
|
||||
onBackToList: () => void;
|
||||
@@ -75,6 +79,14 @@ const panelConfigs: PanelConfig[] = [
|
||||
defaultHeight: 700,
|
||||
shortcutKey: "m", // template의 m
|
||||
},
|
||||
{
|
||||
id: "layouts",
|
||||
title: "레이아웃",
|
||||
defaultPosition: "left",
|
||||
defaultWidth: 380,
|
||||
defaultHeight: 700,
|
||||
shortcutKey: "l", // layout의 l
|
||||
},
|
||||
{
|
||||
id: "properties",
|
||||
title: "속성 편집",
|
||||
@@ -1212,6 +1224,74 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory, openPanel],
|
||||
);
|
||||
|
||||
// 레이아웃 드래그 처리
|
||||
const handleLayoutDrop = useCallback(
|
||||
(e: React.DragEvent, layoutData: any) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const dropX = e.clientX - rect.left;
|
||||
const dropY = e.clientY - rect.top;
|
||||
|
||||
// 현재 해상도에 맞는 격자 정보 계산
|
||||
const currentGridInfo = layout.gridSettings
|
||||
? calculateGridInfo(screenResolution.width, screenResolution.height, {
|
||||
columns: layout.gridSettings.columns,
|
||||
gap: layout.gridSettings.gap,
|
||||
padding: layout.gridSettings.padding,
|
||||
snapToGrid: layout.gridSettings.snapToGrid || false,
|
||||
})
|
||||
: null;
|
||||
|
||||
// 격자 스냅 적용
|
||||
const snappedPosition =
|
||||
layout.gridSettings?.snapToGrid && currentGridInfo
|
||||
? snapToGrid({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
|
||||
: { x: dropX, y: dropY, z: 1 };
|
||||
|
||||
console.log("🏗️ 레이아웃 드롭:", {
|
||||
layoutType: layoutData.layoutType,
|
||||
zonesCount: layoutData.zones.length,
|
||||
dropPosition: { x: dropX, y: dropY },
|
||||
snappedPosition,
|
||||
});
|
||||
|
||||
// 레이아웃 컴포넌트 생성
|
||||
const newLayoutComponent: ComponentData = {
|
||||
id: layoutData.id,
|
||||
type: "layout",
|
||||
layoutType: layoutData.layoutType,
|
||||
layoutConfig: layoutData.layoutConfig,
|
||||
zones: layoutData.zones.map((zone: any) => ({
|
||||
...zone,
|
||||
id: `${layoutData.id}_${zone.id}`, // 레이아웃 ID를 접두사로 추가
|
||||
})),
|
||||
children: [],
|
||||
position: snappedPosition,
|
||||
size: layoutData.size,
|
||||
label: layoutData.label,
|
||||
allowedComponentTypes: layoutData.allowedComponentTypes,
|
||||
dropZoneConfig: layoutData.dropZoneConfig,
|
||||
} as ComponentData;
|
||||
|
||||
// 레이아웃에 새 컴포넌트 추가
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: [...layout.components, newLayoutComponent],
|
||||
};
|
||||
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
|
||||
// 레이아웃 컴포넌트 선택
|
||||
setSelectedComponent(newLayoutComponent);
|
||||
openPanel("properties");
|
||||
|
||||
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
|
||||
},
|
||||
[layout, gridInfo, screenResolution, snapToGrid, saveToHistory, openPanel],
|
||||
);
|
||||
|
||||
// 컴포넌트 드래그 처리
|
||||
const handleComponentDrop = useCallback(
|
||||
(e: React.DragEvent, component: any) => {
|
||||
@@ -1357,6 +1437,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
return;
|
||||
}
|
||||
|
||||
// 레이아웃 드래그인 경우
|
||||
if (parsedData.type === "layout") {
|
||||
handleLayoutDrop(e, parsedData.layout);
|
||||
return;
|
||||
}
|
||||
|
||||
// 컴포넌트 드래그인 경우
|
||||
if (parsedData.type === "component") {
|
||||
handleComponentDrop(e, parsedData.component);
|
||||
@@ -3129,6 +3215,27 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
/>
|
||||
</FloatingPanel>
|
||||
|
||||
<FloatingPanel
|
||||
id="layouts"
|
||||
title="레이아웃"
|
||||
isOpen={panelStates.layouts?.isOpen || false}
|
||||
onClose={() => closePanelState("layouts")}
|
||||
position="left"
|
||||
width={380}
|
||||
height={700}
|
||||
autoHeight={false}
|
||||
>
|
||||
<LayoutsPanel
|
||||
onDragStart={(e, layoutData) => {
|
||||
const dragData = {
|
||||
type: "layout",
|
||||
layout: layoutData,
|
||||
};
|
||||
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
|
||||
}}
|
||||
/>
|
||||
</FloatingPanel>
|
||||
|
||||
<FloatingPanel
|
||||
id="components"
|
||||
title="컴포넌트"
|
||||
|
||||
195
frontend/components/screen/panels/LayoutsPanel.tsx
Normal file
195
frontend/components/screen/panels/LayoutsPanel.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Grid, Layout, LayoutDashboard, Table, Navigation, FileText, Building, Search, Plus } from "lucide-react";
|
||||
import { LAYOUT_CATEGORIES, LayoutCategory } from "@/types/layout";
|
||||
import { LayoutRegistry } from "@/lib/registry/LayoutRegistry";
|
||||
|
||||
// 카테고리 아이콘 매핑
|
||||
const CATEGORY_ICONS = {
|
||||
basic: Grid,
|
||||
form: FileText,
|
||||
table: Table,
|
||||
dashboard: LayoutDashboard,
|
||||
navigation: Navigation,
|
||||
content: Layout,
|
||||
business: Building,
|
||||
};
|
||||
|
||||
// 카테고리 이름 매핑
|
||||
const CATEGORY_NAMES = {
|
||||
basic: "기본",
|
||||
form: "폼",
|
||||
table: "테이블",
|
||||
dashboard: "대시보드",
|
||||
navigation: "네비게이션",
|
||||
content: "컨텐츠",
|
||||
business: "업무용",
|
||||
};
|
||||
|
||||
interface LayoutsPanelProps {
|
||||
onDragStart: (e: React.DragEvent, layoutData: any) => void;
|
||||
onLayoutSelect?: (layoutDefinition: any) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function LayoutsPanel({ onDragStart, onLayoutSelect, className }: LayoutsPanelProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
||||
|
||||
// 레지스트리에서 레이아웃 조회
|
||||
const allLayouts = useMemo(() => LayoutRegistry.getAllLayouts(), []);
|
||||
|
||||
// 필터링된 레이아웃
|
||||
const filteredLayouts = useMemo(() => {
|
||||
let layouts = allLayouts;
|
||||
|
||||
// 카테고리 필터
|
||||
if (selectedCategory !== "all") {
|
||||
layouts = layouts.filter((layout) => layout.category === selectedCategory);
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchTerm) {
|
||||
layouts = layouts.filter(
|
||||
(layout) =>
|
||||
layout.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
layout.nameEng?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
layout.description?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
return layouts;
|
||||
}, [allLayouts, selectedCategory, searchTerm]);
|
||||
|
||||
// 카테고리별 개수
|
||||
const categoryCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
Object.values(LAYOUT_CATEGORIES).forEach((category) => {
|
||||
counts[category] = allLayouts.filter((layout) => layout.category === category).length;
|
||||
});
|
||||
return counts;
|
||||
}, [allLayouts]);
|
||||
|
||||
// 레이아웃 드래그 시작 핸들러
|
||||
const handleDragStart = (e: React.DragEvent, layoutDefinition: any) => {
|
||||
// 새 레이아웃 컴포넌트 데이터 생성
|
||||
const layoutData = {
|
||||
id: `layout_${Date.now()}`,
|
||||
type: "layout",
|
||||
layoutType: layoutDefinition.id,
|
||||
layoutConfig: layoutDefinition.defaultConfig,
|
||||
zones: layoutDefinition.defaultZones,
|
||||
children: [],
|
||||
allowedComponentTypes: [],
|
||||
position: { x: 0, y: 0 },
|
||||
size: layoutDefinition.defaultSize || { width: 400, height: 300 },
|
||||
label: layoutDefinition.name,
|
||||
};
|
||||
|
||||
// 드래그 데이터 설정
|
||||
e.dataTransfer.setData("application/json", JSON.stringify(layoutData));
|
||||
e.dataTransfer.setData("text/plain", layoutDefinition.name);
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
|
||||
onDragStart(e, layoutData);
|
||||
};
|
||||
|
||||
// 레이아웃 선택 핸들러
|
||||
const handleLayoutSelect = (layoutDefinition: any) => {
|
||||
onLayoutSelect?.(layoutDefinition);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`layouts-panel h-full ${className || ""}`}>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">레이아웃</h3>
|
||||
<Button size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
|
||||
<Input
|
||||
placeholder="레이아웃 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 탭 */}
|
||||
<Tabs value={selectedCategory} onValueChange={setSelectedCategory} className="flex-1">
|
||||
<TabsList className="grid w-full grid-cols-4 px-4 pt-2">
|
||||
<TabsTrigger value="all" className="text-xs">
|
||||
전체 ({allLayouts.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="basic" className="text-xs">
|
||||
기본 ({categoryCounts.basic || 0})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="form" className="text-xs">
|
||||
폼 ({categoryCounts.form || 0})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="navigation" className="text-xs">
|
||||
탭 ({categoryCounts.navigation || 0})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 레이아웃 목록 */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{filteredLayouts.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center text-center text-sm text-gray-500">
|
||||
{searchTerm ? "검색 결과가 없습니다." : "레이아웃이 없습니다."}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredLayouts.map((layout) => {
|
||||
const CategoryIcon = CATEGORY_ICONS[layout.category as keyof typeof CATEGORY_ICONS];
|
||||
return (
|
||||
<Card
|
||||
key={layout.id}
|
||||
className="cursor-move transition-shadow hover:shadow-md"
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, layout)}
|
||||
onClick={() => handleLayoutSelect(layout)}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CategoryIcon className="h-4 w-4 text-gray-600" />
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{CATEGORY_NAMES[layout.category as keyof typeof CATEGORY_NAMES]}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-sm">{layout.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{layout.description && (
|
||||
<p className="line-clamp-2 text-xs text-gray-600">{layout.description}</p>
|
||||
)}
|
||||
<div className="mt-2 text-xs text-gray-500">존 개수: {layout.defaultZones.length}개</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user