템플릿관리, 컴포넌트 관리
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
"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;
|
||||
Reference in New Issue
Block a user