레이아웃 추가기능

This commit is contained in:
kjs
2025-09-10 18:36:28 +09:00
parent f7aa71ec30
commit 083f053851
69 changed files with 10218 additions and 3 deletions

View File

@@ -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"

View File

@@ -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="컴포넌트"

View 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>
);
}