레이아웃 추가기능
This commit is contained in:
534
frontend/components/admin/LayoutFormModal.tsx
Normal file
534
frontend/components/admin/LayoutFormModal.tsx
Normal file
@@ -0,0 +1,534 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
Grid,
|
||||
Layout,
|
||||
Navigation,
|
||||
Building,
|
||||
FileText,
|
||||
Table,
|
||||
LayoutDashboard,
|
||||
Plus,
|
||||
Minus,
|
||||
Info,
|
||||
Wand2,
|
||||
} from "lucide-react";
|
||||
import { LayoutCategory } from "@/types/layout";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface LayoutFormModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
// 카테고리 정의
|
||||
const CATEGORIES = [
|
||||
{ id: "basic", name: "기본", icon: Grid, description: "그리드, 플렉스박스 등 기본 레이아웃" },
|
||||
{ id: "navigation", name: "네비게이션", icon: Navigation, description: "메뉴, 탭, 아코디언 등" },
|
||||
{ id: "business", name: "비즈니스", icon: Building, description: "대시보드, 차트, 리포트 등" },
|
||||
{ id: "form", name: "폼", icon: FileText, description: "입력 폼, 설정 패널 등" },
|
||||
{ id: "table", name: "테이블", icon: Table, description: "데이터 테이블, 목록 등" },
|
||||
{ id: "dashboard", name: "대시보드", icon: LayoutDashboard, description: "위젯, 카드 레이아웃 등" },
|
||||
] as const;
|
||||
|
||||
// 레이아웃 템플릿 정의
|
||||
const LAYOUT_TEMPLATES = [
|
||||
{
|
||||
id: "2-column",
|
||||
name: "2열 레이아웃",
|
||||
description: "좌우 2개 영역으로 구성",
|
||||
zones: 2,
|
||||
example: "사이드바 + 메인 콘텐츠",
|
||||
icon: "▢ ▢",
|
||||
},
|
||||
{
|
||||
id: "3-column",
|
||||
name: "3열 레이아웃",
|
||||
description: "좌측, 중앙, 우측 3개 영역",
|
||||
zones: 3,
|
||||
example: "네비 + 콘텐츠 + 사이드",
|
||||
icon: "▢ ▢ ▢",
|
||||
},
|
||||
{
|
||||
id: "header-content",
|
||||
name: "헤더-콘텐츠",
|
||||
description: "상단 헤더 + 하단 콘텐츠",
|
||||
zones: 2,
|
||||
example: "제목 + 내용 영역",
|
||||
icon: "▬\n▢",
|
||||
},
|
||||
{
|
||||
id: "card-grid",
|
||||
name: "카드 그리드",
|
||||
description: "2x2 카드 격자 구조",
|
||||
zones: 4,
|
||||
example: "대시보드, 통계 패널",
|
||||
icon: "▢▢\n▢▢",
|
||||
},
|
||||
{
|
||||
id: "accordion",
|
||||
name: "아코디언",
|
||||
description: "접고 펼칠 수 있는 섹션들",
|
||||
zones: 3,
|
||||
example: "FAQ, 설정 패널",
|
||||
icon: "▷ ▽ ▷",
|
||||
},
|
||||
{
|
||||
id: "tabs",
|
||||
name: "탭 레이아웃",
|
||||
description: "탭으로 구성된 다중 패널",
|
||||
zones: 3,
|
||||
example: "설정, 상세 정보",
|
||||
icon: "[Tab1][Tab2][Tab3]",
|
||||
},
|
||||
];
|
||||
|
||||
export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenChange, onSuccess }) => {
|
||||
const [step, setStep] = useState<"basic" | "template" | "advanced">("basic");
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
nameEng: "",
|
||||
description: "",
|
||||
category: "" as LayoutCategory | "",
|
||||
zones: 2,
|
||||
template: "",
|
||||
author: "Developer",
|
||||
});
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [generationResult, setGenerationResult] = useState<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
files?: string[];
|
||||
} | null>(null);
|
||||
|
||||
const handleReset = () => {
|
||||
setStep("basic");
|
||||
setFormData({
|
||||
name: "",
|
||||
nameEng: "",
|
||||
description: "",
|
||||
category: "",
|
||||
zones: 2,
|
||||
template: "",
|
||||
author: "Developer",
|
||||
});
|
||||
setGenerationResult(null);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
handleReset();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (step === "basic") {
|
||||
setStep("template");
|
||||
} else if (step === "template") {
|
||||
setStep("advanced");
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (step === "template") {
|
||||
setStep("basic");
|
||||
} else if (step === "advanced") {
|
||||
setStep("template");
|
||||
}
|
||||
};
|
||||
|
||||
const validateBasic = () => {
|
||||
return formData.name.trim() && formData.category && formData.description.trim();
|
||||
};
|
||||
|
||||
const validateTemplate = () => {
|
||||
return formData.template && formData.zones > 0;
|
||||
};
|
||||
|
||||
const generateLayout = async () => {
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
|
||||
// CLI 명령어 구성
|
||||
const command = [
|
||||
formData.name.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
|
||||
`--category=${formData.category}`,
|
||||
`--zones=${formData.zones}`,
|
||||
`--description="${formData.description}"`,
|
||||
formData.author !== "Developer" ? `--author="${formData.author}"` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
// API 호출로 CLI 명령어 실행
|
||||
const response = await fetch("/api/admin/layouts/generate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
command,
|
||||
layoutData: formData,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setGenerationResult({
|
||||
success: true,
|
||||
message: "레이아웃이 성공적으로 생성되었습니다!",
|
||||
files: result.files || [],
|
||||
});
|
||||
|
||||
toast.success("레이아웃 생성 완료");
|
||||
|
||||
// 3초 후 자동으로 모달 닫고 새로고침
|
||||
setTimeout(() => {
|
||||
handleClose();
|
||||
onSuccess();
|
||||
}, 3000);
|
||||
} else {
|
||||
setGenerationResult({
|
||||
success: false,
|
||||
message: result.message || "레이아웃 생성에 실패했습니다.",
|
||||
});
|
||||
toast.error("레이아웃 생성 실패");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("레이아웃 생성 오류:", error);
|
||||
setGenerationResult({
|
||||
success: false,
|
||||
message: "서버 오류가 발생했습니다.",
|
||||
});
|
||||
toast.error("서버 오류");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Wand2 className="h-5 w-5" />새 레이아웃 생성
|
||||
</DialogTitle>
|
||||
<DialogDescription>GUI를 통해 새로운 레이아웃을 쉽게 생성할 수 있습니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 단계 표시기 */}
|
||||
<div className="mb-6 flex items-center justify-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={`flex items-center gap-2 ${step === "basic" ? "text-blue-600" : step === "template" || step === "advanced" ? "text-green-600" : "text-gray-400"}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "basic" ? "bg-blue-100 text-blue-600" : step === "template" || step === "advanced" ? "bg-green-100 text-green-600" : "bg-gray-100"}`}
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<span className="text-sm font-medium">기본 정보</span>
|
||||
</div>
|
||||
<div className="h-px w-8 bg-gray-300" />
|
||||
<div
|
||||
className={`flex items-center gap-2 ${step === "template" ? "text-blue-600" : step === "advanced" ? "text-green-600" : "text-gray-400"}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "template" ? "bg-blue-100 text-blue-600" : step === "advanced" ? "bg-green-100 text-green-600" : "bg-gray-100"}`}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<span className="text-sm font-medium">템플릿 선택</span>
|
||||
</div>
|
||||
<div className="h-px w-8 bg-gray-300" />
|
||||
<div className={`flex items-center gap-2 ${step === "advanced" ? "text-blue-600" : "text-gray-400"}`}>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "advanced" ? "bg-blue-100 text-blue-600" : "bg-gray-100"}`}
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<span className="text-sm font-medium">고급 설정</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 단계별 컨텐츠 */}
|
||||
<div className="space-y-6">
|
||||
{step === "basic" && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">레이아웃 이름 *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="예: 사이드바, 대시보드, 카드그리드"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nameEng">영문 이름</Label>
|
||||
<Input
|
||||
id="nameEng"
|
||||
placeholder="예: Sidebar, Dashboard, CardGrid"
|
||||
value={formData.nameEng}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, nameEng: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>카테고리 *</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{CATEGORIES.map((category) => {
|
||||
const IconComponent = category.icon;
|
||||
return (
|
||||
<Card
|
||||
key={category.id}
|
||||
className={`cursor-pointer transition-all ${
|
||||
formData.category === category.id ? "bg-blue-50 ring-2 ring-blue-500" : "hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => setFormData((prev) => ({ ...prev, category: category.id }))}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<IconComponent className="h-5 w-5 text-gray-600" />
|
||||
<div>
|
||||
<div className="font-medium">{category.name}</div>
|
||||
<div className="text-xs text-gray-500">{category.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명 *</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="레이아웃의 용도와 특징을 설명해주세요..."
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "template" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>레이아웃 템플릿 *</Label>
|
||||
<p className="mb-3 text-sm text-gray-500">원하는 레이아웃 구조를 선택하세요</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{LAYOUT_TEMPLATES.map((template) => (
|
||||
<Card
|
||||
key={template.id}
|
||||
className={`cursor-pointer transition-all ${
|
||||
formData.template === template.id ? "bg-blue-50 ring-2 ring-blue-500" : "hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
template: template.id,
|
||||
zones: template.zones,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium">{template.name}</div>
|
||||
<Badge variant="secondary">{template.zones}개 영역</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">{template.description}</div>
|
||||
<div className="text-xs text-gray-500">예: {template.example}</div>
|
||||
<div className="rounded bg-gray-100 p-2 text-center font-mono text-xs">{template.icon}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="zones">영역 개수</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
zones: Math.max(1, prev.zones - 1),
|
||||
}))
|
||||
}
|
||||
disabled={formData.zones <= 1}
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Input
|
||||
id="zones"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={formData.zones}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
zones: parseInt(e.target.value) || 1,
|
||||
}))
|
||||
}
|
||||
className="w-20 text-center"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
zones: Math.min(10, prev.zones + 1),
|
||||
}))
|
||||
}
|
||||
disabled={formData.zones >= 10}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500">개 영역</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "advanced" && (
|
||||
<div className="space-y-4">
|
||||
{generationResult ? (
|
||||
<Alert
|
||||
className={generationResult.success ? "border-green-200 bg-green-50" : "border-red-200 bg-red-50"}
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className={generationResult.success ? "text-green-800" : "text-red-800"}>
|
||||
{generationResult.message}
|
||||
{generationResult.success && generationResult.files && (
|
||||
<div className="mt-2">
|
||||
<div className="text-sm font-medium">생성된 파일들:</div>
|
||||
<ul className="mt-1 space-y-1 text-xs">
|
||||
{generationResult.files.map((file, index) => (
|
||||
<li key={index} className="text-green-700">
|
||||
• {file}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="author">작성자</Label>
|
||||
<Input
|
||||
id="author"
|
||||
value={formData.author}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, author: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">생성 미리보기</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div>
|
||||
<strong>이름:</strong> {formData.name || "이름 없음"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>카테고리:</strong>{" "}
|
||||
{CATEGORIES.find((c) => c.id === formData.category)?.name || "선택 안됨"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>템플릿:</strong>{" "}
|
||||
{LAYOUT_TEMPLATES.find((t) => t.id === formData.template)?.name || "선택 안됨"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>영역 개수:</strong> {formData.zones}개
|
||||
</div>
|
||||
<div>
|
||||
<strong>생성될 파일:</strong>
|
||||
</div>
|
||||
<ul className="ml-4 space-y-1 text-xs text-gray-600">
|
||||
<li>• {formData.name.toLowerCase()}/index.ts</li>
|
||||
<li>
|
||||
• {formData.name.toLowerCase()}/{formData.name}Layout.tsx
|
||||
</li>
|
||||
<li>
|
||||
• {formData.name.toLowerCase()}/{formData.name}LayoutRenderer.tsx
|
||||
</li>
|
||||
<li>• {formData.name.toLowerCase()}/config.ts</li>
|
||||
<li>• {formData.name.toLowerCase()}/types.ts</li>
|
||||
<li>• {formData.name.toLowerCase()}/README.md</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
{step !== "basic" && !generationResult && (
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
이전
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{step === "basic" && (
|
||||
<Button onClick={handleNext} disabled={!validateBasic()}>
|
||||
다음
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{step === "template" && (
|
||||
<Button onClick={handleNext} disabled={!validateTemplate()}>
|
||||
다음
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{step === "advanced" && !generationResult && (
|
||||
<Button onClick={generateLayout} disabled={isGenerating}>
|
||||
{isGenerating ? "생성 중..." : "레이아웃 생성"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{generationResult?.success ? "완료" : "취소"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user