컴포넌트 추가방식 변경
This commit is contained in:
@@ -56,6 +56,11 @@ import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
|
||||
// 레이아웃 초기화
|
||||
import "@/lib/registry/layouts";
|
||||
|
||||
// 컴포넌트 초기화 (새 시스템)
|
||||
import "@/lib/registry/components";
|
||||
// 성능 최적화 도구 초기화 (필요시 사용)
|
||||
import "@/lib/registry/utils/performanceOptimizer";
|
||||
|
||||
interface ScreenDesignerProps {
|
||||
selectedScreen: ScreenDefinition | null;
|
||||
onBackToList: () => void;
|
||||
@@ -1423,8 +1428,37 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const dropX = e.clientX - rect.left;
|
||||
const dropY = e.clientY - rect.top;
|
||||
// 컴포넌트 크기 정보
|
||||
const componentWidth = component.defaultSize?.width || 120;
|
||||
const componentHeight = component.defaultSize?.height || 36;
|
||||
|
||||
// 방법 1: 마우스 포인터를 컴포넌트 중심으로 (현재 방식)
|
||||
const dropX_centered = e.clientX - rect.left - componentWidth / 2;
|
||||
const dropY_centered = e.clientY - rect.top - componentHeight / 2;
|
||||
|
||||
// 방법 2: 마우스 포인터를 컴포넌트 좌상단으로 (사용자가 원할 수도 있는 방식)
|
||||
const dropX_topleft = e.clientX - rect.left;
|
||||
const dropY_topleft = e.clientY - rect.top;
|
||||
|
||||
// 사용자가 원하는 방식으로 변경: 마우스 포인터가 좌상단에 오도록
|
||||
const dropX = dropX_topleft;
|
||||
const dropY = dropY_topleft;
|
||||
|
||||
console.log("🎯 위치 계산 디버깅:", {
|
||||
"1. 마우스 위치": { clientX: e.clientX, clientY: e.clientY },
|
||||
"2. 캔버스 위치": { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
|
||||
"3. 캔버스 내 상대 위치": { x: e.clientX - rect.left, y: e.clientY - rect.top },
|
||||
"4. 컴포넌트 크기": { width: componentWidth, height: componentHeight },
|
||||
"5a. 중심 방식 좌상단": { x: dropX_centered, y: dropY_centered },
|
||||
"5b. 좌상단 방식": { x: dropX_topleft, y: dropY_topleft },
|
||||
"6. 선택된 방식": { dropX, dropY },
|
||||
"7. 예상 컴포넌트 중심": { x: dropX + componentWidth / 2, y: dropY + componentHeight / 2 },
|
||||
"8. 마우스와 중심 일치 확인": {
|
||||
match:
|
||||
Math.abs(dropX + componentWidth / 2 - (e.clientX - rect.left)) < 1 &&
|
||||
Math.abs(dropY + componentHeight / 2 - (e.clientY - rect.top)) < 1,
|
||||
},
|
||||
});
|
||||
|
||||
// 현재 해상도에 맞는 격자 정보 계산
|
||||
const currentGridInfo = layout.gridSettings
|
||||
@@ -1436,36 +1470,45 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
})
|
||||
: null;
|
||||
|
||||
// 캔버스 경계 내로 위치 제한
|
||||
const boundedX = Math.max(0, Math.min(dropX, screenResolution.width - componentWidth));
|
||||
const boundedY = Math.max(0, Math.min(dropY, screenResolution.height - componentHeight));
|
||||
|
||||
// 격자 스냅 적용
|
||||
const snappedPosition =
|
||||
layout.gridSettings?.snapToGrid && currentGridInfo
|
||||
? snapToGrid({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
|
||||
: { x: dropX, y: dropY, z: 1 };
|
||||
? snapToGrid({ x: boundedX, y: boundedY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
|
||||
: { x: boundedX, y: boundedY, z: 1 };
|
||||
|
||||
console.log("🧩 컴포넌트 드롭:", {
|
||||
componentName: component.name,
|
||||
webType: component.webType,
|
||||
dropPosition: { x: dropX, y: dropY },
|
||||
rawPosition: { x: dropX, y: dropY },
|
||||
boundedPosition: { x: boundedX, y: boundedY },
|
||||
snappedPosition,
|
||||
});
|
||||
|
||||
// 새 컴포넌트 생성
|
||||
// 새 컴포넌트 생성 (새 컴포넌트 시스템 지원)
|
||||
console.log("🔍 ScreenDesigner handleComponentDrop:", {
|
||||
componentName: component.name,
|
||||
componentType: component.componentType,
|
||||
componentId: component.id,
|
||||
webType: component.webType,
|
||||
componentConfig: component.componentConfig,
|
||||
finalType: component.componentType || "widget",
|
||||
category: component.category,
|
||||
defaultConfig: component.defaultConfig,
|
||||
});
|
||||
|
||||
const newComponent: ComponentData = {
|
||||
id: generateComponentId(),
|
||||
type: component.componentType || "widget", // 데이터베이스의 componentType 사용
|
||||
type: "widget", // 새 컴포넌트는 모두 widget 타입
|
||||
label: component.name,
|
||||
widgetType: component.webType,
|
||||
componentType: component.id, // 새 컴포넌트 시스템의 ID (DynamicComponentRenderer용)
|
||||
position: snappedPosition,
|
||||
size: component.defaultSize,
|
||||
componentConfig: component.componentConfig || {}, // 데이터베이스의 componentConfig 사용
|
||||
componentConfig: {
|
||||
type: component.id, // 새 컴포넌트 시스템의 ID 사용
|
||||
...component.defaultConfig,
|
||||
},
|
||||
webTypeConfig: getDefaultWebTypeConfig(component.webType),
|
||||
style: {
|
||||
labelDisplay: true,
|
||||
@@ -3043,8 +3086,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
startSelectionDrag(e);
|
||||
}
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
console.log("🎯 캔버스 드롭 이벤트 발생");
|
||||
handleComponentDrop(e);
|
||||
}}
|
||||
>
|
||||
{/* 격자 라인 */}
|
||||
{gridLines.map((line, index) => (
|
||||
@@ -3348,25 +3398,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
height={700}
|
||||
autoHeight={false}
|
||||
>
|
||||
<ComponentsPanel
|
||||
onDragStart={(e, component) => {
|
||||
const dragData = {
|
||||
type: "component",
|
||||
component: {
|
||||
id: component.id,
|
||||
name: component.name,
|
||||
description: component.description,
|
||||
category: component.category,
|
||||
webType: component.webType,
|
||||
componentType: component.componentType, // 추가!
|
||||
componentConfig: component.componentConfig, // 추가!
|
||||
defaultSize: component.defaultSize,
|
||||
},
|
||||
};
|
||||
console.log("🚀 드래그 데이터 설정:", dragData);
|
||||
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
|
||||
}}
|
||||
/>
|
||||
<ComponentsPanel />
|
||||
</FloatingPanel>
|
||||
|
||||
<FloatingPanel
|
||||
|
||||
@@ -1,374 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Plus, Layers, Search, Filter } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useComponents } from "@/hooks/admin/useComponents";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||
import { ComponentDefinition, ComponentCategory } from "@/types/component";
|
||||
import { Search, Package, Grid, Layers, Palette, Zap, RotateCcw } from "lucide-react";
|
||||
|
||||
interface ComponentsPanelProps {
|
||||
onDragStart: (e: React.DragEvent, component: ComponentItem) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ComponentItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
componentType: string;
|
||||
componentConfig: any;
|
||||
webType: string; // webType 추가
|
||||
icon: React.ReactNode;
|
||||
defaultSize: { width: number; height: number };
|
||||
}
|
||||
export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<ComponentCategory | "all">("all");
|
||||
|
||||
// 컴포넌트 카테고리 정의 (실제 생성된 컴포넌트에 맞게)
|
||||
const COMPONENT_CATEGORIES = [
|
||||
{ id: "액션", name: "액션", description: "사용자 동작을 처리하는 컴포넌트" },
|
||||
{ id: "레이아웃", name: "레이아웃", description: "화면 구조를 제공하는 컴포넌트" },
|
||||
{ id: "데이터", name: "데이터", description: "데이터를 표시하는 컴포넌트" },
|
||||
{ id: "네비게이션", name: "네비게이션", description: "화면 이동을 도와주는 컴포넌트" },
|
||||
{ id: "피드백", name: "피드백", description: "사용자 피드백을 제공하는 컴포넌트" },
|
||||
{ id: "입력", name: "입력", description: "사용자 입력을 받는 컴포넌트" },
|
||||
{ id: "표시", name: "표시", description: "정보를 표시하고 알리는 컴포넌트" },
|
||||
{ id: "컨테이너", name: "컨테이너", description: "다른 컴포넌트를 담는 컨테이너" },
|
||||
{ id: "위젯", name: "위젯", description: "범용 위젯 컴포넌트" },
|
||||
{ id: "템플릿", name: "템플릿", description: "미리 정의된 템플릿" },
|
||||
{ id: "차트", name: "차트", description: "데이터 시각화 컴포넌트" },
|
||||
{ id: "폼", name: "폼", description: "폼 관련 컴포넌트" },
|
||||
{ id: "미디어", name: "미디어", description: "이미지, 비디오 등 미디어 컴포넌트" },
|
||||
{ id: "유틸리티", name: "유틸리티", description: "보조 기능 컴포넌트" },
|
||||
{ id: "관리", name: "관리", description: "관리자 전용 컴포넌트" },
|
||||
{ id: "시스템", name: "시스템", description: "시스템 관련 컴포넌트" },
|
||||
{ id: "UI", name: "UI", description: "일반 UI 컴포넌트" },
|
||||
{ id: "컴포넌트", name: "컴포넌트", description: "일반 컴포넌트" },
|
||||
{ id: "기타", name: "기타", description: "기타 컴포넌트" },
|
||||
];
|
||||
// 레지스트리에서 모든 컴포넌트 조회
|
||||
const allComponents = useMemo(() => {
|
||||
return ComponentRegistry.getAllComponents();
|
||||
}, []);
|
||||
|
||||
export const ComponentsPanel: React.FC<ComponentsPanelProps> = ({ onDragStart }) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
||||
// 카테고리별 분류
|
||||
const componentsByCategory = useMemo(() => {
|
||||
const categories: Record<ComponentCategory | "all", ComponentDefinition[]> = {
|
||||
all: allComponents,
|
||||
input: [],
|
||||
display: [],
|
||||
action: [],
|
||||
layout: [],
|
||||
utility: [],
|
||||
};
|
||||
|
||||
// 데이터베이스에서 컴포넌트 가져오기
|
||||
const {
|
||||
data: componentsData,
|
||||
isLoading: loading,
|
||||
error,
|
||||
} = useComponents({
|
||||
active: "Y",
|
||||
});
|
||||
allComponents.forEach((component) => {
|
||||
if (categories[component.category]) {
|
||||
categories[component.category].push(component);
|
||||
}
|
||||
});
|
||||
|
||||
// 컴포넌트를 ComponentItem으로 변환
|
||||
const componentItems = useMemo(() => {
|
||||
if (!componentsData?.components) {
|
||||
console.log("🔍 ComponentsPanel: 컴포넌트 데이터 없음");
|
||||
return [];
|
||||
return categories;
|
||||
}, [allComponents]);
|
||||
|
||||
// 검색 및 필터링된 컴포넌트
|
||||
const filteredComponents = useMemo(() => {
|
||||
let components = componentsByCategory[selectedCategory] || [];
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
components = components.filter(
|
||||
(component) =>
|
||||
component.name.toLowerCase().includes(query) ||
|
||||
component.description.toLowerCase().includes(query) ||
|
||||
component.tags?.some((tag) => tag.toLowerCase().includes(query)),
|
||||
);
|
||||
}
|
||||
|
||||
console.log("🔍 ComponentsPanel 전체 컴포넌트 데이터:", {
|
||||
totalComponents: componentsData.components.length,
|
||||
components: componentsData.components.map((c) => ({
|
||||
code: c.component_code,
|
||||
name: c.component_name,
|
||||
category: c.category,
|
||||
config: c.component_config,
|
||||
})),
|
||||
});
|
||||
return components;
|
||||
}, [componentsByCategory, selectedCategory, searchQuery]);
|
||||
|
||||
return componentsData.components.map((component) => {
|
||||
console.log("🔍 ComponentsPanel 컴포넌트 매핑:", {
|
||||
component_code: component.component_code,
|
||||
component_name: component.component_name,
|
||||
component_config: component.component_config,
|
||||
componentType: component.component_config?.type || component.component_code,
|
||||
webType: component.component_config?.type || component.component_code,
|
||||
category: component.category,
|
||||
});
|
||||
// 드래그 시작 핸들러
|
||||
const handleDragStart = (e: React.DragEvent, component: ComponentDefinition) => {
|
||||
const dragData = {
|
||||
type: "component",
|
||||
component: component,
|
||||
};
|
||||
console.log("🚀 컴포넌트 드래그 시작:", component.name, dragData);
|
||||
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
};
|
||||
|
||||
// 카테고리 매핑 (영어 -> 한국어)
|
||||
const categoryMapping: Record<string, string> = {
|
||||
display: "표시",
|
||||
action: "액션",
|
||||
layout: "레이아웃",
|
||||
data: "데이터",
|
||||
navigation: "네비게이션",
|
||||
feedback: "피드백",
|
||||
input: "입력",
|
||||
container: "컨테이너",
|
||||
widget: "위젯",
|
||||
template: "템플릿",
|
||||
chart: "차트",
|
||||
form: "폼",
|
||||
media: "미디어",
|
||||
utility: "유틸리티",
|
||||
admin: "관리",
|
||||
system: "시스템",
|
||||
ui: "UI",
|
||||
component: "컴포넌트",
|
||||
기타: "기타",
|
||||
other: "기타",
|
||||
// 한국어도 처리
|
||||
표시: "표시",
|
||||
액션: "액션",
|
||||
레이아웃: "레이아웃",
|
||||
데이터: "데이터",
|
||||
네비게이션: "네비게이션",
|
||||
피드백: "피드백",
|
||||
입력: "입력",
|
||||
};
|
||||
// 카테고리별 아이콘
|
||||
const getCategoryIcon = (category: ComponentCategory | "all") => {
|
||||
switch (category) {
|
||||
case "input":
|
||||
return <Grid className="h-4 w-4" />;
|
||||
case "display":
|
||||
return <Palette className="h-4 w-4" />;
|
||||
case "action":
|
||||
return <Zap className="h-4 w-4" />;
|
||||
case "layout":
|
||||
return <Layers className="h-4 w-4" />;
|
||||
case "utility":
|
||||
return <Package className="h-4 w-4" />;
|
||||
default:
|
||||
return <Package className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const mappedCategory = categoryMapping[component.category] || component.category || "other";
|
||||
|
||||
return {
|
||||
id: component.component_code,
|
||||
name: component.component_name,
|
||||
description: component.description || `${component.component_name} 컴포넌트`,
|
||||
category: mappedCategory,
|
||||
componentType: component.component_config?.type || component.component_code,
|
||||
componentConfig: component.component_config,
|
||||
webType: component.component_config?.type || component.component_code, // webType 추가
|
||||
icon: getComponentIcon(component.icon_name || component.component_config?.type),
|
||||
defaultSize: component.default_size || getDefaultSize(component.component_config?.type),
|
||||
};
|
||||
});
|
||||
}, [componentsData]);
|
||||
|
||||
// 필터링된 컴포넌트
|
||||
const filteredComponents = useMemo(() => {
|
||||
return componentItems.filter((component) => {
|
||||
const matchesSearch =
|
||||
component.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
component.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesCategory = selectedCategory === "all" || component.category === selectedCategory;
|
||||
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
}, [componentItems, searchTerm, selectedCategory]);
|
||||
|
||||
// 카테고리별 그룹화
|
||||
const groupedComponents = useMemo(() => {
|
||||
const groups: Record<string, ComponentItem[]> = {};
|
||||
|
||||
COMPONENT_CATEGORIES.forEach((category) => {
|
||||
groups[category.id] = filteredComponents.filter((component) => component.category === category.id);
|
||||
});
|
||||
|
||||
console.log("🔍 카테고리별 그룹화 결과:", {
|
||||
총컴포넌트: filteredComponents.length,
|
||||
카테고리별개수: Object.entries(groups).map(([cat, comps]) => ({ 카테고리: cat, 개수: comps.length })),
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [filteredComponents]);
|
||||
|
||||
console.log("🔍 ComponentsPanel 상태:", {
|
||||
loading,
|
||||
error: error?.message,
|
||||
componentsData,
|
||||
componentItemsLength: componentItems.length,
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Layers className="mx-auto h-8 w-8 animate-pulse text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-500">컴포넌트 로딩 중...</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
API: {process.env.NODE_ENV === "development" ? "http://localhost:8080" : "39.117.244.52:8080"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Layers className="mx-auto h-8 w-8 text-red-400" />
|
||||
<p className="mt-2 text-sm text-red-500">컴포넌트 로드 실패</p>
|
||||
<p className="text-xs text-gray-500">{error.message}</p>
|
||||
<details className="mt-2 text-left">
|
||||
<summary className="cursor-pointer text-xs text-gray-400">상세 오류</summary>
|
||||
<pre className="mt-1 text-xs whitespace-pre-wrap text-gray-400">{JSON.stringify(error, null, 2)}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 컴포넌트 새로고침
|
||||
const handleRefresh = () => {
|
||||
// Hot Reload 트리거 (개발 모드에서만)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
ComponentRegistry.refreshComponents?.();
|
||||
}
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b border-gray-200 p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Layers className="h-4 w-4 text-gray-600" />
|
||||
<h3 className="font-medium text-gray-900">컴포넌트</h3>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{filteredComponents.length}개
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">드래그하여 화면에 추가하세요</p>
|
||||
</div>
|
||||
<Card className={className}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Package className="mr-2 h-5 w-5" />
|
||||
컴포넌트 ({allComponents.length})
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} title="컴포넌트 새로고침">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardTitle>
|
||||
|
||||
{/* 검색 및 필터 */}
|
||||
<div className="space-y-3 border-b border-gray-200 p-4">
|
||||
{/* 검색 */}
|
||||
{/* 검색창 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Search className="text-muted-foreground absolute top-2.5 left-2 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="컴포넌트 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-8 pl-9 text-xs"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="h-4 w-4 text-gray-400" />
|
||||
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 카테고리</SelectItem>
|
||||
{COMPONENT_CATEGORIES.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent>
|
||||
<Tabs
|
||||
value={selectedCategory}
|
||||
onValueChange={(value) => setSelectedCategory(value as ComponentCategory | "all")}
|
||||
>
|
||||
{/* 카테고리 탭 */}
|
||||
<TabsList className="grid w-full grid-cols-3 lg:grid-cols-6">
|
||||
<TabsTrigger value="all" className="flex items-center">
|
||||
<Package className="mr-1 h-3 w-3" />
|
||||
전체
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="input" className="flex items-center">
|
||||
<Grid className="mr-1 h-3 w-3" />
|
||||
입력
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="display" className="flex items-center">
|
||||
<Palette className="mr-1 h-3 w-3" />
|
||||
표시
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="action" className="flex items-center">
|
||||
<Zap className="mr-1 h-3 w-3" />
|
||||
액션
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="layout" className="flex items-center">
|
||||
<Layers className="mr-1 h-3 w-3" />
|
||||
레이아웃
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="utility" className="flex items-center">
|
||||
<Package className="mr-1 h-3 w-3" />
|
||||
유틸
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 컴포넌트 목록 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{selectedCategory === "all" ? (
|
||||
// 카테고리별 그룹 표시
|
||||
<div className="space-y-4 p-4">
|
||||
{COMPONENT_CATEGORIES.map((category) => {
|
||||
const categoryComponents = groupedComponents[category.id];
|
||||
if (categoryComponents.length === 0) return null;
|
||||
{/* 컴포넌트 목록 */}
|
||||
<div className="mt-4">
|
||||
<TabsContent value={selectedCategory} className="space-y-2">
|
||||
{filteredComponents.length > 0 ? (
|
||||
<div className="grid max-h-96 grid-cols-1 gap-2 overflow-y-auto">
|
||||
{filteredComponents.map((component) => (
|
||||
<div
|
||||
key={component.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, component)}
|
||||
className="hover:bg-accent flex cursor-grab items-center rounded-lg border p-3 transition-colors active:cursor-grabbing"
|
||||
title={component.description}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<h4 className="truncate text-sm font-medium">{component.name}</h4>
|
||||
<div className="flex items-center space-x-1">
|
||||
{/* 카테고리 뱃지 */}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{getCategoryIcon(component.category)}
|
||||
<span className="ml-1">{component.category}</span>
|
||||
</Badge>
|
||||
|
||||
return (
|
||||
<div key={category.id}>
|
||||
<div className="mb-2 flex items-center space-x-2">
|
||||
<h4 className="text-sm font-medium text-gray-700">{category.name}</h4>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{categoryComponents.length}개
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mb-3 text-xs text-gray-500">{category.description}</p>
|
||||
<div className="grid gap-2">
|
||||
{categoryComponents.map((component) => (
|
||||
<ComponentCard key={component.id} component={component} onDragStart={onDragStart} />
|
||||
))}
|
||||
</div>
|
||||
{/* 새 컴포넌트 뱃지 */}
|
||||
<Badge variant="default" className="bg-green-500 text-xs">
|
||||
신규
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground truncate text-xs">{component.description}</p>
|
||||
|
||||
{/* 웹타입 및 크기 정보 */}
|
||||
<div className="text-muted-foreground mt-2 flex items-center justify-between text-xs">
|
||||
<span>웹타입: {component.webType}</span>
|
||||
<span>
|
||||
{component.defaultSize.width}×{component.defaultSize.height}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 태그 */}
|
||||
{component.tags && component.tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{component.tags.slice(0, 3).map((tag, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{component.tags.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{component.tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
) : (
|
||||
<div className="text-muted-foreground py-8 text-center">
|
||||
<Package className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="text-sm">
|
||||
{searchQuery
|
||||
? `"${searchQuery}"에 대한 검색 결과가 없습니다.`
|
||||
: "이 카테고리에 컴포넌트가 없습니다."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</div>
|
||||
) : (
|
||||
// 선택된 카테고리만 표시
|
||||
<div className="p-4">
|
||||
<div className="grid gap-2">
|
||||
{filteredComponents.map((component) => (
|
||||
<ComponentCard key={component.id} component={component} onDragStart={onDragStart} />
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{/* 통계 정보 */}
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<div className="grid grid-cols-2 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-bold text-green-600">{filteredComponents.length}</div>
|
||||
<div className="text-muted-foreground text-xs">표시된 컴포넌트</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-blue-600">{allComponents.length}</div>
|
||||
<div className="text-muted-foreground text-xs">전체 컴포넌트</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 개발 정보 (개발 모드에서만) */}
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<div className="text-muted-foreground space-y-1 text-xs">
|
||||
<div>🔧 레지스트리 기반 시스템</div>
|
||||
<div>⚡ Hot Reload 지원</div>
|
||||
<div>🛡️ 완전한 타입 안전성</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredComponents.length === 0 && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Layers className="mx-auto h-8 w-8 text-gray-300" />
|
||||
<p className="mt-2 text-sm text-gray-500">검색 결과가 없습니다</p>
|
||||
<p className="text-xs text-gray-400">다른 검색어를 시도해보세요</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// 컴포넌트 카드 컴포넌트
|
||||
const ComponentCard: React.FC<{
|
||||
component: ComponentItem;
|
||||
onDragStart: (e: React.DragEvent, component: ComponentItem) => void;
|
||||
}> = ({ component, onDragStart }) => {
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, component)}
|
||||
className="group cursor-move rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition-all hover:border-blue-300 hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600 group-hover:bg-blue-100">
|
||||
{component.icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="truncate text-sm font-medium text-gray-900">{component.name}</h4>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-gray-500">{component.description}</p>
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{component.webType}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 웹타입별 아이콘 매핑
|
||||
function getComponentIcon(webType: string): React.ReactNode {
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
text: <span className="text-xs">Aa</span>,
|
||||
number: <span className="text-xs">123</span>,
|
||||
date: <span className="text-xs">📅</span>,
|
||||
select: <span className="text-xs">▼</span>,
|
||||
checkbox: <span className="text-xs">☑</span>,
|
||||
radio: <span className="text-xs">◉</span>,
|
||||
textarea: <span className="text-xs">📝</span>,
|
||||
file: <span className="text-xs">📎</span>,
|
||||
button: <span className="text-xs">🔘</span>,
|
||||
email: <span className="text-xs">📧</span>,
|
||||
tel: <span className="text-xs">📞</span>,
|
||||
password: <span className="text-xs">🔒</span>,
|
||||
code: <span className="text-xs"><></span>,
|
||||
entity: <span className="text-xs">🔗</span>,
|
||||
};
|
||||
|
||||
return iconMap[webType] || <span className="text-xs">⚪</span>;
|
||||
}
|
||||
|
||||
// 웹타입별 기본 크기
|
||||
function getDefaultSize(webType: string): { width: number; height: number } {
|
||||
const sizeMap: Record<string, { width: number; height: number }> = {
|
||||
text: { width: 200, height: 36 },
|
||||
number: { width: 150, height: 36 },
|
||||
date: { width: 180, height: 36 },
|
||||
select: { width: 200, height: 36 },
|
||||
checkbox: { width: 150, height: 36 },
|
||||
radio: { width: 200, height: 80 },
|
||||
textarea: { width: 300, height: 100 },
|
||||
file: { width: 300, height: 120 },
|
||||
button: { width: 120, height: 36 },
|
||||
email: { width: 250, height: 36 },
|
||||
tel: { width: 180, height: 36 },
|
||||
password: { width: 200, height: 36 },
|
||||
code: { width: 200, height: 36 },
|
||||
entity: { width: 200, height: 36 },
|
||||
};
|
||||
|
||||
return sizeMap[webType] || { width: 200, height: 36 };
|
||||
}
|
||||
|
||||
export default ComponentsPanel;
|
||||
|
||||
Reference in New Issue
Block a user