메뉴들 플로팅 패널로 구현
This commit is contained in:
parent
c3213b8a85
commit
9af3cdea01
|
|
@ -3,7 +3,7 @@
|
|||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Plus, ArrowLeft, ArrowRight, CheckCircle, Circle } from "lucide-react";
|
||||
import { Plus, ArrowLeft, ArrowRight, Circle } from "lucide-react";
|
||||
import ScreenList from "@/components/screen/ScreenList";
|
||||
import ScreenDesigner from "@/components/screen/ScreenDesigner";
|
||||
import TemplateManager from "@/components/screen/TemplateManager";
|
||||
|
|
@ -62,69 +62,11 @@ export default function ScreenManagementPage() {
|
|||
}
|
||||
};
|
||||
|
||||
// 단계별 진행 상태 확인
|
||||
const isStepCompleted = (step: Step) => {
|
||||
return stepHistory.includes(step);
|
||||
};
|
||||
|
||||
// 현재 단계가 마지막 단계인지 확인
|
||||
const isLastStep = currentStep === "template";
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">화면관리 시스템</h1>
|
||||
<p className="mt-2 text-gray-600">단계별로 화면을 관리하고 설계하세요</p>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">{stepConfig[currentStep].description}</div>
|
||||
</div>
|
||||
|
||||
{/* 단계별 진행 표시 */}
|
||||
<div className="border-b bg-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{Object.entries(stepConfig).map(([step, config], index) => (
|
||||
<div key={step} className="flex items-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<button
|
||||
onClick={() => goToStep(step as Step)}
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 transition-all ${
|
||||
currentStep === step
|
||||
? "border-blue-600 bg-blue-600 text-white"
|
||||
: isStepCompleted(step as Step)
|
||||
? "border-green-500 bg-green-500 text-white"
|
||||
: "border-gray-300 bg-white text-gray-400"
|
||||
} ${isStepCompleted(step as Step) ? "cursor-pointer hover:bg-green-600" : ""}`}
|
||||
>
|
||||
{isStepCompleted(step as Step) && currentStep !== step ? (
|
||||
<CheckCircle className="h-6 w-6" />
|
||||
) : (
|
||||
<span className="text-lg">{config.icon}</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="mt-2 text-center">
|
||||
<div
|
||||
className={`text-sm font-medium ${
|
||||
currentStep === step
|
||||
? "text-blue-600"
|
||||
: isStepCompleted(step as Step)
|
||||
? "text-green-600"
|
||||
: "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{config.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{index < Object.keys(stepConfig).length - 1 && (
|
||||
<div className={`mx-4 h-0.5 w-16 ${isStepCompleted(step as Step) ? "bg-green-500" : "bg-gray-300"}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 단계별 내용 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{/* 화면 목록 단계 */}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Menu, Database, Settings, Palette, Grid3X3, Save, Undo, Redo, Play, ArrowLeft } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DesignerToolbarProps {
|
||||
screenName?: string;
|
||||
tableName?: string;
|
||||
onBack: () => void;
|
||||
onSave: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onPreview: () => void;
|
||||
onTogglePanel: (panelId: string) => void;
|
||||
panelStates: Record<string, { isOpen: boolean }>;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
isSaving?: boolean;
|
||||
}
|
||||
|
||||
export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
||||
screenName,
|
||||
tableName,
|
||||
onBack,
|
||||
onSave,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPreview,
|
||||
onTogglePanel,
|
||||
panelStates,
|
||||
canUndo,
|
||||
canRedo,
|
||||
isSaving = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-gray-200 bg-white px-4 py-3 shadow-sm">
|
||||
{/* 좌측: 네비게이션 및 화면 정보 */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="sm" onClick={onBack} className="flex items-center space-x-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span>목록으로</span>
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-300" />
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Menu className="h-5 w-5 text-gray-600" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">{screenName || "화면 설계"}</h1>
|
||||
{tableName && (
|
||||
<div className="mt-0.5 flex items-center space-x-1">
|
||||
<Database className="h-3 w-3 text-gray-500" />
|
||||
<span className="font-mono text-xs text-gray-500">{tableName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 중앙: 패널 토글 버튼들 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant={panelStates.tables?.isOpen ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onTogglePanel("tables")}
|
||||
className={cn("flex items-center space-x-2", panelStates.tables?.isOpen && "bg-blue-600 text-white")}
|
||||
>
|
||||
<Database className="h-4 w-4" />
|
||||
<span>테이블</span>
|
||||
<Badge variant="secondary" className="ml-1 text-xs">
|
||||
T
|
||||
</Badge>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={panelStates.properties?.isOpen ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onTogglePanel("properties")}
|
||||
className={cn("flex items-center space-x-2", panelStates.properties?.isOpen && "bg-blue-600 text-white")}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>속성</span>
|
||||
<Badge variant="secondary" className="ml-1 text-xs">
|
||||
P
|
||||
</Badge>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={panelStates.styles?.isOpen ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onTogglePanel("styles")}
|
||||
className={cn("flex items-center space-x-2", panelStates.styles?.isOpen && "bg-blue-600 text-white")}
|
||||
>
|
||||
<Palette className="h-4 w-4" />
|
||||
<span>스타일</span>
|
||||
<Badge variant="secondary" className="ml-1 text-xs">
|
||||
S
|
||||
</Badge>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={panelStates.grid?.isOpen ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onTogglePanel("grid")}
|
||||
className={cn("flex items-center space-x-2", panelStates.grid?.isOpen && "bg-blue-600 text-white")}
|
||||
>
|
||||
<Grid3X3 className="h-4 w-4" />
|
||||
<span>격자</span>
|
||||
<Badge variant="secondary" className="ml-1 text-xs">
|
||||
G
|
||||
</Badge>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 우측: 액션 버튼들 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
<Undo className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">실행취소</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
<Redo className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">다시실행</span>
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-300" />
|
||||
|
||||
<Button variant="outline" size="sm" onClick={onPreview} className="flex items-center space-x-2">
|
||||
<Play className="h-4 w-4" />
|
||||
<span>미리보기</span>
|
||||
</Button>
|
||||
|
||||
<Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2">
|
||||
<Save className="h-4 w-4" />
|
||||
<span>{isSaving ? "저장 중..." : "저장"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DesignerToolbar;
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { X, GripVertical } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FloatingPanelProps {
|
||||
id: string;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
position?: "left" | "right" | "top" | "bottom";
|
||||
width?: number;
|
||||
height?: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
resizable?: boolean;
|
||||
draggable?: boolean;
|
||||
autoHeight?: boolean; // 자동 높이 조정 옵션
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
||||
id,
|
||||
title,
|
||||
children,
|
||||
isOpen,
|
||||
onClose,
|
||||
position = "right",
|
||||
width = 320,
|
||||
height = 400,
|
||||
minWidth = 280,
|
||||
minHeight = 300,
|
||||
maxWidth = 600,
|
||||
maxHeight = 800,
|
||||
resizable = true,
|
||||
draggable = true,
|
||||
autoHeight = false, // 자동 높이 조정 비활성화 (수동 크기 조절만 지원)
|
||||
className,
|
||||
}) => {
|
||||
const [panelSize, setPanelSize] = useState({ width, height });
|
||||
const [panelPosition, setPanelPosition] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const dragHandleRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 초기 위치 설정 (패널이 처음 열릴 때만)
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !hasInitialized && panelRef.current) {
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let initialX = 0;
|
||||
let initialY = 0;
|
||||
|
||||
switch (position) {
|
||||
case "left":
|
||||
initialX = 20;
|
||||
initialY = 80;
|
||||
break;
|
||||
case "right":
|
||||
initialX = viewportWidth - panelSize.width - 20;
|
||||
initialY = 80;
|
||||
break;
|
||||
case "top":
|
||||
initialX = (viewportWidth - panelSize.width) / 2;
|
||||
initialY = 20;
|
||||
break;
|
||||
case "bottom":
|
||||
initialX = (viewportWidth - panelSize.width) / 2;
|
||||
initialY = viewportHeight - panelSize.height - 20;
|
||||
break;
|
||||
}
|
||||
|
||||
setPanelPosition({ x: initialX, y: initialY });
|
||||
setHasInitialized(true);
|
||||
}
|
||||
|
||||
// 패널이 닫힐 때 초기화 상태 리셋
|
||||
if (!isOpen) {
|
||||
setHasInitialized(false);
|
||||
}
|
||||
}, [isOpen, position, hasInitialized]);
|
||||
|
||||
// 자동 높이 조정 기능 제거됨 - 수동 크기 조절만 지원
|
||||
|
||||
// 드래그 시작 - 성능 최적화
|
||||
const handleDragStart = (e: React.MouseEvent) => {
|
||||
if (!draggable) return;
|
||||
|
||||
e.preventDefault(); // 기본 동작 방지로 딜레이 제거
|
||||
e.stopPropagation(); // 이벤트 버블링 방지
|
||||
|
||||
setIsDragging(true);
|
||||
const rect = panelRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
setDragOffset({
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 리사이즈 시작
|
||||
const handleResizeStart = (e: React.MouseEvent) => {
|
||||
if (!resizable) return;
|
||||
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
};
|
||||
|
||||
// 마우스 이동 처리 - 초고속 최적화
|
||||
useEffect(() => {
|
||||
if (!isDragging && !isResizing) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
// 직접 DOM 조작으로 최고 성능
|
||||
if (panelRef.current) {
|
||||
const newX = e.clientX - dragOffset.x;
|
||||
const newY = e.clientY - dragOffset.y;
|
||||
|
||||
panelRef.current.style.left = `${newX}px`;
|
||||
panelRef.current.style.top = `${newY}px`;
|
||||
|
||||
// 상태는 throttle로 업데이트
|
||||
setPanelPosition({ x: newX, y: newY });
|
||||
}
|
||||
} else if (isResizing) {
|
||||
const newWidth = Math.max(minWidth, Math.min(maxWidth, e.clientX - panelPosition.x));
|
||||
const newHeight = Math.max(minHeight, Math.min(maxHeight, e.clientY - panelPosition.y));
|
||||
|
||||
if (panelRef.current) {
|
||||
panelRef.current.style.width = `${newWidth}px`;
|
||||
panelRef.current.style.height = `${newHeight}px`;
|
||||
}
|
||||
|
||||
setPanelSize({ width: newWidth, height: newHeight });
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
// 고성능 이벤트 리스너
|
||||
document.addEventListener("mousemove", handleMouseMove, {
|
||||
passive: true,
|
||||
capture: false,
|
||||
});
|
||||
document.addEventListener("mouseup", handleMouseUp, {
|
||||
passive: true,
|
||||
capture: false,
|
||||
});
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isDragging, isResizing, dragOffset.x, dragOffset.y, panelPosition.x, minWidth, maxWidth, minHeight, maxHeight]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={cn(
|
||||
"fixed z-50 rounded-lg border border-gray-200 bg-white shadow-lg",
|
||||
isDragging ? "cursor-move shadow-2xl" : "transition-all duration-200 ease-in-out",
|
||||
isResizing && "cursor-se-resize",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
left: `${panelPosition.x}px`,
|
||||
top: `${panelPosition.y}px`,
|
||||
width: `${panelSize.width}px`,
|
||||
height: `${panelSize.height}px`,
|
||||
transform: isDragging ? "scale(1.01)" : "scale(1)",
|
||||
transition: isDragging ? "none" : "transform 0.1s ease-out, box-shadow 0.1s ease-out",
|
||||
zIndex: isDragging ? 9999 : 50, // 드래그 중 최상위 표시
|
||||
}}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
ref={dragHandleRef}
|
||||
data-header="true"
|
||||
className="flex cursor-move items-center justify-between rounded-t-lg border-b border-gray-200 bg-gray-50 p-3"
|
||||
onMouseDown={handleDragStart}
|
||||
style={{
|
||||
userSelect: "none", // 텍스트 선택 방지
|
||||
WebkitUserSelect: "none",
|
||||
MozUserSelect: "none",
|
||||
msUserSelect: "none",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<GripVertical className="h-4 w-4 text-gray-400" />
|
||||
<h3 className="text-sm font-medium text-gray-900">{title}</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded p-1 transition-colors hover:bg-gray-200">
|
||||
<X className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="flex-1 overflow-auto"
|
||||
style={{
|
||||
maxHeight: `${panelSize.height - 60}px`, // 헤더 높이 제외
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* 리사이즈 핸들 */}
|
||||
{resizable && (
|
||||
<div className="absolute right-0 bottom-0 h-4 w-4 cursor-se-resize" onMouseDown={handleResizeStart}>
|
||||
<div className="absolute right-1 bottom-1 h-2 w-2 rounded-sm bg-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingPanel;
|
||||
|
|
@ -252,6 +252,7 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
|
|||
top: `${component.position.y}px`,
|
||||
width: `${size.width}px`, // 격자 기반 계산 제거
|
||||
height: `${size.height}px`,
|
||||
zIndex: component.position.z || 1, // z-index 적용
|
||||
...selectionStyle,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,667 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Group, Database, Trash2, Copy, Clipboard } from "lucide-react";
|
||||
import {
|
||||
ScreenDefinition,
|
||||
ComponentData,
|
||||
LayoutData,
|
||||
GroupState,
|
||||
WebType,
|
||||
TableInfo,
|
||||
GroupComponent,
|
||||
Position,
|
||||
} from "@/types/screen";
|
||||
import { generateComponentId } from "@/lib/utils/generateId";
|
||||
import {
|
||||
createGroupComponent,
|
||||
calculateBoundingBox,
|
||||
calculateRelativePositions,
|
||||
restoreAbsolutePositions,
|
||||
getGroupChildren,
|
||||
} from "@/lib/utils/groupingUtils";
|
||||
import {
|
||||
calculateGridInfo,
|
||||
snapToGrid,
|
||||
snapSizeToGrid,
|
||||
generateGridLines,
|
||||
GridSettings as GridUtilSettings,
|
||||
} from "@/lib/utils/gridUtils";
|
||||
import { GroupingToolbar } from "./GroupingToolbar";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import StyleEditor from "./StyleEditor";
|
||||
import { RealtimePreview } from "./RealtimePreview";
|
||||
import FloatingPanel from "./FloatingPanel";
|
||||
import DesignerToolbar from "./DesignerToolbar";
|
||||
import TablesPanel from "./panels/TablesPanel";
|
||||
import PropertiesPanel from "./panels/PropertiesPanel";
|
||||
import GridPanel from "./panels/GridPanel";
|
||||
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
|
||||
|
||||
interface ScreenDesignerProps {
|
||||
selectedScreen: ScreenDefinition | null;
|
||||
onBackToList: () => void;
|
||||
}
|
||||
|
||||
// 패널 설정
|
||||
const panelConfigs: PanelConfig[] = [
|
||||
{
|
||||
id: "tables",
|
||||
title: "테이블 목록",
|
||||
defaultPosition: "left",
|
||||
defaultWidth: 320,
|
||||
defaultHeight: 600,
|
||||
shortcutKey: "t",
|
||||
},
|
||||
{
|
||||
id: "properties",
|
||||
title: "속성 편집",
|
||||
defaultPosition: "right",
|
||||
defaultWidth: 320,
|
||||
defaultHeight: 500,
|
||||
shortcutKey: "p",
|
||||
},
|
||||
{
|
||||
id: "styles",
|
||||
title: "스타일 편집",
|
||||
defaultPosition: "right",
|
||||
defaultWidth: 320,
|
||||
defaultHeight: 400,
|
||||
shortcutKey: "s",
|
||||
},
|
||||
{
|
||||
id: "grid",
|
||||
title: "격자 설정",
|
||||
defaultPosition: "right",
|
||||
defaultWidth: 280,
|
||||
defaultHeight: 450,
|
||||
shortcutKey: "g",
|
||||
},
|
||||
];
|
||||
|
||||
export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) {
|
||||
// 패널 상태 관리
|
||||
const { panelStates, togglePanel, openPanel, closePanel, closeAllPanels } = usePanelState(panelConfigs);
|
||||
|
||||
const [layout, setLayout] = useState<LayoutData>({
|
||||
components: [],
|
||||
gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true },
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
|
||||
|
||||
// 실행취소/다시실행을 위한 히스토리 상태
|
||||
const [history, setHistory] = useState<LayoutData[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
|
||||
// 그룹 상태
|
||||
const [groupState, setGroupState] = useState<GroupState>({
|
||||
selectedComponents: [],
|
||||
isGrouping: false,
|
||||
});
|
||||
|
||||
// 드래그 상태
|
||||
const [dragState, setDragState] = useState({
|
||||
isDragging: false,
|
||||
draggedComponent: null as ComponentData | null,
|
||||
originalPosition: { x: 0, y: 0 },
|
||||
currentPosition: { x: 0, y: 0 },
|
||||
grabOffset: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
// 테이블 데이터
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// 클립보드
|
||||
const [clipboard, setClipboard] = useState<{
|
||||
type: "single" | "multiple" | "group";
|
||||
data: ComponentData[];
|
||||
} | null>(null);
|
||||
|
||||
// 그룹 생성 다이얼로그
|
||||
const [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false);
|
||||
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 격자 정보 계산
|
||||
const gridInfo = useMemo(() => {
|
||||
if (!canvasRef.current || !layout.gridSettings) return null;
|
||||
return calculateGridInfo(canvasRef.current, layout.gridSettings);
|
||||
}, [layout.gridSettings]);
|
||||
|
||||
// 격자 라인 생성
|
||||
const gridLines = useMemo(() => {
|
||||
if (!gridInfo || !layout.gridSettings?.showGrid) return [];
|
||||
return generateGridLines(gridInfo, layout.gridSettings);
|
||||
}, [gridInfo, layout.gridSettings]);
|
||||
|
||||
// 필터된 테이블 목록
|
||||
const filteredTables = useMemo(() => {
|
||||
if (!searchTerm) return tables;
|
||||
return tables.filter(
|
||||
(table) =>
|
||||
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
table.columns.some((col) => col.columnName.toLowerCase().includes(searchTerm.toLowerCase())),
|
||||
);
|
||||
}, [tables, searchTerm]);
|
||||
|
||||
// 히스토리에 저장
|
||||
const saveToHistory = useCallback(
|
||||
(newLayout: LayoutData) => {
|
||||
setHistory((prev) => {
|
||||
const newHistory = prev.slice(0, historyIndex + 1);
|
||||
newHistory.push(newLayout);
|
||||
return newHistory.slice(-50); // 최대 50개까지만 저장
|
||||
});
|
||||
setHistoryIndex((prev) => Math.min(prev + 1, 49));
|
||||
setHasUnsavedChanges(true);
|
||||
},
|
||||
[historyIndex],
|
||||
);
|
||||
|
||||
// 실행취소
|
||||
const undo = useCallback(() => {
|
||||
if (historyIndex > 0) {
|
||||
setHistoryIndex((prev) => prev - 1);
|
||||
setLayout(history[historyIndex - 1]);
|
||||
}
|
||||
}, [history, historyIndex]);
|
||||
|
||||
// 다시실행
|
||||
const redo = useCallback(() => {
|
||||
if (historyIndex < history.length - 1) {
|
||||
setHistoryIndex((prev) => prev + 1);
|
||||
setLayout(history[historyIndex + 1]);
|
||||
}
|
||||
}, [history, historyIndex]);
|
||||
|
||||
// 컴포넌트 속성 업데이트
|
||||
const updateComponentProperty = useCallback(
|
||||
(componentId: string, path: string, value: any) => {
|
||||
const pathParts = path.split(".");
|
||||
const updatedComponents = layout.components.map((comp) => {
|
||||
if (comp.id !== componentId) return comp;
|
||||
|
||||
const newComp = { ...comp };
|
||||
let current: any = newComp;
|
||||
|
||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||
if (!current[pathParts[i]]) {
|
||||
current[pathParts[i]] = {};
|
||||
}
|
||||
current = current[pathParts[i]];
|
||||
}
|
||||
current[pathParts[pathParts.length - 1]] = value;
|
||||
|
||||
// 크기 변경 시 격자 스냅 적용
|
||||
if ((path === "size.width" || path === "size.height") && layout.gridSettings?.snapToGrid && gridInfo) {
|
||||
const snappedSize = snapSizeToGrid(newComp.size, gridInfo, layout.gridSettings as GridUtilSettings);
|
||||
newComp.size = snappedSize;
|
||||
}
|
||||
|
||||
return newComp;
|
||||
});
|
||||
|
||||
const newLayout = { ...layout, components: updatedComponents };
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
},
|
||||
[layout, gridInfo, saveToHistory],
|
||||
);
|
||||
|
||||
// 테이블 데이터 로드
|
||||
useEffect(() => {
|
||||
if (selectedScreen?.tableName) {
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await screenApi.getTableInfo([selectedScreen.tableName]);
|
||||
setTables(response.data || []);
|
||||
} catch (error) {
|
||||
console.error("테이블 정보 로드 실패:", error);
|
||||
toast.error("테이블 정보를 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
}
|
||||
}, [selectedScreen?.tableName]);
|
||||
|
||||
// 화면 레이아웃 로드
|
||||
useEffect(() => {
|
||||
if (selectedScreen?.screenId) {
|
||||
const loadLayout = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await screenApi.getScreenLayout(selectedScreen.screenId);
|
||||
if (response.success && response.data) {
|
||||
setLayout(response.data);
|
||||
setHistory([response.data]);
|
||||
setHistoryIndex(0);
|
||||
setHasUnsavedChanges(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("레이아웃 로드 실패:", error);
|
||||
toast.error("화면 레이아웃을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadLayout();
|
||||
}
|
||||
}, [selectedScreen?.screenId]);
|
||||
|
||||
// 저장
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!selectedScreen?.screenId) return;
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
const response = await screenApi.saveScreenLayout(selectedScreen.screenId, layout);
|
||||
if (response.success) {
|
||||
toast.success("화면이 저장되었습니다.");
|
||||
setHasUnsavedChanges(false);
|
||||
} else {
|
||||
toast.error("저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("저장 실패:", error);
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [selectedScreen?.screenId, layout]);
|
||||
|
||||
// 드래그 앤 드롭 처리
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const dragData = e.dataTransfer.getData("application/json");
|
||||
if (!dragData) return;
|
||||
|
||||
try {
|
||||
const { type, table, column } = JSON.parse(dragData);
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
let newComponent: ComponentData;
|
||||
|
||||
if (type === "table") {
|
||||
// 테이블 컨테이너 생성
|
||||
newComponent = {
|
||||
id: generateComponentId(),
|
||||
type: "container",
|
||||
label: table.tableName,
|
||||
tableName: table.tableName,
|
||||
position: { x, y, z: 1 },
|
||||
size: { width: 300, height: 200 },
|
||||
};
|
||||
} else if (type === "column") {
|
||||
// 컬럼 위젯 생성
|
||||
newComponent = {
|
||||
id: generateComponentId(),
|
||||
type: "widget",
|
||||
label: column.columnName,
|
||||
tableName: table.tableName,
|
||||
columnName: column.columnName,
|
||||
widgetType: column.widgetType,
|
||||
dataType: column.dataType,
|
||||
required: column.required,
|
||||
position: { x, y, z: 1 },
|
||||
size: { width: 200, height: 40 },
|
||||
};
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// 격자 스냅 적용
|
||||
if (layout.gridSettings?.snapToGrid && gridInfo) {
|
||||
newComponent.position = snapToGrid(newComponent.position, gridInfo, layout.gridSettings as GridUtilSettings);
|
||||
newComponent.size = snapSizeToGrid(newComponent.size, gridInfo, layout.gridSettings as GridUtilSettings);
|
||||
}
|
||||
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: [...layout.components, newComponent],
|
||||
};
|
||||
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setSelectedComponent(newComponent);
|
||||
|
||||
// 속성 패널 자동 열기
|
||||
openPanel("properties");
|
||||
} catch (error) {
|
||||
console.error("드롭 처리 실패:", error);
|
||||
}
|
||||
},
|
||||
[layout, gridInfo, saveToHistory, openPanel],
|
||||
);
|
||||
|
||||
// 컴포넌트 클릭 처리
|
||||
const handleComponentClick = useCallback(
|
||||
(component: ComponentData, event?: React.MouseEvent) => {
|
||||
event?.stopPropagation();
|
||||
setSelectedComponent(component);
|
||||
|
||||
// 속성 패널 자동 열기
|
||||
openPanel("properties");
|
||||
},
|
||||
[openPanel],
|
||||
);
|
||||
|
||||
// 컴포넌트 삭제
|
||||
const deleteComponent = useCallback(() => {
|
||||
if (!selectedComponent) return;
|
||||
|
||||
const newComponents = layout.components.filter((comp) => comp.id !== selectedComponent.id);
|
||||
const newLayout = { ...layout, components: newComponents };
|
||||
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setSelectedComponent(null);
|
||||
}, [selectedComponent, layout, saveToHistory]);
|
||||
|
||||
// 컴포넌트 복사
|
||||
const copyComponent = useCallback(() => {
|
||||
if (!selectedComponent) return;
|
||||
|
||||
setClipboard({
|
||||
type: "single",
|
||||
data: [{ ...selectedComponent, id: generateComponentId() }],
|
||||
});
|
||||
|
||||
toast.success("컴포넌트가 복사되었습니다.");
|
||||
}, [selectedComponent]);
|
||||
|
||||
// 그룹 생성
|
||||
const handleGroupCreate = useCallback(
|
||||
(componentIds: string[], title: string, style?: any) => {
|
||||
const selectedComponents = layout.components.filter((comp) => componentIds.includes(comp.id));
|
||||
if (selectedComponents.length < 2) return;
|
||||
|
||||
// 경계 박스 계산
|
||||
const boundingBox = calculateBoundingBox(selectedComponents);
|
||||
|
||||
// 그룹 컴포넌트 생성
|
||||
const groupComponent = createGroupComponent(
|
||||
componentIds,
|
||||
title,
|
||||
{ x: boundingBox.minX, y: boundingBox.minY },
|
||||
{ width: boundingBox.width, height: boundingBox.height },
|
||||
style,
|
||||
);
|
||||
|
||||
// 자식 컴포넌트들의 상대 위치 계산
|
||||
const relativeChildren = calculateRelativePositions(
|
||||
selectedComponents,
|
||||
{ x: boundingBox.minX, y: boundingBox.minY },
|
||||
groupComponent.id,
|
||||
);
|
||||
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: [
|
||||
...layout.components.filter((comp) => !componentIds.includes(comp.id)),
|
||||
groupComponent,
|
||||
...relativeChildren,
|
||||
],
|
||||
};
|
||||
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
|
||||
},
|
||||
[layout, saveToHistory],
|
||||
);
|
||||
|
||||
// 키보드 이벤트 처리
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Delete 키로 컴포넌트 삭제
|
||||
if (e.key === "Delete" && selectedComponent) {
|
||||
deleteComponent();
|
||||
}
|
||||
|
||||
// Ctrl+C로 복사
|
||||
if (e.ctrlKey && e.key === "c" && selectedComponent) {
|
||||
copyComponent();
|
||||
}
|
||||
|
||||
// Ctrl+Z로 실행취소
|
||||
if (e.ctrlKey && e.key === "z" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
undo();
|
||||
}
|
||||
|
||||
// Ctrl+Y 또는 Ctrl+Shift+Z로 다시실행
|
||||
if ((e.ctrlKey && e.key === "y") || (e.ctrlKey && e.shiftKey && e.key === "z")) {
|
||||
e.preventDefault();
|
||||
redo();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectedComponent, deleteComponent, copyComponent, undo, redo]);
|
||||
|
||||
if (!selectedScreen) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Database className="mx-auto mb-4 h-12 w-12 text-gray-400" />
|
||||
<h3 className="text-lg font-medium text-gray-900">화면을 선택하세요</h3>
|
||||
<p className="text-gray-500">설계할 화면을 먼저 선택해주세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col bg-gray-100">
|
||||
{/* 상단 툴바 */}
|
||||
<DesignerToolbar
|
||||
screenName={selectedScreen?.screenName}
|
||||
tableName={selectedScreen?.tableName}
|
||||
onBack={onBackToList}
|
||||
onSave={handleSave}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onPreview={() => {
|
||||
toast.info("미리보기 기능은 준비 중입니다.");
|
||||
}}
|
||||
onTogglePanel={togglePanel}
|
||||
panelStates={panelStates}
|
||||
canUndo={historyIndex > 0}
|
||||
canRedo={historyIndex < history.length - 1}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
|
||||
{/* 메인 캔버스 영역 (전체 화면) */}
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="relative flex-1 overflow-hidden bg-white"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setSelectedComponent(null);
|
||||
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
|
||||
}
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
{/* 격자 라인 */}
|
||||
{gridLines.map((line, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
left: line.type === "vertical" ? `${line.position}px` : 0,
|
||||
top: line.type === "horizontal" ? `${line.position}px` : 0,
|
||||
width: line.type === "vertical" ? "1px" : "100%",
|
||||
height: line.type === "horizontal" ? "1px" : "100%",
|
||||
backgroundColor: layout.gridSettings?.gridColor || "#e5e7eb",
|
||||
opacity: layout.gridSettings?.gridOpacity || 0.3,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 컴포넌트들 */}
|
||||
{layout.components
|
||||
.filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링
|
||||
.map((component) => {
|
||||
const children =
|
||||
component.type === "group" ? layout.components.filter((child) => child.parentId === component.id) : [];
|
||||
|
||||
return (
|
||||
<RealtimePreview
|
||||
key={component.id}
|
||||
component={component}
|
||||
isSelected={selectedComponent?.id === component.id}
|
||||
onClick={(e) => handleComponentClick(component, e)}
|
||||
>
|
||||
{children.map((child) => (
|
||||
<RealtimePreview
|
||||
key={child.id}
|
||||
component={child}
|
||||
isSelected={selectedComponent?.id === child.id}
|
||||
onClick={(e) => handleComponentClick(child, e)}
|
||||
/>
|
||||
))}
|
||||
</RealtimePreview>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 빈 캔버스 안내 */}
|
||||
{layout.components.length === 0 && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center text-gray-400">
|
||||
<Database className="mx-auto mb-4 h-16 w-16" />
|
||||
<h3 className="mb-2 text-xl font-medium">캔버스가 비어있습니다</h3>
|
||||
<p className="text-sm">좌측 테이블 패널에서 테이블이나 컬럼을 드래그하여 화면을 설계하세요</p>
|
||||
<p className="mt-2 text-xs">단축키: T(테이블), P(속성), S(스타일), G(격자)</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 플로팅 패널들 */}
|
||||
<FloatingPanel
|
||||
id="tables"
|
||||
title="테이블 목록"
|
||||
isOpen={panelStates.tables?.isOpen || false}
|
||||
onClose={() => closePanel("tables")}
|
||||
position="left"
|
||||
width={320}
|
||||
height={600}
|
||||
>
|
||||
<TablesPanel
|
||||
tables={filteredTables}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
onDragStart={(e, table, column) => {
|
||||
const dragData = {
|
||||
type: column ? "column" : "table",
|
||||
table,
|
||||
column,
|
||||
};
|
||||
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
|
||||
}}
|
||||
selectedTableName={selectedScreen.tableName}
|
||||
/>
|
||||
</FloatingPanel>
|
||||
|
||||
<FloatingPanel
|
||||
id="properties"
|
||||
title="속성 편집"
|
||||
isOpen={panelStates.properties?.isOpen || false}
|
||||
onClose={() => closePanel("properties")}
|
||||
position="right"
|
||||
width={320}
|
||||
height={500}
|
||||
>
|
||||
<PropertiesPanel
|
||||
selectedComponent={selectedComponent}
|
||||
onUpdateProperty={updateComponentProperty}
|
||||
onDeleteComponent={deleteComponent}
|
||||
onCopyComponent={copyComponent}
|
||||
/>
|
||||
</FloatingPanel>
|
||||
|
||||
<FloatingPanel
|
||||
id="styles"
|
||||
title="스타일 편집"
|
||||
isOpen={panelStates.styles?.isOpen || false}
|
||||
onClose={() => closePanel("styles")}
|
||||
position="right"
|
||||
width={320}
|
||||
height={400}
|
||||
>
|
||||
{selectedComponent ? (
|
||||
<div className="p-4">
|
||||
<StyleEditor
|
||||
style={selectedComponent.style || {}}
|
||||
onStyleChange={(newStyle) => updateComponentProperty(selectedComponent.id, "style", newStyle)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-gray-500">
|
||||
컴포넌트를 선택하여 스타일을 편집하세요
|
||||
</div>
|
||||
)}
|
||||
</FloatingPanel>
|
||||
|
||||
<FloatingPanel
|
||||
id="grid"
|
||||
title="격자 설정"
|
||||
isOpen={panelStates.grid?.isOpen || false}
|
||||
onClose={() => closePanel("grid")}
|
||||
position="right"
|
||||
width={280}
|
||||
height={450}
|
||||
>
|
||||
<GridPanel
|
||||
gridSettings={layout.gridSettings || { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true }}
|
||||
onGridSettingsChange={(settings) => {
|
||||
const newLayout = { ...layout, gridSettings: settings };
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
}}
|
||||
onResetGrid={() => {
|
||||
const defaultSettings = { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true };
|
||||
const newLayout = { ...layout, gridSettings: defaultSettings };
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
}}
|
||||
/>
|
||||
</FloatingPanel>
|
||||
|
||||
{/* 그룹 생성 툴바 (필요시) */}
|
||||
{groupState.selectedComponents.length > 1 && (
|
||||
<div className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2 transform">
|
||||
<GroupingToolbar
|
||||
selectedComponents={groupState.selectedComponents}
|
||||
onGroupCreate={handleGroupCreate}
|
||||
showCreateDialog={showGroupCreateDialog}
|
||||
onShowCreateDialogChange={setShowGroupCreateDialog}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,223 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Grid3X3, RotateCcw, Eye, EyeOff, Zap } from "lucide-react";
|
||||
import { GridSettings } from "@/types/screen";
|
||||
|
||||
interface GridPanelProps {
|
||||
gridSettings: GridSettings;
|
||||
onGridSettingsChange: (settings: GridSettings) => void;
|
||||
onResetGrid: () => void;
|
||||
}
|
||||
|
||||
export const GridPanel: React.FC<GridPanelProps> = ({ gridSettings, onGridSettingsChange, onResetGrid }) => {
|
||||
const updateSetting = (key: keyof GridSettings, value: any) => {
|
||||
onGridSettingsChange({
|
||||
...gridSettings,
|
||||
[key]: value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b border-gray-200 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Grid3X3 className="h-4 w-4 text-gray-600" />
|
||||
<h3 className="font-medium text-gray-900">격자 설정</h3>
|
||||
</div>
|
||||
|
||||
<Button size="sm" variant="outline" onClick={onResetGrid} className="flex items-center space-x-1">
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
<span>초기화</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 주요 토글들 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{gridSettings.showGrid ? (
|
||||
<Eye className="h-4 w-4 text-blue-600" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
<Label htmlFor="showGrid" className="text-sm font-medium">
|
||||
격자 표시
|
||||
</Label>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="showGrid"
|
||||
checked={gridSettings.showGrid}
|
||||
onCheckedChange={(checked) => updateSetting("showGrid", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Zap className="h-4 w-4 text-green-600" />
|
||||
<Label htmlFor="snapToGrid" className="text-sm font-medium">
|
||||
격자 스냅
|
||||
</Label>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="snapToGrid"
|
||||
checked={gridSettings.snapToGrid}
|
||||
onCheckedChange={(checked) => updateSetting("snapToGrid", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 설정 영역 */}
|
||||
<div className="flex-1 space-y-6 overflow-y-auto p-4">
|
||||
{/* 격자 구조 */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-gray-900">격자 구조</h4>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="columns" className="mb-2 block text-sm font-medium">
|
||||
컬럼 수: {gridSettings.columns}
|
||||
</Label>
|
||||
<Slider
|
||||
id="columns"
|
||||
min={1}
|
||||
max={24}
|
||||
step={1}
|
||||
value={[gridSettings.columns]}
|
||||
onValueChange={([value]) => updateSetting("columns", value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-xs text-gray-500">
|
||||
<span>1</span>
|
||||
<span>24</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="gap" className="mb-2 block text-sm font-medium">
|
||||
간격: {gridSettings.gap}px
|
||||
</Label>
|
||||
<Slider
|
||||
id="gap"
|
||||
min={0}
|
||||
max={40}
|
||||
step={2}
|
||||
value={[gridSettings.gap]}
|
||||
onValueChange={([value]) => updateSetting("gap", value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-xs text-gray-500">
|
||||
<span>0px</span>
|
||||
<span>40px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="padding" className="mb-2 block text-sm font-medium">
|
||||
여백: {gridSettings.padding}px
|
||||
</Label>
|
||||
<Slider
|
||||
id="padding"
|
||||
min={0}
|
||||
max={60}
|
||||
step={4}
|
||||
value={[gridSettings.padding]}
|
||||
onValueChange={([value]) => updateSetting("padding", value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-xs text-gray-500">
|
||||
<span>0px</span>
|
||||
<span>60px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 격자 스타일 */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-gray-900">격자 스타일</h4>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="gridColor" className="text-sm font-medium">
|
||||
격자 색상
|
||||
</Label>
|
||||
<div className="mt-1 flex items-center space-x-2">
|
||||
<Input
|
||||
id="gridColor"
|
||||
type="color"
|
||||
value={gridSettings.gridColor || "#d1d5db"}
|
||||
onChange={(e) => updateSetting("gridColor", e.target.value)}
|
||||
className="h-8 w-12 rounded border p-1"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={gridSettings.gridColor || "#d1d5db"}
|
||||
onChange={(e) => updateSetting("gridColor", e.target.value)}
|
||||
placeholder="#d1d5db"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="gridOpacity" className="mb-2 block text-sm font-medium">
|
||||
격자 투명도: {Math.round((gridSettings.gridOpacity || 0.5) * 100)}%
|
||||
</Label>
|
||||
<Slider
|
||||
id="gridOpacity"
|
||||
min={0.1}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={[gridSettings.gridOpacity || 0.5]}
|
||||
onValueChange={([value]) => updateSetting("gridOpacity", value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-xs text-gray-500">
|
||||
<span>10%</span>
|
||||
<span>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 미리보기 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-gray-900">미리보기</h4>
|
||||
|
||||
<div
|
||||
className="rounded-md border border-gray-200 bg-white p-4"
|
||||
style={{
|
||||
backgroundImage: gridSettings.showGrid
|
||||
? `linear-gradient(to right, ${gridSettings.gridColor || "#d1d5db"} 1px, transparent 1px),
|
||||
linear-gradient(to bottom, ${gridSettings.gridColor || "#d1d5db"} 1px, transparent 1px)`
|
||||
: "none",
|
||||
backgroundSize: gridSettings.showGrid ? `${100 / gridSettings.columns}% 20px` : "none",
|
||||
opacity: gridSettings.gridOpacity || 0.5,
|
||||
}}
|
||||
>
|
||||
<div className="flex h-16 items-center justify-center rounded border-2 border-dashed border-blue-300 bg-blue-100">
|
||||
<span className="text-xs text-blue-600">컴포넌트 예시</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="border-t border-gray-200 bg-gray-50 p-3">
|
||||
<div className="text-xs text-gray-600">💡 격자 설정은 실시간으로 캔버스에 반영됩니다</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GridPanel;
|
||||
|
|
@ -0,0 +1,456 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Settings, Move, Resize, Type, Palette, Trash2, Copy, Group, Ungroup } from "lucide-react";
|
||||
import { ComponentData, WebType } from "@/types/screen";
|
||||
|
||||
interface PropertiesPanelProps {
|
||||
selectedComponent?: ComponentData;
|
||||
onUpdateProperty: (path: string, value: any) => void;
|
||||
onDeleteComponent: () => void;
|
||||
onCopyComponent: () => void;
|
||||
onGroupComponents?: () => void;
|
||||
onUngroupComponents?: () => void;
|
||||
canGroup?: boolean;
|
||||
canUngroup?: boolean;
|
||||
}
|
||||
|
||||
const webTypeOptions: { value: WebType; label: string }[] = [
|
||||
{ value: "text", label: "텍스트" },
|
||||
{ value: "email", label: "이메일" },
|
||||
{ value: "tel", label: "전화번호" },
|
||||
{ value: "number", label: "숫자" },
|
||||
{ value: "decimal", label: "소수" },
|
||||
{ value: "date", label: "날짜" },
|
||||
{ value: "datetime", label: "날짜시간" },
|
||||
{ value: "select", label: "선택박스" },
|
||||
{ value: "dropdown", label: "드롭다운" },
|
||||
{ value: "textarea", label: "텍스트영역" },
|
||||
{ value: "boolean", label: "불린" },
|
||||
{ value: "checkbox", label: "체크박스" },
|
||||
{ value: "radio", label: "라디오" },
|
||||
{ value: "code", label: "코드" },
|
||||
{ value: "entity", label: "엔티티" },
|
||||
{ value: "file", label: "파일" },
|
||||
];
|
||||
|
||||
// Debounce hook for better performance
|
||||
const useDebounce = (value: any, delay: number) => {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
||||
selectedComponent,
|
||||
onUpdateProperty,
|
||||
onDeleteComponent,
|
||||
onCopyComponent,
|
||||
onGroupComponents,
|
||||
onUngroupComponents,
|
||||
canGroup = false,
|
||||
canUngroup = false,
|
||||
}) => {
|
||||
// 최신 값들의 참조를 유지
|
||||
const selectedComponentRef = useRef(selectedComponent);
|
||||
const onUpdatePropertyRef = useRef(onUpdateProperty);
|
||||
|
||||
useEffect(() => {
|
||||
selectedComponentRef.current = selectedComponent;
|
||||
onUpdatePropertyRef.current = onUpdateProperty;
|
||||
});
|
||||
|
||||
// 로컬 상태 관리 (실시간 입력 반영용)
|
||||
const [localValues, setLocalValues] = useState({
|
||||
label: selectedComponent?.label || "",
|
||||
placeholder: selectedComponent?.placeholder || "",
|
||||
title: selectedComponent?.title || "",
|
||||
positionX: selectedComponent?.position.x || 0,
|
||||
positionY: selectedComponent?.position.y || 0,
|
||||
positionZ: selectedComponent?.position.z || 1,
|
||||
width: selectedComponent?.size.width || 0,
|
||||
height: selectedComponent?.size.height || 0,
|
||||
});
|
||||
|
||||
// 선택된 컴포넌트가 변경될 때 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
if (selectedComponent) {
|
||||
setLocalValues({
|
||||
label: selectedComponent.label || "",
|
||||
placeholder: selectedComponent.placeholder || "",
|
||||
title: selectedComponent.title || "",
|
||||
positionX: selectedComponent.position.x || 0,
|
||||
positionY: selectedComponent.position.y || 0,
|
||||
positionZ: selectedComponent.position.z || 1,
|
||||
width: selectedComponent.size.width || 0,
|
||||
height: selectedComponent.size.height || 0,
|
||||
});
|
||||
}
|
||||
}, [selectedComponent]);
|
||||
|
||||
// Debounce된 값들
|
||||
const debouncedLabel = useDebounce(localValues.label, 300);
|
||||
const debouncedPlaceholder = useDebounce(localValues.placeholder, 300);
|
||||
const debouncedTitle = useDebounce(localValues.title, 300);
|
||||
const debouncedPositionX = useDebounce(localValues.positionX, 150);
|
||||
const debouncedPositionY = useDebounce(localValues.positionY, 150);
|
||||
const debouncedPositionZ = useDebounce(localValues.positionZ, 150);
|
||||
const debouncedWidth = useDebounce(localValues.width, 150);
|
||||
const debouncedHeight = useDebounce(localValues.height, 150);
|
||||
|
||||
// Debounce된 값이 변경될 때 실제 업데이트
|
||||
useEffect(() => {
|
||||
const currentComponent = selectedComponentRef.current;
|
||||
const updateProperty = onUpdatePropertyRef.current;
|
||||
|
||||
if (currentComponent && debouncedLabel !== currentComponent.label && debouncedLabel) {
|
||||
updateProperty("label", debouncedLabel);
|
||||
}
|
||||
}, [debouncedLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentComponent = selectedComponentRef.current;
|
||||
const updateProperty = onUpdatePropertyRef.current;
|
||||
|
||||
if (currentComponent && debouncedPlaceholder !== currentComponent.placeholder) {
|
||||
updateProperty("placeholder", debouncedPlaceholder);
|
||||
}
|
||||
}, [debouncedPlaceholder]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentComponent = selectedComponentRef.current;
|
||||
const updateProperty = onUpdatePropertyRef.current;
|
||||
|
||||
if (currentComponent && debouncedTitle !== currentComponent.title) {
|
||||
updateProperty("title", debouncedTitle);
|
||||
}
|
||||
}, [debouncedTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentComponent = selectedComponentRef.current;
|
||||
const updateProperty = onUpdatePropertyRef.current;
|
||||
|
||||
if (currentComponent && debouncedPositionX !== currentComponent.position.x) {
|
||||
updateProperty("position.x", debouncedPositionX);
|
||||
}
|
||||
}, [debouncedPositionX]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentComponent = selectedComponentRef.current;
|
||||
const updateProperty = onUpdatePropertyRef.current;
|
||||
|
||||
if (currentComponent && debouncedPositionY !== currentComponent.position.y) {
|
||||
updateProperty("position.y", debouncedPositionY);
|
||||
}
|
||||
}, [debouncedPositionY]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentComponent = selectedComponentRef.current;
|
||||
const updateProperty = onUpdatePropertyRef.current;
|
||||
|
||||
if (currentComponent && debouncedPositionZ !== currentComponent.position.z) {
|
||||
updateProperty("position.z", debouncedPositionZ);
|
||||
}
|
||||
}, [debouncedPositionZ]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentComponent = selectedComponentRef.current;
|
||||
const updateProperty = onUpdatePropertyRef.current;
|
||||
|
||||
if (currentComponent && debouncedWidth !== currentComponent.size.width) {
|
||||
updateProperty("size.width", debouncedWidth);
|
||||
}
|
||||
}, [debouncedWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentComponent = selectedComponentRef.current;
|
||||
const updateProperty = onUpdatePropertyRef.current;
|
||||
|
||||
if (currentComponent && debouncedHeight !== currentComponent.size.height) {
|
||||
updateProperty("size.height", debouncedHeight);
|
||||
}
|
||||
}, [debouncedHeight]);
|
||||
|
||||
if (!selectedComponent) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
|
||||
<Settings className="mb-4 h-12 w-12 text-gray-400" />
|
||||
<h3 className="mb-2 text-lg font-medium text-gray-900">컴포넌트를 선택하세요</h3>
|
||||
<p className="text-sm text-gray-500">캔버스에서 컴포넌트를 클릭하면 속성을 편집할 수 있습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b border-gray-200 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Settings className="h-4 w-4 text-gray-600" />
|
||||
<h3 className="font-medium text-gray-900">속성 편집</h3>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{selectedComponent.type}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼들 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={onCopyComponent} className="flex items-center space-x-1">
|
||||
<Copy className="h-3 w-3" />
|
||||
<span>복사</span>
|
||||
</Button>
|
||||
|
||||
{canGroup && (
|
||||
<Button size="sm" variant="outline" onClick={onGroupComponents} className="flex items-center space-x-1">
|
||||
<Group className="h-3 w-3" />
|
||||
<span>그룹</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canUngroup && (
|
||||
<Button size="sm" variant="outline" onClick={onUngroupComponents} className="flex items-center space-x-1">
|
||||
<Ungroup className="h-3 w-3" />
|
||||
<span>해제</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button size="sm" variant="destructive" onClick={onDeleteComponent} className="flex items-center space-x-1">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<span>삭제</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 속성 편집 영역 */}
|
||||
<div className="flex-1 space-y-6 overflow-y-auto p-4">
|
||||
{/* 기본 정보 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Type className="h-4 w-4 text-gray-600" />
|
||||
<h4 className="font-medium text-gray-900">기본 정보</h4>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="label" className="text-sm font-medium">
|
||||
라벨
|
||||
</Label>
|
||||
<Input
|
||||
id="label"
|
||||
value={localValues.label}
|
||||
onChange={(e) => setLocalValues((prev) => ({ ...prev, label: e.target.value }))}
|
||||
placeholder="컴포넌트 라벨"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedComponent.type === "widget" && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="columnName" className="text-sm font-medium">
|
||||
컬럼명 (읽기 전용)
|
||||
</Label>
|
||||
<Input
|
||||
id="columnName"
|
||||
value={selectedComponent.columnName || ""}
|
||||
readOnly
|
||||
placeholder="데이터베이스 컬럼명"
|
||||
className="mt-1 bg-gray-50 text-gray-600"
|
||||
title="컬럼명은 변경할 수 없습니다"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="widgetType" className="text-sm font-medium">
|
||||
위젯 타입
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedComponent.widgetType || "text"}
|
||||
onValueChange={(value) => onUpdateProperty("widgetType", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{webTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="placeholder" className="text-sm font-medium">
|
||||
플레이스홀더
|
||||
</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
value={localValues.placeholder}
|
||||
onChange={(e) => setLocalValues((prev) => ({ ...prev, placeholder: e.target.value }))}
|
||||
placeholder="입력 힌트 텍스트"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="required"
|
||||
checked={selectedComponent.required || false}
|
||||
onCheckedChange={(checked) => onUpdateProperty("required", checked)}
|
||||
/>
|
||||
<Label htmlFor="required" className="text-sm">
|
||||
필수 입력
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="readonly"
|
||||
checked={selectedComponent.readonly || false}
|
||||
onCheckedChange={(checked) => onUpdateProperty("readonly", checked)}
|
||||
/>
|
||||
<Label htmlFor="readonly" className="text-sm">
|
||||
읽기 전용
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 위치 및 크기 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Move className="h-4 w-4 text-gray-600" />
|
||||
<h4 className="font-medium text-gray-900">위치 및 크기</h4>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="positionX" className="text-sm font-medium">
|
||||
X 위치
|
||||
</Label>
|
||||
<Input
|
||||
id="positionX"
|
||||
type="number"
|
||||
value={localValues.positionX}
|
||||
onChange={(e) => setLocalValues((prev) => ({ ...prev, positionX: Number(e.target.value) }))}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="positionY" className="text-sm font-medium">
|
||||
Y 위치
|
||||
</Label>
|
||||
<Input
|
||||
id="positionY"
|
||||
type="number"
|
||||
value={localValues.positionY}
|
||||
onChange={(e) => setLocalValues((prev) => ({ ...prev, positionY: Number(e.target.value) }))}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="width" className="text-sm font-medium">
|
||||
너비
|
||||
</Label>
|
||||
<Input
|
||||
id="width"
|
||||
type="number"
|
||||
value={localValues.width}
|
||||
onChange={(e) => setLocalValues((prev) => ({ ...prev, width: Number(e.target.value) }))}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="height" className="text-sm font-medium">
|
||||
높이
|
||||
</Label>
|
||||
<Input
|
||||
id="height"
|
||||
type="number"
|
||||
value={localValues.height}
|
||||
onChange={(e) => setLocalValues((prev) => ({ ...prev, height: Number(e.target.value) }))}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="zIndex" className="text-sm font-medium">
|
||||
Z-Index (레이어 순서)
|
||||
</Label>
|
||||
<Input
|
||||
id="zIndex"
|
||||
type="number"
|
||||
min="0"
|
||||
max="9999"
|
||||
value={localValues.positionZ}
|
||||
onChange={(e) => setLocalValues((prev) => ({ ...prev, positionZ: Number(e.target.value) }))}
|
||||
className="mt-1"
|
||||
placeholder="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedComponent.type === "group" && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
{/* 그룹 설정 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Group className="h-4 w-4 text-gray-600" />
|
||||
<h4 className="font-medium text-gray-900">그룹 설정</h4>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="groupTitle" className="text-sm font-medium">
|
||||
그룹 제목
|
||||
</Label>
|
||||
<Input
|
||||
id="groupTitle"
|
||||
value={localValues.title}
|
||||
onChange={(e) => setLocalValues((prev) => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="그룹 제목"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropertiesPanel;
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Database,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Type,
|
||||
Hash,
|
||||
Calendar,
|
||||
CheckSquare,
|
||||
List,
|
||||
AlignLeft,
|
||||
Code,
|
||||
Building,
|
||||
File,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { TableInfo, WebType } from "@/types/screen";
|
||||
|
||||
interface TablesPanelProps {
|
||||
tables: TableInfo[];
|
||||
searchTerm: string;
|
||||
onSearchChange: (term: string) => void;
|
||||
onDragStart: (e: React.DragEvent, table: TableInfo, column?: any) => void;
|
||||
selectedTableName?: string;
|
||||
}
|
||||
|
||||
// 위젯 타입별 아이콘
|
||||
const getWidgetIcon = (widgetType: WebType) => {
|
||||
switch (widgetType) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "tel":
|
||||
return <Type className="h-3 w-3 text-blue-600" />;
|
||||
case "number":
|
||||
case "decimal":
|
||||
return <Hash className="h-3 w-3 text-green-600" />;
|
||||
case "date":
|
||||
case "datetime":
|
||||
return <Calendar className="h-3 w-3 text-purple-600" />;
|
||||
case "select":
|
||||
case "dropdown":
|
||||
return <List className="h-3 w-3 text-orange-600" />;
|
||||
case "textarea":
|
||||
case "text_area":
|
||||
return <AlignLeft className="h-3 w-3 text-indigo-600" />;
|
||||
case "boolean":
|
||||
case "checkbox":
|
||||
return <CheckSquare className="h-3 w-3 text-blue-600" />;
|
||||
case "code":
|
||||
return <Code className="h-3 w-3 text-gray-600" />;
|
||||
case "entity":
|
||||
return <Building className="h-3 w-3 text-cyan-600" />;
|
||||
case "file":
|
||||
return <File className="h-3 w-3 text-yellow-600" />;
|
||||
default:
|
||||
return <Type className="h-3 w-3 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
export const TablesPanel: React.FC<TablesPanelProps> = ({
|
||||
tables,
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
onDragStart,
|
||||
selectedTableName,
|
||||
}) => {
|
||||
const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleTable = (tableName: string) => {
|
||||
const newExpanded = new Set(expandedTables);
|
||||
if (newExpanded.has(tableName)) {
|
||||
newExpanded.delete(tableName);
|
||||
} else {
|
||||
newExpanded.add(tableName);
|
||||
}
|
||||
setExpandedTables(newExpanded);
|
||||
};
|
||||
|
||||
const filteredTables = tables.filter(
|
||||
(table) =>
|
||||
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
table.columns.some((col) => col.columnName.toLowerCase().includes(searchTerm.toLowerCase())),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b border-gray-200 p-4">
|
||||
{selectedTableName && (
|
||||
<div className="mb-3 rounded-md bg-blue-50 p-3">
|
||||
<div className="text-sm font-medium text-blue-900">선택된 테이블</div>
|
||||
<div className="mt-1 flex items-center space-x-2">
|
||||
<Database className="h-3 w-3 text-blue-600" />
|
||||
<span className="font-mono text-xs text-blue-800">{selectedTableName}</span>
|
||||
</div>
|
||||
</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
|
||||
type="text"
|
||||
placeholder="테이블명, 컬럼명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 py-2 pr-3 pl-10 focus:border-transparent focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-xs text-gray-600">총 {filteredTables.length}개 테이블</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 목록 */}
|
||||
<div className="scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 flex-1 overflow-y-auto">
|
||||
<div className="space-y-1 p-2">
|
||||
{filteredTables.map((table) => {
|
||||
const isExpanded = expandedTables.has(table.tableName);
|
||||
|
||||
return (
|
||||
<div key={table.tableName} className="rounded-md border border-gray-200">
|
||||
{/* 테이블 헤더 */}
|
||||
<div
|
||||
className="flex cursor-pointer items-center justify-between p-3 hover:bg-gray-50"
|
||||
onClick={() => toggleTable(table.tableName)}
|
||||
>
|
||||
<div className="flex flex-1 items-center space-x-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-gray-500" />
|
||||
)}
|
||||
<Database className="h-4 w-4 text-blue-600" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">{table.tableName}</div>
|
||||
<div className="text-xs text-gray-500">{table.columns.length}개 컬럼</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, table)}
|
||||
className="ml-2 text-xs"
|
||||
>
|
||||
드래그
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 목록 */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-200 bg-gray-50">
|
||||
<div
|
||||
className={`${
|
||||
table.columns.length > 8
|
||||
? "scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 max-h-64 overflow-y-auto"
|
||||
: ""
|
||||
}`}
|
||||
style={{
|
||||
scrollbarWidth: "thin",
|
||||
scrollbarColor: "#cbd5e1 #f1f5f9",
|
||||
}}
|
||||
>
|
||||
{table.columns.map((column, index) => (
|
||||
<div
|
||||
key={column.columnName}
|
||||
className={`flex cursor-pointer items-center justify-between p-2 hover:bg-white ${
|
||||
index < table.columns.length - 1 ? "border-b border-gray-100" : ""
|
||||
}`}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, table, column)}
|
||||
>
|
||||
<div className="flex flex-1 items-center space-x-2">
|
||||
{getWidgetIcon(column.widgetType)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{column.columnName}</div>
|
||||
<div className="truncate text-xs text-gray-500">{column.dataType}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center space-x-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{column.widgetType}
|
||||
</Badge>
|
||||
{column.required && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
필수
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 컬럼 수가 많을 때 안내 메시지 */}
|
||||
{table.columns.length > 8 && (
|
||||
<div className="sticky bottom-0 bg-gray-100 p-2 text-center">
|
||||
<div className="text-xs text-gray-600">
|
||||
📜 총 {table.columns.length}개 컬럼 (스크롤하여 더 보기)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="border-t border-gray-200 bg-gray-50 p-3">
|
||||
<div className="text-xs text-gray-600">💡 테이블이나 컬럼을 캔버스로 드래그하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TablesPanel;
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
|
||||
export interface PanelState {
|
||||
isOpen: boolean;
|
||||
position: { x: number; y: number };
|
||||
size: { width: number; height: number };
|
||||
}
|
||||
|
||||
export interface PanelConfig {
|
||||
id: string;
|
||||
title: string;
|
||||
defaultPosition: "left" | "right" | "top" | "bottom";
|
||||
defaultWidth: number;
|
||||
defaultHeight: number;
|
||||
shortcutKey?: string;
|
||||
}
|
||||
|
||||
export const usePanelState = (panels: PanelConfig[]) => {
|
||||
const [panelStates, setPanelStates] = useState<Record<string, PanelState>>(() => {
|
||||
const initialStates: Record<string, PanelState> = {};
|
||||
|
||||
panels.forEach((panel) => {
|
||||
initialStates[panel.id] = {
|
||||
isOpen: false,
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: panel.defaultWidth, height: panel.defaultHeight },
|
||||
};
|
||||
});
|
||||
|
||||
return initialStates;
|
||||
});
|
||||
|
||||
// 패널 토글
|
||||
const togglePanel = useCallback((panelId: string) => {
|
||||
setPanelStates((prev) => ({
|
||||
...prev,
|
||||
[panelId]: {
|
||||
...prev[panelId],
|
||||
isOpen: !prev[panelId]?.isOpen,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 패널 열기
|
||||
const openPanel = useCallback((panelId: string) => {
|
||||
setPanelStates((prev) => ({
|
||||
...prev,
|
||||
[panelId]: {
|
||||
...prev[panelId],
|
||||
isOpen: true,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 패널 닫기
|
||||
const closePanel = useCallback((panelId: string) => {
|
||||
setPanelStates((prev) => ({
|
||||
...prev,
|
||||
[panelId]: {
|
||||
...prev[panelId],
|
||||
isOpen: false,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 모든 패널 닫기
|
||||
const closeAllPanels = useCallback(() => {
|
||||
setPanelStates((prev) => {
|
||||
const newStates = { ...prev };
|
||||
Object.keys(newStates).forEach((panelId) => {
|
||||
newStates[panelId] = {
|
||||
...newStates[panelId],
|
||||
isOpen: false,
|
||||
};
|
||||
});
|
||||
return newStates;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 패널 위치 업데이트
|
||||
const updatePanelPosition = useCallback((panelId: string, position: { x: number; y: number }) => {
|
||||
setPanelStates((prev) => ({
|
||||
...prev,
|
||||
[panelId]: {
|
||||
...prev[panelId],
|
||||
position,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 패널 크기 업데이트
|
||||
const updatePanelSize = useCallback((panelId: string, size: { width: number; height: number }) => {
|
||||
setPanelStates((prev) => ({
|
||||
...prev,
|
||||
[panelId]: {
|
||||
...prev[panelId],
|
||||
size,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 키보드 단축키 처리
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Esc 키로 모든 패널 닫기
|
||||
if (e.key === "Escape") {
|
||||
closeAllPanels();
|
||||
return;
|
||||
}
|
||||
|
||||
// 단축키 처리
|
||||
panels.forEach((panel) => {
|
||||
if (panel.shortcutKey && e.key.toLowerCase() === panel.shortcutKey.toLowerCase()) {
|
||||
// Ctrl/Cmd 키와 함께 사용
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
togglePanel(panel.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [panels, togglePanel, closeAllPanels]);
|
||||
|
||||
return {
|
||||
panelStates,
|
||||
togglePanel,
|
||||
openPanel,
|
||||
closePanel,
|
||||
closeAllPanels,
|
||||
updatePanelPosition,
|
||||
updatePanelSize,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -75,12 +75,28 @@ export function snapSizeToGrid(size: Size, gridInfo: GridInfo, gridSettings: Gri
|
|||
const { gap } = gridSettings;
|
||||
|
||||
// 격자 단위로 너비 계산
|
||||
const gridColumns = Math.max(1, Math.round(size.width / (columnWidth + gap)));
|
||||
// 컴포넌트가 차지하는 컬럼 수를 올바르게 계산
|
||||
let gridColumns = 1;
|
||||
|
||||
// 현재 너비에서 가장 가까운 격자 컬럼 수 찾기
|
||||
for (let cols = 1; cols <= gridSettings.columns; cols++) {
|
||||
const targetWidth = cols * columnWidth + (cols - 1) * gap;
|
||||
if (size.width <= targetWidth + (columnWidth + gap) / 2) {
|
||||
gridColumns = cols;
|
||||
break;
|
||||
}
|
||||
gridColumns = cols;
|
||||
}
|
||||
|
||||
const snappedWidth = gridColumns * columnWidth + (gridColumns - 1) * gap;
|
||||
|
||||
// 높이는 20px 단위로 스냅
|
||||
const snappedHeight = Math.max(40, Math.round(size.height / 20) * 20);
|
||||
|
||||
console.log(
|
||||
`📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`,
|
||||
);
|
||||
|
||||
return {
|
||||
width: Math.max(columnWidth, snappedWidth),
|
||||
height: snappedHeight,
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ export interface ComponentStyle {
|
|||
export interface BaseComponent {
|
||||
id: string;
|
||||
type: ComponentType;
|
||||
position: { x: number; y: number };
|
||||
position: Position;
|
||||
size: { width: number; height: number };
|
||||
parentId?: string;
|
||||
style?: ComponentStyle; // 스타일 속성 추가
|
||||
|
|
@ -199,6 +199,9 @@ export interface GridSettings {
|
|||
gap: number; // 기본값: 16px
|
||||
padding: number; // 기본값: 16px
|
||||
snapToGrid?: boolean; // 격자에 맞춤 여부 (기본값: true)
|
||||
showGrid?: boolean; // 격자 표시 여부 (기본값: true)
|
||||
gridColor?: string; // 격자 색상 (기본값: #d1d5db)
|
||||
gridOpacity?: number; // 격자 투명도 (기본값: 0.5)
|
||||
}
|
||||
|
||||
// 유효성 검증 규칙
|
||||
|
|
@ -291,8 +294,8 @@ export interface DropZone {
|
|||
export interface GroupState {
|
||||
isGrouping: boolean;
|
||||
selectedComponents: string[];
|
||||
groupTarget: string | null;
|
||||
groupMode: "create" | "add" | "remove" | "ungroup";
|
||||
groupTarget?: string | null;
|
||||
groupMode?: "create" | "add" | "remove" | "ungroup";
|
||||
groupTitle?: string;
|
||||
groupStyle?: ComponentStyle;
|
||||
}
|
||||
|
|
@ -313,7 +316,9 @@ export interface ColumnInfo {
|
|||
columnLabel?: string;
|
||||
dataType: string;
|
||||
webType?: WebType;
|
||||
widgetType?: WebType; // 프론트엔드에서 사용하는 필드 (webType과 동일)
|
||||
isNullable: string;
|
||||
required?: boolean; // isNullable에서 변환된 필드
|
||||
columnDefault?: string;
|
||||
characterMaximumLength?: number;
|
||||
numericPrecision?: number;
|
||||
|
|
|
|||
Loading…
Reference in New Issue