| "use client"; |
|
|
| import { useCallback, useState, useEffect, useRef } from "react"; |
| import ReactFlow, { |
| Node, |
| Edge, |
| addEdge, |
| Connection, |
| useNodesState, |
| useEdgesState, |
| Controls, |
| MiniMap, |
| Background, |
| BackgroundVariant, |
| NodeMouseHandler, |
| SelectionMode, |
| ReactFlowInstance, |
| } from "reactflow"; |
| import "reactflow/dist/style.css"; |
| import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; |
| import { Button } from "@/components/ui/button"; |
| import { Plus, Save, Play, Copy, Trash2, Undo, Redo, FileText, Loader2, Download, Upload, HelpCircle, BookOpen, Hand, MousePointer2, Sparkles, TerminalIcon, X } from "lucide-react"; |
| import { AiWorkflowDialog } from "./ai-workflow-dialog"; |
| import { ImportWorkflowDialog } from "./import-workflow-dialog"; |
| import { NodeConfigDialog } from "./node-config-dialog"; |
| import { WorkflowNode } from "./workflow-node"; |
| import { WorkflowTemplatesDialog } from "./workflow-templates-dialog"; |
| import { WorkflowGuideDialog } from "./workflow-guide-dialog"; |
| import { useToast } from "@/hooks/use-toast"; |
| import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; |
| import { useUndoRedo } from "./use-undo-redo"; |
|
|
|
|
| const nodeTypes = { |
| workflowNode: WorkflowNode, |
| }; |
|
|
| export interface NodeData { |
| label: string; |
| type: "start" | "condition" | "template" | "delay" | "custom" | "gemini" | "apiRequest" | "agent" | "webhook" | "schedule" | "merge" | "splitInBatches" | "filter" | "set" | "scraper" | "linkedinScraper" | "linkedinMessage" | "abSplit" | "whatsappNode" | "database" | "social_post" | "social_reply" | "social_monitor"; |
| config?: { |
| templateId?: string; |
| delayHours?: number; |
| condition?: string; |
| customCode?: string; |
| aiPrompt?: string; |
| |
| url?: string; |
| method?: "GET" | "POST" | "PUT" | "DELETE"; |
| headers?: string; |
| body?: string; |
| |
| agentPrompt?: string; |
| agentContext?: string; |
| |
| webhookMethod?: "GET" | "POST"; |
| scheduleCron?: string; |
| filterCondition?: string; |
| setVariables?: Record<string, string>; |
| |
| scraperAction?: "summarize" | "extract-emails" | "clean-html" | "markdown" | "fetch-url"; |
| scraperInputField?: string; |
| |
| preventDuplicates?: boolean; |
| cooldownDays?: number; |
| |
| linkedinKeywords?: string; |
| linkedinLocation?: string; |
| profileUrl?: string; |
| messageBody?: string; |
| |
| abSplitWeight?: number; |
| |
| templateName?: string; |
| variables?: string[]; |
| |
| operation?: string; |
| tableName?: string; |
| data?: string; |
| |
| platforms?: string[]; |
| content?: string; |
| mediaUrl?: string; |
| accountId?: string; |
| platform?: string; |
| triggerType?: string; |
| keywords?: string[]; |
| responseTemplate?: string; |
| actionType?: string; |
| monitorType?: string; |
| saveToVariable?: string; |
| }; |
| isConnected?: boolean; |
| } |
|
|
|
|
|
|
| interface NodeEditorProps { |
| initialNodes?: Node<NodeData>[]; |
| initialEdges?: Edge[]; |
| onSave?: (nodes: Node<NodeData>[], edges: Edge[]) => void; |
| isSaving?: boolean; |
| workflowId?: string; |
| } |
|
|
| export function NodeEditor({ |
| initialNodes = [], |
| initialEdges = [], |
| onSave, |
| isSaving = false, |
| workflowId, |
| }: NodeEditorProps) { |
| const [nodes, setNodes, onNodesChange] = useNodesState<NodeData>(initialNodes); |
| const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); |
|
|
| |
| useEffect(() => { |
| setNodes((nds) => |
| nds.map((node) => { |
| const isConnected = edges.some( |
| (edge) => edge.source === node.id || edge.target === node.id |
| ); |
| return { |
| ...node, |
| data: { |
| ...node.data, |
| isConnected, |
| }, |
| }; |
| }) |
| ); |
| }, [edges, setNodes]); |
|
|
| const [selectedNode, setSelectedNode] = useState<Node<NodeData> | null>(null); |
| const [selectedNodes, setSelectedNodes] = useState<Node<NodeData>[]>([]); |
| const [isConfigOpen, setIsConfigOpen] = useState(false); |
| const [isTemplatesOpen, setIsTemplatesOpen] = useState(false); |
|
|
| const [isGuideOpen, setIsGuideOpen] = useState(false); |
| const [isImportOpen, setIsImportOpen] = useState(false); |
| const [isAiDialogOpen, setIsAiDialogOpen] = useState(false); |
| const [contextMenu, setContextMenu] = useState<{ |
| x: number; |
| y: number; |
| nodeId?: string; |
| edgeId?: string; |
| } | null>(null); |
| const [isExecuting, setIsExecuting] = useState(false); |
| const [copiedNodes, setCopiedNodes] = useState<Node<NodeData>[]>([]); |
| const [executionLogs, setExecutionLogs] = useState<string[]>([]); |
| const [showTerminal, setShowTerminal] = useState(false); |
| const [canvasMode, setCanvasMode] = useState<'drag' | 'select'>('drag'); |
| const { toast } = useToast(); |
|
|
| |
| const { undo, redo, canUndo, canRedo, takeSnapshot } = useUndoRedo(initialNodes, initialEdges); |
|
|
| |
| const handleUndo = useCallback(() => { |
| const prevState = undo(nodes, edges); |
| if (prevState) { |
| setNodes(prevState.nodes); |
| setEdges(prevState.edges); |
| } |
| }, [undo, nodes, edges, setNodes, setEdges]); |
|
|
| |
| const handleRedo = useCallback(() => { |
| const nextState = redo(nodes, edges); |
| if (nextState) { |
| setNodes(nextState.nodes); |
| setEdges(nextState.edges); |
| } |
| }, [redo, nodes, edges, setNodes, setEdges]); |
|
|
| |
| useEffect(() => { |
| const handleKeyDown = (e: KeyboardEvent) => { |
| |
| if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { |
| e.preventDefault(); |
| handleUndo(); |
| } |
| |
| if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { |
| e.preventDefault(); |
| handleRedo(); |
| } |
| |
| if ((e.ctrlKey || e.metaKey) && e.key === 's') { |
| e.preventDefault(); |
| if (onSave) { |
| onSave(nodes, edges); |
| toast({ title: "Saved", description: "Workflow saved successfully." }); |
| } |
| } |
| }; |
|
|
| window.addEventListener('keydown', handleKeyDown); |
| return () => window.removeEventListener('keydown', handleKeyDown); |
| }, [handleUndo, handleRedo, onSave, nodes, edges, toast]); |
|
|
| |
| |
| |
|
|
| |
|
|
| |
| const handlePasteNodeRef = useRef<(() => void) | null>(null); |
|
|
| const handlePasteNode = useCallback(() => { |
| if (copiedNodes.length === 0) return; |
|
|
| takeSnapshot(nodes, edges); |
|
|
| const newNodes = copiedNodes.map((node) => ({ |
| ...node, |
| id: `${node.data.type}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, |
| position: { |
| x: node.position.x + 50, |
| y: node.position.y + 50, |
| }, |
| selected: true, |
| })); |
|
|
| setNodes((nds) => [...nds.map(n => ({ ...n, selected: false })), ...newNodes]); |
| setSelectedNodes(newNodes); |
| }, [copiedNodes, setNodes, nodes, edges, takeSnapshot]); |
|
|
| |
| const handleDeleteSelectedNodeRef = useRef<(() => void) | null>(null); |
|
|
| const handleDeleteSelectedNode = useCallback(() => { |
| if (selectedNodes.length > 0 || selectedNode) { |
| takeSnapshot(nodes, edges); |
|
|
| const idsToDelete = selectedNodes.length > 0 |
| ? selectedNodes.map(n => n.id) |
| : (selectedNode ? [selectedNode.id] : []); |
|
|
| if (idsToDelete.length > 0) { |
| setNodes((nds) => nds.filter((n) => !idsToDelete.includes(n.id))); |
| setEdges((eds) => |
| eds.filter((e) => !idsToDelete.includes(e.source) && !idsToDelete.includes(e.target)) |
| ); |
| setSelectedNodes([]); |
| setSelectedNode(null); |
| } |
| } |
| }, [selectedNode, selectedNodes, setNodes, setEdges, takeSnapshot, nodes, edges]); |
|
|
| |
| useEffect(() => { |
| handlePasteNodeRef.current = handlePasteNode; |
| handleDeleteSelectedNodeRef.current = handleDeleteSelectedNode; |
| }, [handlePasteNode, handleDeleteSelectedNode]); |
|
|
| |
| useEffect(() => { |
| const handleKeyDown = (e: KeyboardEvent) => { |
| |
| if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { |
| e.preventDefault(); |
| handleUndo(); |
| } |
| |
| if (((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z') || |
| ((e.ctrlKey || e.metaKey) && e.key === 'y')) { |
| e.preventDefault(); |
| handleRedo(); |
| } |
| |
| if ((e.ctrlKey || e.metaKey) && e.key === 'c') { |
| if (selectedNodes.length > 0) { |
| e.preventDefault(); |
| setCopiedNodes(selectedNodes); |
| } else if (selectedNode) { |
| e.preventDefault(); |
| setCopiedNodes([selectedNode]); |
| } |
| } |
| |
| if ((e.ctrlKey || e.metaKey) && e.key === 'v' && copiedNodes.length > 0) { |
| e.preventDefault(); |
| handlePasteNodeRef.current?.(); |
| } |
| |
| if (e.key === 'Delete' && (selectedNode || selectedNodes.length > 0)) { |
| e.preventDefault(); |
| handleDeleteSelectedNodeRef.current?.(); |
| } |
| |
| if ((e.ctrlKey || e.metaKey) && e.key === 'a') { |
| e.preventDefault(); |
| setNodes((nds) => nds.map((n) => ({ ...n, selected: true }))); |
| setSelectedNodes(nodes); |
| } |
| }; |
|
|
| window.addEventListener('keydown', handleKeyDown); |
| return () => window.removeEventListener('keydown', handleKeyDown); |
| }, [selectedNode, selectedNodes, copiedNodes, handleUndo, handleRedo, nodes, setNodes]); |
|
|
| const onConnect = useCallback( |
| (params: Connection) => { |
| takeSnapshot(nodes, edges); |
| setEdges((eds) => addEdge(params, eds)); |
| }, |
| [setEdges, takeSnapshot, nodes, edges] |
| ); |
|
|
| const [rfInstance, setRfInstance] = useState<ReactFlowInstance | null>(null); |
|
|
| const addNode = useCallback( |
| (type: NodeData["type"]) => { |
| const nodeLabels = { |
| start: "Start", |
| condition: "Condition", |
| template: "Send Email", |
| delay: "Delay", |
| custom: "Custom Function", |
| gemini: "AI Task", |
| apiRequest: "API Request", |
| agent: "Agent with Excel", |
| webhook: "Webhook", |
| schedule: "Schedule", |
| merge: "Merge", |
| splitInBatches: "Loop", |
| filter: "Filter", |
| set: "Set Variables", |
| scraper: "Scraper Action", |
| linkedinScraper: "LinkedIn Scraper", |
| linkedinMessage: "LinkedIn Message", |
| abSplit: "A/B Split", |
| whatsappNode: "Send WhatsApp", |
| database: "Database Operation", |
| social_post: "Social Post", |
| social_reply: "Social Reply", |
| social_monitor: "Social Monitor", |
| }; |
|
|
| |
| let position = { x: 250, y: 250 }; |
| if (rfInstance) { |
| const { x, y, zoom } = rfInstance.getViewport(); |
| |
| |
| |
| |
| |
| |
| const centerX = (-x + (window.innerWidth / 2)) / zoom; |
| const centerY = (-y + (window.innerHeight / 2)) / zoom; |
| position = { x: centerX - 100 + (Math.random() * 50), y: centerY - 50 + (Math.random() * 50) }; |
| } |
|
|
| const newNode: Node<NodeData> = { |
| id: `${type}-${Date.now()}`, |
| type: "workflowNode", |
| data: { |
| label: nodeLabels[type], |
| type, |
| config: {}, |
| }, |
| position, |
| }; |
|
|
| takeSnapshot(nodes, edges); |
| setNodes((nds) => [...nds, newNode]); |
| }, |
| [setNodes, takeSnapshot, rfInstance, nodes, edges] |
| ); |
|
|
| const onNodeClick = useCallback((event: React.MouseEvent, node: Node<NodeData>) => { |
| event.stopPropagation(); |
| setSelectedNode(node); |
| setIsConfigOpen(true); |
| }, []); |
|
|
| const onNodeContextMenu: NodeMouseHandler = useCallback((event, node) => { |
| event.preventDefault(); |
| setContextMenu({ |
| x: event.clientX, |
| y: event.clientY, |
| nodeId: node.id, |
| }); |
| }, []); |
|
|
| const onEdgeContextMenu = useCallback( |
| (event: React.MouseEvent, edge: Edge) => { |
| event.preventDefault(); |
| setContextMenu({ |
| x: event.clientX, |
| y: event.clientY, |
| edgeId: edge.id, |
| }); |
| }, |
| [] |
| ); |
|
|
| const onPaneContextMenu = useCallback((event: React.MouseEvent) => { |
| event.preventDefault(); |
| setContextMenu({ |
| x: event.clientX, |
| y: event.clientY, |
| nodeId: "", |
| }); |
| }, []); |
|
|
| const handleDeleteItem = useCallback(() => { |
| if (!contextMenu) return; |
|
|
| if (contextMenu.nodeId) { |
| takeSnapshot(nodes, edges); |
| setNodes((nds) => nds.filter((n) => n.id !== contextMenu.nodeId)); |
| setEdges((eds) => |
| eds.filter((e) => e.source !== contextMenu.nodeId && e.target !== contextMenu.nodeId) |
| ); |
| } else if (contextMenu.edgeId) { |
| takeSnapshot(nodes, edges); |
| setEdges((eds) => eds.filter((e) => e.id !== contextMenu.edgeId)); |
| } |
|
|
| setContextMenu(null); |
| }, [contextMenu, setNodes, setEdges, takeSnapshot, nodes, edges]); |
|
|
| const handleDuplicateNode = useCallback(() => { |
| if (!contextMenu) return; |
| const nodeToDuplicate = nodes.find((n) => n.id === contextMenu.nodeId); |
| if (!nodeToDuplicate) return; |
|
|
| const newNode: Node<NodeData> = { |
| ...nodeToDuplicate, |
| id: `${nodeToDuplicate.data.type}-${Date.now()}`, |
| position: { |
| x: nodeToDuplicate.position.x + 50, |
| y: nodeToDuplicate.position.y + 50, |
| }, |
| }; |
|
|
| takeSnapshot(nodes, edges); |
| setNodes((nds) => [...nds, newNode]); |
| setContextMenu(null); |
| }, [contextMenu, nodes, setNodes, takeSnapshot, edges]); |
|
|
| const updateNodeConfig = useCallback( |
| (nodeId: string, config: NodeData["config"], label?: string) => { |
| takeSnapshot(nodes, edges); |
| setNodes((nds) => |
| nds.map((node) => |
| node.id === nodeId |
| ? { ...node, data: { ...node.data, config, ...(label ? { label } : {}) } } |
| : node |
| ) |
| ); |
| }, |
| [setNodes, takeSnapshot, nodes, edges] |
| ); |
|
|
| const handleSave = useCallback(() => { |
| if (onSave) { |
| onSave(nodes, edges); |
| } else { |
| toast({ |
| title: "Success", |
| description: "Workflow saved locally!", |
| }); |
| } |
| }, [nodes, edges, onSave, toast]); |
|
|
| const executeWorkflow = useCallback(async () => { |
| if (!workflowId) { |
| toast({ |
| title: "Error", |
| description: "Please save the workflow before running it.", |
| variant: "destructive", |
| }); |
| return; |
| } |
|
|
| const startNode = nodes.find((n) => n.data.type === "start"); |
| if (!startNode) { |
| toast({ |
| title: "Error", |
| description: "No start node found! Please add a Start node to begin the workflow.", |
| variant: "destructive", |
| }); |
| return; |
| } |
|
|
| setIsExecuting(true); |
| toast({ |
| title: "Starting Execution", |
| description: "Running workflow on pending businesses..." |
| }); |
|
|
| try { |
| const response = await fetch("/api/workflows/execute", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ workflowId }), |
| }); |
|
|
| const data = await response.json(); |
|
|
| setExecutionLogs(data.logs || []); |
| setShowTerminal(true); |
|
|
| if (data.success) { |
| toast({ |
| title: "Execution Completed", |
| description: `Processed ${data.totalProcessed} businesses. Check terminal for details.`, |
| }); |
| } else { |
| toast({ |
| title: "Execution Failed", |
| description: "Check terminal for logs.", |
| variant: "destructive", |
| }); |
| } |
| } catch (error) { |
| toast({ |
| title: "Error", |
| description: "Failed to execute workflow", |
| variant: "destructive", |
| }); |
| console.error(error); |
| } finally { |
| setIsExecuting(false); |
| } |
| }, [workflowId, nodes, toast]); |
|
|
| const loadTemplate = useCallback((templateNodes: Node<NodeData>[], templateEdges: Edge[]) => { |
| takeSnapshot(nodes, edges); |
| setNodes(templateNodes); |
| setEdges(templateEdges); |
| setIsTemplatesOpen(false); |
| }, [setNodes, setEdges, takeSnapshot, nodes, edges]); |
|
|
| const handleAiGenerate = useCallback((generatedNodes: Node<NodeData>[], generatedEdges: Edge[]) => { |
| takeSnapshot(nodes, edges); |
| setNodes(generatedNodes); |
| setEdges(generatedEdges); |
| toast({ |
| title: "Workflow Generated", |
| description: "AI successfully created the workflow structure.", |
| }); |
| }, [setNodes, setEdges, takeSnapshot, toast, nodes, edges]); |
|
|
| |
| useEffect(() => { |
| const handleClick = () => setContextMenu(null); |
| if (contextMenu) { |
| document.addEventListener("click", handleClick); |
| return () => document.removeEventListener("click", handleClick); |
| } |
| }, [contextMenu]); |
|
|
| return ( |
| <TooltipProvider> |
| <div className="h-full w-full flex flex-col"> |
| {/* Toolbar */} |
| <Card className="mb-4"> |
| <CardHeader> |
| <CardTitle className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4"> |
| <span>Workflow Editor</span> |
| <div className="flex flex-wrap gap-2 w-full sm:w-auto"> |
| <Tooltip> |
| <TooltipTrigger asChild> |
| <Button |
| onClick={() => setIsAiDialogOpen(true)} |
| variant="outline" |
| className="gap-2 text-indigo-600 border-indigo-200 hover:bg-indigo-50" |
| > |
| <Sparkles className="h-4 w-4" /> |
| AI Generate |
| </Button> |
| </TooltipTrigger> |
| <TooltipContent>Generate workflow with AI</TooltipContent> |
| </Tooltip> |
| <Tooltip> |
| <TooltipTrigger asChild> |
| <Button |
| onClick={() => setShowTerminal(true)} |
| size="icon" |
| variant="ghost" |
| |
| > |
| <TerminalIcon className="h-4 w-4" /> |
| </Button> |
| </TooltipTrigger> |
| <TooltipContent>Terminal</TooltipContent> |
| </Tooltip> |
| <div className="w-px h-6 bg-border mx-1 self-center" /> |
| |
| <Tooltip> |
| <TooltipTrigger asChild> |
| <Button |
| onClick={handleUndo} |
| size="icon" |
| variant="ghost" |
| disabled={!canUndo} |
| > |
| <Undo className="h-4 w-4" /> |
| </Button> |
| </TooltipTrigger> |
| <TooltipContent>Undo (Ctrl+Z)</TooltipContent> |
| </Tooltip> |
| |
| <Tooltip> |
| <TooltipTrigger asChild> |
| <Button |
| onClick={handleRedo} |
| size="icon" |
| variant="ghost" |
| disabled={!canRedo} |
| > |
| <Redo className="h-4 w-4" /> |
| </Button> |
| </TooltipTrigger> |
| <TooltipContent>Redo (Ctrl+Shift+Z)</TooltipContent> |
| </Tooltip> |
| |
| <div className="w-px h-6 bg-border mx-1 self-center" /> |
| |
| <div className="flex items-center border rounded-md mr-1 overflow-hidden"> |
| <Tooltip> |
| <TooltipTrigger asChild> |
| <Button |
| variant={canvasMode === 'drag' ? 'secondary' : 'ghost'} |
| size="icon" |
| className="h-8 w-8 rounded-none" |
| onClick={() => setCanvasMode('drag')} |
| > |
| <Hand className="h-4 w-4" /> |
| </Button> |
| </TooltipTrigger> |
| <TooltipContent>Pan Mode (Drag to move canvas)</TooltipContent> |
| </Tooltip> |
| <div className="w-px h-full bg-border" /> |
| <Tooltip> |
| <TooltipTrigger asChild> |
| <Button |
| variant={canvasMode === 'select' ? 'secondary' : 'ghost'} |
| size="icon" |
| className="h-8 w-8 rounded-none" |
| onClick={() => setCanvasMode('select')} |
| > |
| <MousePointer2 className="h-4 w-4" /> |
| </Button> |
| </TooltipTrigger> |
| <TooltipContent>Select Mode (Drag to select nodes)</TooltipContent> |
| </Tooltip> |
| </div> |
| |
| <div className="w-px h-6 bg-border mx-1 self-center" /> |
| |
| <Tooltip> |
| <TooltipTrigger asChild> |
| <Button |
| onClick={() => setIsGuideOpen(true)} |
| size="icon" |
| variant="ghost" |
| > |
| <BookOpen className="h-4 w-4" /> |
| </Button> |
| </TooltipTrigger> |
| <TooltipContent>Workflow Guide</TooltipContent> |
| </Tooltip> |
| |
| <Tooltip> |
| <TooltipTrigger asChild> |
| <Button |
| onClick={() => setIsTemplatesOpen(true)} |
| size="icon" |
| variant="ghost" |
| > |
| <FileText className="h-4 w-4" /> |
| </Button> |
| </TooltipTrigger> |
| <TooltipContent>Templates</TooltipContent> |
| </Tooltip> |
| |
| <div className="w-px h-6 bg-border mx-1 self-center" /> |
| |
| <Tooltip> |
| <TooltipTrigger asChild> |
| <Button onClick={handleSave} size="icon" variant="outline" disabled={isSaving}> |
| {isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />} |
| </Button> |
| </TooltipTrigger> |
| <TooltipContent>Save Workflow</TooltipContent> |
| </Tooltip> |
| |
| <Tooltip> |
| <TooltipTrigger asChild> |
| <Button |
| onClick={executeWorkflow} |
| size="icon" |
| variant="default" |
| disabled={isExecuting} |
| > |
| <Play className="h-4 w-4" /> |
| </Button> |
| </TooltipTrigger> |
| <TooltipContent>{isExecuting ? "Running..." : "Test Run"}</TooltipContent> |
| </Tooltip> |
| |
| <div className="w-px h-6 bg-border mx-1 self-center" /> |
| |
| {/* Export / Import */} |
| <Tooltip> |
| <TooltipTrigger asChild> |
| <Button |
| size="icon" |
| variant="ghost" |
| onClick={() => { |
| try { |
| const data = JSON.stringify({ nodes, edges }, null, 2); |
| navigator.clipboard.writeText(data); |
| toast({ title: "Copied", description: "Workflow JSON copied to clipboard" }); |
| } catch { |
| toast({ title: "Error", description: "Failed to copy workflow", variant: "destructive" }); |
| } |
| }} |
| > |
| <Upload className="h-4 w-4" /> |
| </Button> |
| </TooltipTrigger> |
| <TooltipContent>Export JSON</TooltipContent> |
| </Tooltip> |
| |
| <Tooltip> |
| <TooltipTrigger asChild> |
| <Button size="icon" variant="ghost" onClick={() => setIsImportOpen(true)}> |
| <Download className="h-4 w-4" /> |
| </Button> |
| </TooltipTrigger> |
| <TooltipContent>Import JSON</TooltipContent> |
| </Tooltip> |
| </div> |
| </CardTitle> |
| </CardHeader> |
| <CardContent> |
| {/* Mobile: Scrollable toolbar */} |
| <div className="flex flex-wrap gap-2 max-h-[150px] overflow-y-auto sm:max-h-none sm:overflow-visible"> |
| {/* Bulk Actions Menu if multiple selected */} |
| {selectedNodes.length > 1 && ( |
| <div className="w-full bg-accent/20 p-2 rounded-md mb-2 flex items-center justify-between border border-accent"> |
| <span className="text-sm font-medium">{selectedNodes.length} nodes selected</span> |
| <Button size="sm" variant="destructive" onClick={() => handleDeleteSelectedNodeRef.current?.()}> |
| <Trash2 className="h-4 w-4 mr-2" /> Delete Selected |
| </Button> |
| </div> |
| )} |
| {/* Button Groups for better organization */} |
| <div className="flex flex-col gap-2 w-full"> |
| <div className="flex flex-wrap gap-2 items-center"> |
| <span className="text-xs font-semibold text-muted-foreground mr-1">Triggers:</span> |
| {["start", "webhook", "schedule"].map((type) => ( |
| <Button key={type} variant="outline" size="sm" onClick={() => addNode(type as NodeData["type"])}> |
| <Plus className="h-3 w-3 mr-1" />{type.charAt(0).toUpperCase() + type.slice(1)} |
| </Button> |
| ))} |
| </div> |
| <div className="flex flex-wrap gap-2 items-center"> |
| <span className="text-xs font-semibold text-muted-foreground mr-1">Actions:</span> |
| {["template", "apiRequest", "gemini", "agent", "set", "scraper", "whatsappNode"].map((type) => ( |
| <Tooltip key={type}> |
| <TooltipTrigger asChild> |
| <Button variant="outline" size="sm" onClick={() => addNode(type as NodeData["type"])}> |
| <Plus className="h-3 w-3 mr-1" />{type === 'apiRequest' ? 'API' : type === 'whatsappNode' ? 'WhatsApp' : type.charAt(0).toUpperCase() + type.slice(1)} |
| </Button> |
| </TooltipTrigger> |
| <TooltipContent>Add {type} node</TooltipContent> |
| </Tooltip> |
| ))} |
| </div> |
| <div className="flex flex-wrap gap-2 items-center"> |
| <span className="text-xs font-semibold text-muted-foreground mr-1">Logic:</span> |
| {["condition", "delay", "merge", "splitInBatches", "filter", "custom", "abSplit"].map((type) => ( |
| <Tooltip key={type}> |
| <TooltipTrigger asChild> |
| <Button variant="outline" size="sm" onClick={() => addNode(type as NodeData["type"])}> |
| <Plus className="h-3 w-3 mr-1" />{type === 'splitInBatches' ? 'Loop' : type === 'abSplit' ? 'A/B Split' : type.charAt(0).toUpperCase() + type.slice(1)} |
| </Button> |
| </TooltipTrigger> |
| <TooltipContent>Add {type} node</TooltipContent> |
| </Tooltip> |
| ))} |
| </div> |
| </div> |
| </div> |
| <p className="text-xs text-muted-foreground mt-2 flex items-center gap-1"> |
| <HelpCircle className="h-3 w-3" /> Shortcuts: Ctrl+C/V (copy/paste), Ctrl+Z/Y (undo/redo), Delete (remove node) |
| </p> |
| </CardContent> |
| </Card> |
| |
| {/* React Flow Canvas Canvas - Adjusted for mobile height */} |
| <div className="flex-1 h-full border rounded-lg overflow-hidden relative"> |
| <ReactFlow |
| nodes={nodes} |
| edges={edges} |
| |
| onInit={setRfInstance} |
| onNodesChange={onNodesChange} |
| onEdgesChange={onEdgesChange} |
| onConnect={onConnect} |
| onNodeClick={onNodeClick} |
| onNodeContextMenu={onNodeContextMenu} |
| onEdgeContextMenu={onEdgeContextMenu} |
| onPaneContextMenu={onPaneContextMenu} |
| nodeTypes={nodeTypes} |
| fitView |
| selectionOnDrag={canvasMode === 'select'} |
| selectionMode={SelectionMode.Partial} |
| panOnDrag={canvasMode === 'drag'} |
| panOnScroll={true} |
| onSelectionChange={({ nodes }) => setSelectedNodes(nodes)} |
| > |
| <Controls /> |
| <MiniMap /> |
| <Background variant={BackgroundVariant.Dots} gap={12} size={1} /> |
| </ReactFlow> |
| <div className="absolute bottom-2 left-2 z-10 text-[10px] text-muted-foreground opacity-50 pointer-events-none"> |
| Canvas |
| </div> |
| |
| {/* Context Menu */} |
| {contextMenu && ( |
| <div |
| className="fixed bg-card border rounded-lg shadow-lg z-50 min-w-[180px] py-1" |
| style={{ |
| left: contextMenu.x, |
| top: contextMenu.y, |
| }} |
| > |
| {contextMenu.nodeId ? ( |
| <> |
| <button |
| onClick={() => { |
| const node = nodes.find(n => n.id === contextMenu.nodeId); |
| if (node) setCopiedNodes([node]); |
| setContextMenu(null); |
| toast({ title: "Copied", description: "Node copied to clipboard" }); |
| }} |
| className="w-full px-4 py-2 text-left hover:bg-accent flex items-center gap-2 text-sm" |
| > |
| <Copy className="h-4 w-4" /> |
| Copy Node |
| </button> |
| <button |
| onClick={handleDuplicateNode} |
| className="w-full px-4 py-2 text-left hover:bg-accent flex items-center gap-2 text-sm" |
| > |
| <Copy className="h-4 w-4" /> |
| Duplicate Node |
| </button> |
| <button |
| onClick={handleDeleteItem} |
| className="w-full px-4 py-2 text-left hover:bg-destructive/10 text-destructive flex items-center gap-2 text-sm" |
| > |
| <Trash2 className="h-4 w-4" /> |
| Delete Node |
| </button> |
| </> |
| ) : contextMenu.edgeId ? ( |
| <button |
| onClick={handleDeleteItem} |
| className="w-full px-4 py-2 text-left hover:bg-destructive/10 text-destructive flex items-center gap-2 text-sm" |
| > |
| <Trash2 className="h-4 w-4" /> |
| Delete Connection |
| </button> |
| ) : ( |
| <> |
| <button |
| disabled={copiedNodes.length === 0} |
| onClick={() => { |
| handlePasteNode(); |
| setContextMenu(null); |
| }} |
| className="w-full px-4 py-2 text-left hover:bg-accent flex items-center gap-2 text-sm disabled:opacity-50" |
| > |
| <Copy className="h-4 w-4" /> |
| Paste ({copiedNodes.length}) |
| </button> |
| </> |
| )} |
| </div> |
| )} |
| </div> |
|
|
| {} |
| {selectedNode && ( |
| <NodeConfigDialog |
| open={isConfigOpen} |
| onOpenChange={setIsConfigOpen} |
| node={selectedNode} |
| onSave={(config, label) => { |
| updateNodeConfig(selectedNode.id, config, label); |
| setIsConfigOpen(false); |
| }} |
| onDelete={() => { |
| takeSnapshot(nodes, edges); |
| setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id)); |
| setEdges((eds) => eds.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id)); |
| setSelectedNode(null); |
| setIsConfigOpen(false); |
| }} |
| /> |
| )} |
|
|
| {} |
| <WorkflowTemplatesDialog |
| open={isTemplatesOpen} |
| onOpenChange={setIsTemplatesOpen} |
| onSelectTemplate={loadTemplate} |
| /> |
| <WorkflowGuideDialog open={isGuideOpen} onOpenChange={setIsGuideOpen} /> |
| <ImportWorkflowDialog |
| open={isImportOpen} |
| onOpenChange={setIsImportOpen} |
| onImport={(n, e) => { |
| takeSnapshot(nodes, edges); |
| setNodes(n); |
| setEdges(e); |
| setIsImportOpen(false); |
| }} |
| /> |
| <AiWorkflowDialog |
| open={isAiDialogOpen} |
| onOpenChange={setIsAiDialogOpen} |
| onGenerate={handleAiGenerate} |
| /> |
| {} |
| {showTerminal && ( |
| <div className="fixed bottom-0 left-0 right-0 h-72 bg-slate-950 border-t border-slate-800 shadow-2xl flex flex-col z-200 animate-in slide-in-from-bottom duration-300"> |
| <div className="flex items-center justify-between px-4 py-3 bg-slate-900 border-b border-slate-800"> |
| <div className="flex items-center gap-3 text-slate-200"> |
| <TerminalIcon className="h-5 w-5 text-indigo-400" /> |
| <span className="text-sm font-mono font-bold tracking-tight">SYSTEM TERMINAL</span> |
| <span className="text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-400 border border-slate-700"> |
| {executionLogs.length} Lines |
| </span> |
| </div> |
| <div className="flex items-center gap-2"> |
| <Button |
| variant="ghost" |
| size="sm" |
| className="h-8 w-8 text-slate-400 hover:text-white hover:bg-slate-800 rounded-md transition-colors" |
| onClick={() => setExecutionLogs([])} |
| title="Clear Logs" |
| > |
| <Trash2 className="h-4 w-4" /> |
| </Button> |
| <Button |
| variant="ghost" |
| size="sm" |
| className="h-8 w-8 text-slate-400 hover:text-white hover:bg-slate-800 rounded-md transition-colors" |
| onClick={() => setShowTerminal(false)} |
| title="Close Terminal" |
| > |
| <X className="h-4 w-4" /> |
| </Button> |
| </div> |
| </div> |
| <div className="flex-1 overflow-auto p-4 font-mono text-xs md:text-sm text-slate-300 space-y-1.5 scrollbar-thin scrollbar-thumb-slate-700 scrollbar-track-transparent"> |
| {executionLogs.length === 0 ? ( |
| <div className="h-full flex flex-col items-center justify-center text-slate-600 gap-2"> |
| <TerminalIcon className="h-8 w-8 opacity-20" /> |
| <p>Waiting for execution...</p> |
| </div> |
| ) : ( |
| executionLogs.map((log, i) => ( |
| <div key={i} className="flex gap-3 border-b border-slate-900/50 pb-1 last:border-0 font-medium"> |
| <span className="text-slate-600 select-none min-w-[24px] text-right">{(i + 1).toString().padStart(2, '0')}</span> |
| <span className={ |
| log.toLowerCase().includes("error") ? "text-red-400 bg-red-950/20 px-1 rounded" : |
| log.toLowerCase().includes("warning") ? "text-amber-400 bg-amber-950/20 px-1 rounded" : |
| log.toLowerCase().includes("success") || log.toLowerCase().includes("completed") ? "text-emerald-400" : |
| log.toLowerCase().includes("sending") || log.toLowerCase().includes("running") ? "text-blue-400" : |
| log.toLowerCase().includes("response") ? "text-cyan-400" : |
| "text-slate-300" |
| }> |
| {log} |
| </span> |
| </div> |
| )) |
| )} |
| </div> |
| </div> |
| )} |
| </div> |
| </TooltipProvider> |
| ); |
| } |
|
|