186 lines
5.7 KiB
TypeScript
186 lines
5.7 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 플로우 에디터 상단 툴바
|
|
*/
|
|
|
|
import { useState } from "react";
|
|
import { Save, Undo2, Redo2, ZoomIn, ZoomOut, Download, Trash2 } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
|
import { useReactFlow } from "reactflow";
|
|
import { SaveConfirmDialog } from "./dialogs/SaveConfirmDialog";
|
|
import { validateFlow, summarizeValidations } from "@/lib/utils/flowValidation";
|
|
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
|
|
interface FlowToolbarProps {
|
|
validations?: FlowValidation[];
|
|
}
|
|
|
|
export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
|
|
const { toast } = useToast();
|
|
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
|
const {
|
|
flowName,
|
|
setFlowName,
|
|
nodes,
|
|
edges,
|
|
saveFlow,
|
|
exportFlow,
|
|
isSaving,
|
|
selectedNodes,
|
|
removeNodes,
|
|
undo,
|
|
redo,
|
|
canUndo,
|
|
canRedo,
|
|
} = useFlowEditorStore();
|
|
|
|
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
|
|
|
const handleSave = async () => {
|
|
// 검증 수행
|
|
const currentValidations = validations.length > 0 ? validations : validateFlow(nodes, edges);
|
|
const summary = summarizeValidations(currentValidations);
|
|
|
|
// 오류나 경고가 있으면 다이얼로그 표시
|
|
if (currentValidations.length > 0) {
|
|
setShowSaveDialog(true);
|
|
return;
|
|
}
|
|
|
|
// 문제 없으면 바로 저장
|
|
await performSave();
|
|
};
|
|
|
|
const performSave = async () => {
|
|
const result = await saveFlow();
|
|
if (result.success) {
|
|
toast({
|
|
title: "✅ 플로우 저장 완료",
|
|
description: `${result.message}\nFlow ID: ${result.flowId}`,
|
|
variant: "default",
|
|
});
|
|
} else {
|
|
toast({
|
|
title: "❌ 저장 실패",
|
|
description: result.message,
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
setShowSaveDialog(false);
|
|
};
|
|
|
|
const handleExport = () => {
|
|
const json = exportFlow();
|
|
const blob = new Blob([json], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `${flowName || "flow"}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
toast({
|
|
title: "✅ 내보내기 완료",
|
|
description: "JSON 파일로 저장되었습니다.",
|
|
variant: "default",
|
|
});
|
|
};
|
|
|
|
const handleDelete = () => {
|
|
if (selectedNodes.length === 0) {
|
|
toast({
|
|
title: "⚠️ 선택된 노드 없음",
|
|
description: "삭제할 노드를 선택해주세요.",
|
|
variant: "default",
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (confirm(`선택된 ${selectedNodes.length}개 노드를 삭제하시겠습니까?`)) {
|
|
removeNodes(selectedNodes);
|
|
toast({
|
|
title: "✅ 노드 삭제 완료",
|
|
description: `${selectedNodes.length}개 노드가 삭제되었습니다.`,
|
|
variant: "default",
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="flex items-center gap-2 rounded-lg border bg-white p-2 shadow-md">
|
|
{/* 플로우 이름 */}
|
|
<Input
|
|
value={flowName}
|
|
onChange={(e) => setFlowName(e.target.value)}
|
|
className="h-8 w-[200px] text-sm"
|
|
placeholder="플로우 이름"
|
|
/>
|
|
|
|
<div className="h-6 w-px bg-gray-200" />
|
|
|
|
{/* 실행 취소/다시 실행 */}
|
|
<Button variant="ghost" size="sm" title="실행 취소 (Ctrl+Z)" disabled={!canUndo()} onClick={undo}>
|
|
<Undo2 className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="sm" title="다시 실행 (Ctrl+Y)" disabled={!canRedo()} onClick={redo}>
|
|
<Redo2 className="h-4 w-4" />
|
|
</Button>
|
|
|
|
<div className="h-6 w-px bg-gray-200" />
|
|
|
|
{/* 삭제 버튼 */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleDelete}
|
|
disabled={selectedNodes.length === 0}
|
|
title={selectedNodes.length > 0 ? `${selectedNodes.length}개 노드 삭제` : "삭제할 노드를 선택하세요"}
|
|
className="gap-1 text-red-600 hover:bg-red-50 hover:text-red-700 disabled:opacity-50"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
{selectedNodes.length > 0 && <span className="text-xs">({selectedNodes.length})</span>}
|
|
</Button>
|
|
|
|
<div className="h-6 w-px bg-gray-200" />
|
|
|
|
{/* 줌 컨트롤 */}
|
|
<Button variant="ghost" size="sm" onClick={() => zoomIn()} title="확대">
|
|
<ZoomIn className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => zoomOut()} title="축소">
|
|
<ZoomOut className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => fitView()} title="전체 보기">
|
|
<span className="text-xs">전체</span>
|
|
</Button>
|
|
|
|
<div className="h-6 w-px bg-gray-200" />
|
|
|
|
{/* 저장 */}
|
|
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="gap-1">
|
|
<Save className="h-4 w-4" />
|
|
<span className="text-xs">{isSaving ? "저장 중..." : "저장"}</span>
|
|
</Button>
|
|
|
|
{/* 내보내기 */}
|
|
<Button variant="outline" size="sm" onClick={handleExport} className="gap-1">
|
|
<Download className="h-4 w-4" />
|
|
<span className="text-xs">JSON</span>
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 저장 확인 다이얼로그 */}
|
|
<SaveConfirmDialog
|
|
open={showSaveDialog}
|
|
validations={validations.length > 0 ? validations : validateFlow(nodes, edges)}
|
|
onConfirm={performSave}
|
|
onCancel={() => setShowSaveDialog(false)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|