284 lines
11 KiB
TypeScript
284 lines
11 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import React, { useState, useMemo } from "react";
|
||
|
|
import { Plus, Layers, Search, Filter } from "lucide-react";
|
||
|
|
import { Badge } from "@/components/ui/badge";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
|
|
import { useComponents } from "@/hooks/admin/useComponents";
|
||
|
|
|
||
|
|
interface ComponentsPanelProps {
|
||
|
|
onDragStart: (e: React.DragEvent, component: ComponentItem) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ComponentItem {
|
||
|
|
id: string;
|
||
|
|
name: string;
|
||
|
|
description: string;
|
||
|
|
category: string;
|
||
|
|
componentType: string;
|
||
|
|
componentConfig: any;
|
||
|
|
icon: React.ReactNode;
|
||
|
|
defaultSize: { width: number; height: number };
|
||
|
|
}
|
||
|
|
|
||
|
|
// 컴포넌트 카테고리 정의 (실제 생성된 컴포넌트에 맞게)
|
||
|
|
const COMPONENT_CATEGORIES = [
|
||
|
|
{ id: "action", name: "액션", description: "사용자 동작을 처리하는 컴포넌트" },
|
||
|
|
{ id: "layout", name: "레이아웃", description: "화면 구조를 제공하는 컴포넌트" },
|
||
|
|
{ id: "data", name: "데이터", description: "데이터를 표시하는 컴포넌트" },
|
||
|
|
{ id: "navigation", name: "네비게이션", description: "화면 이동을 도와주는 컴포넌트" },
|
||
|
|
{ id: "feedback", name: "피드백", description: "사용자 피드백을 제공하는 컴포넌트" },
|
||
|
|
{ id: "input", name: "입력", description: "사용자 입력을 받는 컴포넌트" },
|
||
|
|
{ id: "other", name: "기타", description: "기타 컴포넌트" },
|
||
|
|
];
|
||
|
|
|
||
|
|
export const ComponentsPanel: React.FC<ComponentsPanelProps> = ({ onDragStart }) => {
|
||
|
|
const [searchTerm, setSearchTerm] = useState("");
|
||
|
|
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
||
|
|
|
||
|
|
// 데이터베이스에서 컴포넌트 가져오기
|
||
|
|
const {
|
||
|
|
data: componentsData,
|
||
|
|
isLoading: loading,
|
||
|
|
error,
|
||
|
|
} = useComponents({
|
||
|
|
active: "Y",
|
||
|
|
});
|
||
|
|
|
||
|
|
// 컴포넌트를 ComponentItem으로 변환
|
||
|
|
const componentItems = useMemo(() => {
|
||
|
|
if (!componentsData?.components) return [];
|
||
|
|
|
||
|
|
return componentsData.components.map((component) => ({
|
||
|
|
id: component.component_code,
|
||
|
|
name: component.component_name,
|
||
|
|
description: component.description || `${component.component_name} 컴포넌트`,
|
||
|
|
category: component.category || "other",
|
||
|
|
componentType: component.component_config?.type || component.component_code,
|
||
|
|
componentConfig: component.component_config,
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
|
||
|
|
return groups;
|
||
|
|
}, [filteredComponents]);
|
||
|
|
|
||
|
|
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>
|
||
|
|
</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>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
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>
|
||
|
|
|
||
|
|
{/* 검색 및 필터 */}
|
||
|
|
<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" />
|
||
|
|
<Input
|
||
|
|
placeholder="컴포넌트 검색..."
|
||
|
|
value={searchTerm}
|
||
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||
|
|
className="h-8 pl-9 text-xs"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 카테고리 필터 */}
|
||
|
|
<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>
|
||
|
|
|
||
|
|
{/* 컴포넌트 목록 */}
|
||
|
|
<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;
|
||
|
|
|
||
|
|
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>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
// 선택된 카테고리만 표시
|
||
|
|
<div className="p-4">
|
||
|
|
<div className="grid gap-2">
|
||
|
|
{filteredComponents.map((component) => (
|
||
|
|
<ComponentCard key={component.id} component={component} onDragStart={onDragStart} />
|
||
|
|
))}
|
||
|
|
</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>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 컴포넌트 카드 컴포넌트
|
||
|
|
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;
|