Generated by Spark: Can have multiple component trees, add a n8n style workflow system and lambdas with Monaco editor

This commit is contained in:
2026-01-16 02:14:58 +00:00
committed by GitHub
parent ac7e8c03af
commit 141e8a6b84
6 changed files with 2106 additions and 2 deletions

21
PRD.md
View File

@@ -40,6 +40,27 @@ This is a full-featured low-code IDE with multiple integrated tools (code editor
- **Progression**: Select component type or describe to AI → Add to tree → Configure props → Nest children → View generated JSX → Export component
- **Success criteria**: Can add/remove/reorder components; AI-generated components are well-structured; props are editable; generates valid React code
### Component Tree Manager
- **Functionality**: Manage multiple named component trees, each with its own hierarchy and component structure
- **Purpose**: Organize different parts of the application (Main App, Dashboard, Admin Panel) into separate, manageable trees
- **Trigger**: Opening the Component Trees tab
- **Progression**: Create tree → Name and describe purpose → Build component hierarchy → Switch between trees → Duplicate or delete trees → Export all trees
- **Success criteria**: Can create unlimited trees; each tree maintains independent state; trees can be duplicated; generates organized component structure per tree
### n8n-Style Workflow Designer
- **Functionality**: Visual workflow builder with draggable nodes (triggers, actions, conditions, transforms, lambdas, APIs, database queries) connected by visual connections
- **Purpose**: Design automation workflows and business logic visually without coding complex orchestration
- **Trigger**: Opening the Workflows tab
- **Progression**: Create workflow → Add nodes (drag/position on canvas) → Connect nodes → Configure each node (API endpoints, conditions, lambda code) → Activate workflow → Export workflow definition
- **Success criteria**: Nodes can be positioned freely; connections show data flow; Monaco editor for lambda/transform nodes; supports HTTP methods, database queries, conditional branching; workflows persist and can be activated/deactivated
### Lambda Function Designer
- **Functionality**: Full-featured serverless function editor with Monaco code editor, multiple runtime support (Node.js, Python), environment variables, and trigger configuration
- **Purpose**: Create and manage serverless functions with professional IDE features
- **Trigger**: Opening the Lambdas tab
- **Progression**: Create lambda → Select language (TypeScript/JavaScript/Python) → Write code in Monaco editor → Configure runtime, timeout, memory → Add triggers (HTTP, schedule, event, queue) → Set environment variables → Export lambda definitions
- **Success criteria**: Monaco editor with syntax highlighting; supports multiple languages; configuration matches AWS Lambda standards; triggers are configurable; environment variables are managed securely; generates deployment-ready functions
### Style Designer
- **Functionality**: Visual interface for Material UI theming with support for multiple theme variants (light/dark/custom), custom color management, and AI theme generation from descriptions
- **Purpose**: Configure colors, typography, spacing with support for unlimited theme variants and custom color palettes beyond standard specifications, with AI design assistance

View File

@@ -6,11 +6,14 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card } from '@/components/ui/card'
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
import { Code, Database, Tree, PaintBrush, Download, Sparkle, Flask, BookOpen, Play, Wrench, Gear, Cube, FileText, ChartBar, Keyboard } from '@phosphor-icons/react'
import { ProjectFile, PrismaModel, ComponentNode, ThemeConfig, PlaywrightTest, StorybookStory, UnitTest, FlaskConfig, NextJsConfig, NpmSettings } from '@/types/project'
import { Code, Database, Tree, PaintBrush, Download, Sparkle, Flask, BookOpen, Play, Wrench, Gear, Cube, FileText, ChartBar, Keyboard, FlowArrow } from '@phosphor-icons/react'
import { ProjectFile, PrismaModel, ComponentNode, ComponentTree, ThemeConfig, PlaywrightTest, StorybookStory, UnitTest, FlaskConfig, NextJsConfig, NpmSettings, Workflow, Lambda } from '@/types/project'
import { CodeEditor } from '@/components/CodeEditor'
import { ModelDesigner } from '@/components/ModelDesigner'
import { ComponentTreeBuilder } from '@/components/ComponentTreeBuilder'
import { ComponentTreeManager } from '@/components/ComponentTreeManager'
import { WorkflowDesigner } from '@/components/WorkflowDesigner'
import { LambdaDesigner } from '@/components/LambdaDesigner'
import { StyleDesigner } from '@/components/StyleDesigner'
import { FileExplorer } from '@/components/FileExplorer'
import { PlaywrightDesigner } from '@/components/PlaywrightDesigner'
@@ -140,6 +143,18 @@ function App() {
const [files, setFiles] = useKV<ProjectFile[]>('project-files', DEFAULT_FILES)
const [models, setModels] = useKV<PrismaModel[]>('project-models', [])
const [components, setComponents] = useKV<ComponentNode[]>('project-components', [])
const [componentTrees, setComponentTrees] = useKV<ComponentTree[]>('project-component-trees', [
{
id: 'default-tree',
name: 'Main App',
description: 'Default component tree',
rootNodes: [],
createdAt: Date.now(),
updatedAt: Date.now(),
},
])
const [workflows, setWorkflows] = useKV<Workflow[]>('project-workflows', [])
const [lambdas, setLambdas] = useKV<Lambda[]>('project-lambdas', [])
const [theme, setTheme] = useKV<ThemeConfig>('project-theme', DEFAULT_THEME)
const [playwrightTests, setPlaywrightTests] = useKV<PlaywrightTest[]>('project-playwright-tests', [])
const [storybookStories, setStorybookStories] = useKV<StorybookStory[]>('project-storybook-stories', [])
@@ -156,6 +171,9 @@ function App() {
const safeFiles = files || []
const safeModels = models || []
const safeComponents = components || []
const safeComponentTrees = componentTrees || []
const safeWorkflows = workflows || []
const safeLambdas = lambdas || []
const safeTheme = (theme && theme.variants && theme.variants.length > 0) ? theme : DEFAULT_THEME
const safePlaywrightTests = playwrightTests || []
const safeStorybookStories = storybookStories || []
@@ -200,6 +218,24 @@ function App() {
{
key: '5',
ctrl: true,
description: 'Go to Component Trees',
action: () => setActiveTab('component-trees'),
},
{
key: '6',
ctrl: true,
description: 'Go to Workflows',
action: () => setActiveTab('workflows'),
},
{
key: '7',
ctrl: true,
description: 'Go to Lambdas',
action: () => setActiveTab('lambdas'),
},
{
key: '8',
ctrl: true,
description: 'Go to Styling',
action: () => setActiveTab('styling'),
},
@@ -457,6 +493,18 @@ Navigate to the backend directory and follow the setup instructions.
<Tree size={18} />
Components
</TabsTrigger>
<TabsTrigger value="component-trees" className="gap-2">
<Tree size={18} />
Component Trees
</TabsTrigger>
<TabsTrigger value="workflows" className="gap-2">
<FlowArrow size={18} />
Workflows
</TabsTrigger>
<TabsTrigger value="lambdas" className="gap-2">
<Code size={18} />
Lambdas
</TabsTrigger>
<TabsTrigger value="styling" className="gap-2">
<PaintBrush size={18} />
Styling
@@ -549,6 +597,27 @@ Navigate to the backend directory and follow the setup instructions.
/>
</TabsContent>
<TabsContent value="component-trees" className="h-full m-0">
<ComponentTreeManager
trees={safeComponentTrees}
onTreesChange={setComponentTrees}
/>
</TabsContent>
<TabsContent value="workflows" className="h-full m-0">
<WorkflowDesigner
workflows={safeWorkflows}
onWorkflowsChange={setWorkflows}
/>
</TabsContent>
<TabsContent value="lambdas" className="h-full m-0">
<LambdaDesigner
lambdas={safeLambdas}
onLambdasChange={setLambdas}
/>
</TabsContent>
<TabsContent value="styling" className="h-full m-0">
<StyleDesigner theme={safeTheme} onThemeChange={setTheme} />
</TabsContent>

View File

@@ -0,0 +1,302 @@
import { useState } from 'react'
import { ComponentTree, ComponentNode } from '@/types/project'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Plus, Tree, Trash, Pencil, Copy, FolderOpen } from '@phosphor-icons/react'
import { ComponentTreeBuilder } from '@/components/ComponentTreeBuilder'
import { toast } from 'sonner'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
interface ComponentTreeManagerProps {
trees: ComponentTree[]
onTreesChange: (updater: (trees: ComponentTree[]) => ComponentTree[]) => void
}
export function ComponentTreeManager({ trees, onTreesChange }: ComponentTreeManagerProps) {
const [selectedTreeId, setSelectedTreeId] = useState<string | null>(trees[0]?.id || null)
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [newTreeName, setNewTreeName] = useState('')
const [newTreeDescription, setNewTreeDescription] = useState('')
const [editingTree, setEditingTree] = useState<ComponentTree | null>(null)
const selectedTree = trees.find(t => t.id === selectedTreeId)
const handleCreateTree = () => {
if (!newTreeName.trim()) {
toast.error('Please enter a tree name')
return
}
const newTree: ComponentTree = {
id: `tree-${Date.now()}`,
name: newTreeName,
description: newTreeDescription,
rootNodes: [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
onTreesChange((current) => [...current, newTree])
setSelectedTreeId(newTree.id)
setNewTreeName('')
setNewTreeDescription('')
setCreateDialogOpen(false)
toast.success('Component tree created')
}
const handleEditTree = () => {
if (!editingTree || !newTreeName.trim()) return
onTreesChange((current) =>
current.map((tree) =>
tree.id === editingTree.id
? { ...tree, name: newTreeName, description: newTreeDescription, updatedAt: Date.now() }
: tree
)
)
setEditDialogOpen(false)
setEditingTree(null)
setNewTreeName('')
setNewTreeDescription('')
toast.success('Component tree updated')
}
const handleDeleteTree = (treeId: string) => {
if (trees.length === 1) {
toast.error('Cannot delete the last component tree')
return
}
if (confirm('Are you sure you want to delete this component tree?')) {
onTreesChange((current) => current.filter((t) => t.id !== treeId))
if (selectedTreeId === treeId) {
setSelectedTreeId(trees.find(t => t.id !== treeId)?.id || null)
}
toast.success('Component tree deleted')
}
}
const handleDuplicateTree = (tree: ComponentTree) => {
const duplicatedTree: ComponentTree = {
...tree,
id: `tree-${Date.now()}`,
name: `${tree.name} (Copy)`,
createdAt: Date.now(),
updatedAt: Date.now(),
}
onTreesChange((current) => [...current, duplicatedTree])
setSelectedTreeId(duplicatedTree.id)
toast.success('Component tree duplicated')
}
const handleComponentsChange = (components: ComponentNode[]) => {
if (!selectedTreeId) return
onTreesChange((current) =>
current.map((tree) =>
tree.id === selectedTreeId
? { ...tree, rootNodes: components, updatedAt: Date.now() }
: tree
)
)
}
const openEditDialog = (tree: ComponentTree) => {
setEditingTree(tree)
setNewTreeName(tree.name)
setNewTreeDescription(tree.description)
setEditDialogOpen(true)
}
return (
<div className="h-full flex">
<div className="w-80 border-r border-border bg-card p-4 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Tree size={20} weight="duotone" />
Component Trees
</h2>
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
<Plus size={16} />
</Button>
</div>
<ScrollArea className="flex-1">
<div className="space-y-2">
{trees.map((tree) => (
<Card
key={tree.id}
className={`cursor-pointer transition-all ${
selectedTreeId === tree.id
? 'ring-2 ring-primary bg-accent'
: 'hover:bg-accent/50'
}`}
onClick={() => setSelectedTreeId(tree.id)}
>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<CardTitle className="text-sm truncate">{tree.name}</CardTitle>
{tree.description && (
<CardDescription className="text-xs mt-1 line-clamp-2">
{tree.description}
</CardDescription>
)}
<div className="flex gap-2 mt-2">
<Badge variant="outline" className="text-xs">
{tree.rootNodes.length} components
</Badge>
</div>
</div>
</div>
<div className="flex gap-1 mt-2" onClick={(e) => e.stopPropagation()}>
<Button
size="sm"
variant="ghost"
onClick={() => openEditDialog(tree)}
title="Edit tree"
>
<Pencil size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDuplicateTree(tree)}
title="Duplicate tree"
>
<Copy size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteTree(tree.id)}
disabled={trees.length === 1}
title="Delete tree"
>
<Trash size={14} />
</Button>
</div>
</CardHeader>
</Card>
))}
</div>
</ScrollArea>
{trees.length === 0 && (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-muted-foreground">
<FolderOpen size={48} className="mx-auto mb-2 opacity-50" />
<p className="text-sm">No component trees yet</p>
<Button size="sm" className="mt-2" onClick={() => setCreateDialogOpen(true)}>
Create First Tree
</Button>
</div>
</div>
)}
</div>
<div className="flex-1 overflow-hidden">
{selectedTree ? (
<ComponentTreeBuilder
components={selectedTree.rootNodes}
onComponentsChange={handleComponentsChange}
/>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Tree size={64} className="mx-auto mb-4 opacity-50" weight="duotone" />
<p>Select a component tree to edit</p>
</div>
</div>
)}
</div>
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Component Tree</DialogTitle>
<DialogDescription>
Create a new component tree to organize your UI components
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="tree-name">Tree Name</Label>
<Input
id="tree-name"
value={newTreeName}
onChange={(e) => setNewTreeName(e.target.value)}
placeholder="e.g., Main App, Dashboard, Admin Panel"
/>
</div>
<div>
<Label htmlFor="tree-description">Description</Label>
<Textarea
id="tree-description"
value={newTreeDescription}
onChange={(e) => setNewTreeDescription(e.target.value)}
placeholder="Describe the purpose of this component tree"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateTree}>Create Tree</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Component Tree</DialogTitle>
<DialogDescription>Update the component tree details</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="edit-tree-name">Tree Name</Label>
<Input
id="edit-tree-name"
value={newTreeName}
onChange={(e) => setNewTreeName(e.target.value)}
/>
</div>
<div>
<Label htmlFor="edit-tree-description">Description</Label>
<Textarea
id="edit-tree-description"
value={newTreeDescription}
onChange={(e) => setNewTreeDescription(e.target.value)}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleEditTree}>Save Changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,770 @@
import { useState } from 'react'
import { Lambda, LambdaTrigger } from '@/types/project'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Plus,
Trash,
Code,
Play,
Copy,
Pencil,
Lightning,
Clock,
Globe,
Queue,
} from '@phosphor-icons/react'
import { toast } from 'sonner'
import Editor from '@monaco-editor/react'
interface LambdaDesignerProps {
lambdas: Lambda[]
onLambdasChange: (updater: (lambdas: Lambda[]) => Lambda[]) => void
}
const DEFAULT_JS_CODE = `// Lambda function
export async function handler(event, context) {
console.log('Event:', event);
// Your logic here
const result = {
statusCode: 200,
body: JSON.stringify({
message: 'Hello from Lambda!',
input: event,
}),
};
return result;
}`
const DEFAULT_TS_CODE = `// Lambda function
interface Event {
[key: string]: any;
}
interface Context {
requestId: string;
functionName: string;
[key: string]: any;
}
interface Response {
statusCode: number;
body: string;
headers?: Record<string, string>;
}
export async function handler(event: Event, context: Context): Promise<Response> {
console.log('Event:', event);
// Your logic here
const result: Response = {
statusCode: 200,
body: JSON.stringify({
message: 'Hello from Lambda!',
input: event,
}),
};
return result;
}`
const DEFAULT_PYTHON_CODE = `# Lambda function
def handler(event, context):
print('Event:', event)
# Your logic here
result = {
'statusCode': 200,
'body': json.dumps({
'message': 'Hello from Lambda!',
'input': event,
})
}
return result`
const TRIGGER_TYPES = [
{ value: 'http', label: 'HTTP API', icon: Globe },
{ value: 'schedule', label: 'Schedule', icon: Clock },
{ value: 'event', label: 'Event', icon: Lightning },
{ value: 'queue', label: 'Queue', icon: Queue },
]
export function LambdaDesigner({ lambdas, onLambdasChange }: LambdaDesignerProps) {
const [selectedLambdaId, setSelectedLambdaId] = useState<string | null>(lambdas[0]?.id || null)
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [triggerDialogOpen, setTriggerDialogOpen] = useState(false)
const [newLambdaName, setNewLambdaName] = useState('')
const [newLambdaDescription, setNewLambdaDescription] = useState('')
const [newLambdaLanguage, setNewLambdaLanguage] = useState<Lambda['language']>('typescript')
const [editingLambda, setEditingLambda] = useState<Lambda | null>(null)
const [newTriggerType, setNewTriggerType] = useState<LambdaTrigger['type']>('http')
const selectedLambda = lambdas.find((l) => l.id === selectedLambdaId)
const getDefaultCode = (language: Lambda['language']) => {
switch (language) {
case 'javascript':
return DEFAULT_JS_CODE
case 'typescript':
return DEFAULT_TS_CODE
case 'python':
return DEFAULT_PYTHON_CODE
default:
return DEFAULT_JS_CODE
}
}
const handleCreateLambda = () => {
if (!newLambdaName.trim()) {
toast.error('Please enter a lambda name')
return
}
const newLambda: Lambda = {
id: `lambda-${Date.now()}`,
name: newLambdaName,
description: newLambdaDescription,
code: getDefaultCode(newLambdaLanguage),
language: newLambdaLanguage,
runtime: newLambdaLanguage === 'python' ? 'python3.11' : 'nodejs20.x',
handler: 'index.handler',
timeout: 30,
memory: 256,
environment: {},
triggers: [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
onLambdasChange((current) => [...current, newLambda])
setSelectedLambdaId(newLambda.id)
setNewLambdaName('')
setNewLambdaDescription('')
setNewLambdaLanguage('typescript')
setCreateDialogOpen(false)
toast.success('Lambda created')
}
const handleEditLambda = () => {
if (!editingLambda || !newLambdaName.trim()) return
onLambdasChange((current) =>
current.map((lambda) =>
lambda.id === editingLambda.id
? {
...lambda,
name: newLambdaName,
description: newLambdaDescription,
updatedAt: Date.now(),
}
: lambda
)
)
setEditDialogOpen(false)
setEditingLambda(null)
setNewLambdaName('')
setNewLambdaDescription('')
toast.success('Lambda updated')
}
const handleDeleteLambda = (lambdaId: string) => {
if (confirm('Are you sure you want to delete this lambda?')) {
onLambdasChange((current) => current.filter((l) => l.id !== lambdaId))
if (selectedLambdaId === lambdaId) {
setSelectedLambdaId(lambdas.find((l) => l.id !== lambdaId)?.id || null)
}
toast.success('Lambda deleted')
}
}
const handleDuplicateLambda = (lambda: Lambda) => {
const duplicatedLambda: Lambda = {
...lambda,
id: `lambda-${Date.now()}`,
name: `${lambda.name} (Copy)`,
createdAt: Date.now(),
updatedAt: Date.now(),
}
onLambdasChange((current) => [...current, duplicatedLambda])
setSelectedLambdaId(duplicatedLambda.id)
toast.success('Lambda duplicated')
}
const handleUpdateCode = (code: string) => {
if (!selectedLambdaId) return
onLambdasChange((current) =>
current.map((lambda) =>
lambda.id === selectedLambdaId
? { ...lambda, code, updatedAt: Date.now() }
: lambda
)
)
}
const handleUpdateConfig = (field: keyof Lambda, value: any) => {
if (!selectedLambdaId) return
onLambdasChange((current) =>
current.map((lambda) =>
lambda.id === selectedLambdaId
? { ...lambda, [field]: value, updatedAt: Date.now() }
: lambda
)
)
}
const handleAddTrigger = () => {
if (!selectedLambdaId) return
const newTrigger: LambdaTrigger = {
id: `trigger-${Date.now()}`,
type: newTriggerType,
config: {},
}
onLambdasChange((current) =>
current.map((lambda) =>
lambda.id === selectedLambdaId
? {
...lambda,
triggers: [...lambda.triggers, newTrigger],
updatedAt: Date.now(),
}
: lambda
)
)
setTriggerDialogOpen(false)
toast.success('Trigger added')
}
const handleDeleteTrigger = (triggerId: string) => {
if (!selectedLambdaId) return
onLambdasChange((current) =>
current.map((lambda) =>
lambda.id === selectedLambdaId
? {
...lambda,
triggers: lambda.triggers.filter((t) => t.id !== triggerId),
updatedAt: Date.now(),
}
: lambda
)
)
toast.success('Trigger deleted')
}
const handleAddEnvironmentVariable = () => {
if (!selectedLambdaId) return
const key = prompt('Enter environment variable name:')
if (!key) return
const value = prompt('Enter environment variable value:')
if (value === null) return
onLambdasChange((current) =>
current.map((lambda) =>
lambda.id === selectedLambdaId
? {
...lambda,
environment: { ...lambda.environment, [key]: value },
updatedAt: Date.now(),
}
: lambda
)
)
toast.success('Environment variable added')
}
const handleDeleteEnvironmentVariable = (key: string) => {
if (!selectedLambdaId) return
onLambdasChange((current) =>
current.map((lambda) => {
if (lambda.id !== selectedLambdaId) return lambda
const { [key]: _, ...rest } = lambda.environment
return { ...lambda, environment: rest, updatedAt: Date.now() }
})
)
toast.success('Environment variable deleted')
}
const openEditDialog = (lambda: Lambda) => {
setEditingLambda(lambda)
setNewLambdaName(lambda.name)
setNewLambdaDescription(lambda.description)
setEditDialogOpen(true)
}
const getEditorLanguage = (language: Lambda['language']) => {
return language === 'typescript' || language === 'javascript' ? 'typescript' : 'python'
}
return (
<div className="h-full flex">
<div className="w-80 border-r border-border bg-card p-4 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Code size={20} weight="duotone" />
Lambda Functions
</h2>
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
<Plus size={16} />
</Button>
</div>
<ScrollArea className="flex-1">
<div className="space-y-2">
{lambdas.map((lambda) => (
<Card
key={lambda.id}
className={`cursor-pointer transition-all ${
selectedLambdaId === lambda.id
? 'ring-2 ring-primary bg-accent'
: 'hover:bg-accent/50'
}`}
onClick={() => setSelectedLambdaId(lambda.id)}
>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<CardTitle className="text-sm truncate">{lambda.name}</CardTitle>
{lambda.description && (
<CardDescription className="text-xs mt-1 line-clamp-2">
{lambda.description}
</CardDescription>
)}
<div className="flex gap-2 mt-2 flex-wrap">
<Badge variant="outline" className="text-xs capitalize">
{lambda.language}
</Badge>
<Badge variant="outline" className="text-xs">
{lambda.triggers.length} triggers
</Badge>
</div>
</div>
</div>
<div className="flex gap-1 mt-2" onClick={(e) => e.stopPropagation()}>
<Button
size="sm"
variant="ghost"
onClick={() => openEditDialog(lambda)}
title="Edit"
>
<Pencil size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDuplicateLambda(lambda)}
title="Duplicate"
>
<Copy size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteLambda(lambda.id)}
title="Delete"
>
<Trash size={14} />
</Button>
</div>
</CardHeader>
</Card>
))}
</div>
</ScrollArea>
{lambdas.length === 0 && (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-muted-foreground">
<Code size={48} className="mx-auto mb-2 opacity-50" weight="duotone" />
<p className="text-sm">No lambdas yet</p>
<Button size="sm" className="mt-2" onClick={() => setCreateDialogOpen(true)}>
Create First Lambda
</Button>
</div>
</div>
)}
</div>
<div className="flex-1 flex flex-col">
{selectedLambda ? (
<>
<div className="border-b border-border bg-card p-4">
<div className="flex items-center justify-between mb-2">
<div>
<h3 className="font-semibold">{selectedLambda.name}</h3>
<p className="text-sm text-muted-foreground">
{selectedLambda.description || 'No description'}
</p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" disabled>
<Play size={16} className="mr-2" />
Test
</Button>
</div>
</div>
<div className="flex gap-2 flex-wrap">
<Badge variant="outline" className="capitalize">{selectedLambda.language}</Badge>
<Badge variant="outline">{selectedLambda.runtime}</Badge>
<Badge variant="outline">{selectedLambda.timeout}s timeout</Badge>
<Badge variant="outline">{selectedLambda.memory}MB memory</Badge>
</div>
</div>
<Tabs defaultValue="code" className="flex-1 flex flex-col">
<div className="border-b border-border bg-card px-4">
<TabsList className="bg-transparent">
<TabsTrigger value="code">Code</TabsTrigger>
<TabsTrigger value="config">Configuration</TabsTrigger>
<TabsTrigger value="triggers">Triggers</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
</TabsList>
</div>
<TabsContent value="code" className="flex-1 m-0">
<Editor
height="100%"
language={getEditorLanguage(selectedLambda.language)}
value={selectedLambda.code}
onChange={(value) => handleUpdateCode(value || '')}
theme="vs-dark"
options={{
minimap: { enabled: true },
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
}}
/>
</TabsContent>
<TabsContent value="config" className="m-0 p-4">
<ScrollArea className="h-full">
<div className="space-y-4 max-w-2xl">
<div>
<Label htmlFor="runtime">Runtime</Label>
<Select
value={selectedLambda.runtime}
onValueChange={(value) => handleUpdateConfig('runtime', value)}
>
<SelectTrigger id="runtime">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="nodejs20.x">Node.js 20.x</SelectItem>
<SelectItem value="nodejs18.x">Node.js 18.x</SelectItem>
<SelectItem value="python3.11">Python 3.11</SelectItem>
<SelectItem value="python3.10">Python 3.10</SelectItem>
<SelectItem value="python3.9">Python 3.9</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="handler">Handler</Label>
<Input
id="handler"
value={selectedLambda.handler}
onChange={(e) => handleUpdateConfig('handler', e.target.value)}
placeholder="index.handler"
/>
</div>
<div>
<Label htmlFor="timeout">Timeout (seconds)</Label>
<Input
id="timeout"
type="number"
value={selectedLambda.timeout}
onChange={(e) => handleUpdateConfig('timeout', parseInt(e.target.value))}
min="1"
max="900"
/>
</div>
<div>
<Label htmlFor="memory">Memory (MB)</Label>
<Select
value={selectedLambda.memory.toString()}
onValueChange={(value) => handleUpdateConfig('memory', parseInt(value))}
>
<SelectTrigger id="memory">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="128">128 MB</SelectItem>
<SelectItem value="256">256 MB</SelectItem>
<SelectItem value="512">512 MB</SelectItem>
<SelectItem value="1024">1024 MB</SelectItem>
<SelectItem value="2048">2048 MB</SelectItem>
<SelectItem value="4096">4096 MB</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="triggers" className="m-0 p-4">
<div className="flex items-center justify-between mb-4">
<h4 className="font-semibold">Lambda Triggers</h4>
<Button size="sm" onClick={() => setTriggerDialogOpen(true)}>
<Plus size={16} className="mr-2" />
Add Trigger
</Button>
</div>
<ScrollArea className="h-[calc(100%-3rem)]">
<div className="space-y-2">
{selectedLambda.triggers.map((trigger) => {
const triggerType = TRIGGER_TYPES.find((t) => t.value === trigger.type)
const Icon = triggerType?.icon || Lightning
return (
<Card key={trigger.id}>
<CardHeader className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Icon size={20} weight="duotone" />
<CardTitle className="text-sm capitalize">
{trigger.type}
</CardTitle>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteTrigger(trigger.id)}
>
<Trash size={14} />
</Button>
</div>
</CardHeader>
</Card>
)
})}
{selectedLambda.triggers.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<Lightning size={48} className="mx-auto mb-2 opacity-50" weight="duotone" />
<p className="text-sm">No triggers configured</p>
</div>
)}
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="environment" className="m-0 p-4">
<div className="flex items-center justify-between mb-4">
<h4 className="font-semibold">Environment Variables</h4>
<Button size="sm" onClick={handleAddEnvironmentVariable}>
<Plus size={16} className="mr-2" />
Add Variable
</Button>
</div>
<ScrollArea className="h-[calc(100%-3rem)]">
<div className="space-y-2">
{Object.entries(selectedLambda.environment).map(([key, value]) => (
<Card key={key}>
<CardHeader className="p-4">
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<code className="text-sm font-semibold">{key}</code>
<p className="text-xs text-muted-foreground truncate mt-1">
{value}
</p>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteEnvironmentVariable(key)}
>
<Trash size={14} />
</Button>
</div>
</CardHeader>
</Card>
))}
{Object.keys(selectedLambda.environment).length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<Code size={48} className="mx-auto mb-2 opacity-50" weight="duotone" />
<p className="text-sm">No environment variables</p>
</div>
)}
</div>
</ScrollArea>
</TabsContent>
</Tabs>
</>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Code size={64} className="mx-auto mb-4 opacity-50" weight="duotone" />
<p>Select a lambda to edit</p>
</div>
</div>
)}
</div>
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Lambda Function</DialogTitle>
<DialogDescription>
Create a new serverless lambda function
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="lambda-name">Function Name</Label>
<Input
id="lambda-name"
value={newLambdaName}
onChange={(e) => setNewLambdaName(e.target.value)}
placeholder="e.g., processUserData"
/>
</div>
<div>
<Label htmlFor="lambda-description">Description</Label>
<Textarea
id="lambda-description"
value={newLambdaDescription}
onChange={(e) => setNewLambdaDescription(e.target.value)}
placeholder="Describe what this lambda does"
rows={3}
/>
</div>
<div>
<Label htmlFor="lambda-language">Language</Label>
<Select
value={newLambdaLanguage}
onValueChange={(value: Lambda['language']) => setNewLambdaLanguage(value)}
>
<SelectTrigger id="lambda-language">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="typescript">TypeScript</SelectItem>
<SelectItem value="javascript">JavaScript</SelectItem>
<SelectItem value="python">Python</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateLambda}>Create Lambda</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Lambda Function</DialogTitle>
<DialogDescription>Update the lambda function details</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="edit-lambda-name">Function Name</Label>
<Input
id="edit-lambda-name"
value={newLambdaName}
onChange={(e) => setNewLambdaName(e.target.value)}
/>
</div>
<div>
<Label htmlFor="edit-lambda-description">Description</Label>
<Textarea
id="edit-lambda-description"
value={newLambdaDescription}
onChange={(e) => setNewLambdaDescription(e.target.value)}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleEditLambda}>Save Changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={triggerDialogOpen} onOpenChange={setTriggerDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Trigger</DialogTitle>
<DialogDescription>Configure a trigger for this lambda function</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="trigger-type">Trigger Type</Label>
<Select
value={newTriggerType}
onValueChange={(value: LambdaTrigger['type']) => setNewTriggerType(value)}
>
<SelectTrigger id="trigger-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRIGGER_TYPES.map((type) => {
const Icon = type.icon
return (
<SelectItem key={type.value} value={type.value}>
<div className="flex items-center gap-2">
<Icon size={16} />
{type.label}
</div>
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setTriggerDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleAddTrigger}>Add Trigger</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,866 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { Workflow, WorkflowNode, WorkflowConnection } from '@/types/project'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Plus,
Trash,
FlowArrow,
Play,
Pause,
Sparkle,
Code,
Database,
Plugs,
GitBranch,
Lightning,
Copy,
Pencil,
Link,
} from '@phosphor-icons/react'
import { toast } from 'sonner'
import Editor from '@monaco-editor/react'
interface WorkflowDesignerProps {
workflows: Workflow[]
onWorkflowsChange: (updater: (workflows: Workflow[]) => Workflow[]) => void
}
const NODE_TYPES = [
{ value: 'trigger', label: 'Trigger', icon: Lightning, color: 'text-yellow-500' },
{ value: 'action', label: 'Action', icon: Play, color: 'text-blue-500' },
{ value: 'condition', label: 'Condition', icon: GitBranch, color: 'text-purple-500' },
{ value: 'transform', label: 'Transform', icon: Code, color: 'text-green-500' },
{ value: 'lambda', label: 'Lambda', icon: Code, color: 'text-orange-500' },
{ value: 'api', label: 'API', icon: Plugs, color: 'text-cyan-500' },
{ value: 'database', label: 'Database', icon: Database, color: 'text-pink-500' },
]
export function WorkflowDesigner({ workflows, onWorkflowsChange }: WorkflowDesignerProps) {
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(
workflows[0]?.id || null
)
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [createNodeDialogOpen, setCreateNodeDialogOpen] = useState(false)
const [newWorkflowName, setNewWorkflowName] = useState('')
const [newWorkflowDescription, setNewWorkflowDescription] = useState('')
const [newNodeType, setNewNodeType] = useState<WorkflowNode['type']>('action')
const [newNodeName, setNewNodeName] = useState('')
const [isDragging, setIsDragging] = useState(false)
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
const [connectingFrom, setConnectingFrom] = useState<string | null>(null)
const canvasRef = useRef<HTMLDivElement>(null)
const selectedWorkflow = workflows.find((w) => w.id === selectedWorkflowId)
const selectedNode = selectedWorkflow?.nodes.find((n) => n.id === selectedNodeId)
const handleCreateWorkflow = () => {
if (!newWorkflowName.trim()) {
toast.error('Please enter a workflow name')
return
}
const newWorkflow: Workflow = {
id: `workflow-${Date.now()}`,
name: newWorkflowName,
description: newWorkflowDescription,
nodes: [],
connections: [],
isActive: false,
createdAt: Date.now(),
updatedAt: Date.now(),
}
onWorkflowsChange((current) => [...current, newWorkflow])
setSelectedWorkflowId(newWorkflow.id)
setNewWorkflowName('')
setNewWorkflowDescription('')
setCreateDialogOpen(false)
toast.success('Workflow created')
}
const handleDeleteWorkflow = (workflowId: string) => {
if (confirm('Are you sure you want to delete this workflow?')) {
onWorkflowsChange((current) => current.filter((w) => w.id !== workflowId))
if (selectedWorkflowId === workflowId) {
setSelectedWorkflowId(workflows.find((w) => w.id !== workflowId)?.id || null)
}
toast.success('Workflow deleted')
}
}
const handleDuplicateWorkflow = (workflow: Workflow) => {
const duplicatedWorkflow: Workflow = {
...workflow,
id: `workflow-${Date.now()}`,
name: `${workflow.name} (Copy)`,
nodes: workflow.nodes.map((node) => ({ ...node, id: `node-${Date.now()}-${node.id}` })),
createdAt: Date.now(),
updatedAt: Date.now(),
}
onWorkflowsChange((current) => [...current, duplicatedWorkflow])
setSelectedWorkflowId(duplicatedWorkflow.id)
toast.success('Workflow duplicated')
}
const handleToggleActive = (workflowId: string) => {
onWorkflowsChange((current) =>
current.map((w) =>
w.id === workflowId ? { ...w, isActive: !w.isActive, updatedAt: Date.now() } : w
)
)
toast.success(selectedWorkflow?.isActive ? 'Workflow deactivated' : 'Workflow activated')
}
const handleAddNode = () => {
if (!selectedWorkflowId || !newNodeName.trim()) {
toast.error('Please enter a node name')
return
}
const newNode: WorkflowNode = {
id: `node-${Date.now()}`,
type: newNodeType,
name: newNodeName,
position: { x: 100, y: 100 },
data: {},
config: {},
}
onWorkflowsChange((current) =>
current.map((w) =>
w.id === selectedWorkflowId
? { ...w, nodes: [...w.nodes, newNode], updatedAt: Date.now() }
: w
)
)
setNewNodeName('')
setCreateNodeDialogOpen(false)
toast.success('Node added')
}
const handleDeleteNode = (nodeId: string) => {
if (!selectedWorkflowId) return
onWorkflowsChange((current) =>
current.map((w) =>
w.id === selectedWorkflowId
? {
...w,
nodes: w.nodes.filter((n) => n.id !== nodeId),
connections: w.connections.filter(
(c) => c.source !== nodeId && c.target !== nodeId
),
updatedAt: Date.now(),
}
: w
)
)
if (selectedNodeId === nodeId) {
setSelectedNodeId(null)
}
toast.success('Node deleted')
}
const handleNodeMouseDown = (nodeId: string, e: React.MouseEvent) => {
if (!selectedWorkflow) return
const node = selectedWorkflow.nodes.find((n) => n.id === nodeId)
if (!node) return
setSelectedNodeId(nodeId)
setIsDragging(true)
setDragOffset({
x: e.clientX - node.position.x,
y: e.clientY - node.position.y,
})
}
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging || !selectedNodeId || !selectedWorkflowId) return
onWorkflowsChange((current) =>
current.map((w) =>
w.id === selectedWorkflowId
? {
...w,
nodes: w.nodes.map((n) =>
n.id === selectedNodeId
? {
...n,
position: {
x: e.clientX - dragOffset.x,
y: e.clientY - dragOffset.y,
},
}
: n
),
}
: w
)
)
},
[isDragging, selectedNodeId, selectedWorkflowId, dragOffset, onWorkflowsChange]
)
const handleMouseUp = useCallback(() => {
setIsDragging(false)
}, [])
useEffect(() => {
if (isDragging) {
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('mouseup', handleMouseUp)
return () => {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
}
}
}, [isDragging, handleMouseMove, handleMouseUp])
const handleStartConnection = (nodeId: string) => {
setConnectingFrom(nodeId)
}
const handleEndConnection = (targetNodeId: string) => {
if (!connectingFrom || !selectedWorkflowId || connectingFrom === targetNodeId) {
setConnectingFrom(null)
return
}
const newConnection: WorkflowConnection = {
id: `conn-${Date.now()}`,
source: connectingFrom,
target: targetNodeId,
}
onWorkflowsChange((current) =>
current.map((w) =>
w.id === selectedWorkflowId
? { ...w, connections: [...w.connections, newConnection], updatedAt: Date.now() }
: w
)
)
setConnectingFrom(null)
toast.success('Nodes connected')
}
const handleDeleteConnection = (connectionId: string) => {
if (!selectedWorkflowId) return
onWorkflowsChange((current) =>
current.map((w) =>
w.id === selectedWorkflowId
? {
...w,
connections: w.connections.filter((c) => c.id !== connectionId),
updatedAt: Date.now(),
}
: w
)
)
toast.success('Connection deleted')
}
const handleUpdateNodeConfig = (field: string, value: any) => {
if (!selectedNodeId || !selectedWorkflowId) return
onWorkflowsChange((current) =>
current.map((w) =>
w.id === selectedWorkflowId
? {
...w,
nodes: w.nodes.map((n) =>
n.id === selectedNodeId
? { ...n, config: { ...n.config, [field]: value } }
: n
),
updatedAt: Date.now(),
}
: w
)
)
}
const getNodeIcon = (type: WorkflowNode['type']) => {
const nodeType = NODE_TYPES.find((t) => t.value === type)
return nodeType?.icon || Code
}
const getNodeColor = (type: WorkflowNode['type']) => {
const nodeType = NODE_TYPES.find((t) => t.value === type)
return nodeType?.color || 'text-gray-500'
}
return (
<div className="h-full flex">
<div className="w-80 border-r border-border bg-card p-4 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold flex items-center gap-2">
<FlowArrow size={20} weight="duotone" />
Workflows
</h2>
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
<Plus size={16} />
</Button>
</div>
<ScrollArea className="flex-1">
<div className="space-y-2">
{workflows.map((workflow) => (
<Card
key={workflow.id}
className={`cursor-pointer transition-all ${
selectedWorkflowId === workflow.id
? 'ring-2 ring-primary bg-accent'
: 'hover:bg-accent/50'
}`}
onClick={() => setSelectedWorkflowId(workflow.id)}
>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<CardTitle className="text-sm truncate">{workflow.name}</CardTitle>
<Badge
variant={workflow.isActive ? 'default' : 'outline'}
className="text-xs"
>
{workflow.isActive ? 'Active' : 'Inactive'}
</Badge>
</div>
{workflow.description && (
<CardDescription className="text-xs mt-1 line-clamp-2">
{workflow.description}
</CardDescription>
)}
<div className="flex gap-2 mt-2">
<Badge variant="outline" className="text-xs">
{workflow.nodes.length} nodes
</Badge>
<Badge variant="outline" className="text-xs">
{workflow.connections.length} connections
</Badge>
</div>
</div>
</div>
<div className="flex gap-1 mt-2" onClick={(e) => e.stopPropagation()}>
<Button
size="sm"
variant="ghost"
onClick={() => handleToggleActive(workflow.id)}
title={workflow.isActive ? 'Deactivate' : 'Activate'}
>
{workflow.isActive ? <Pause size={14} /> : <Play size={14} />}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDuplicateWorkflow(workflow)}
title="Duplicate"
>
<Copy size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteWorkflow(workflow.id)}
title="Delete"
>
<Trash size={14} />
</Button>
</div>
</CardHeader>
</Card>
))}
</div>
</ScrollArea>
{workflows.length === 0 && (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-muted-foreground">
<FlowArrow size={48} className="mx-auto mb-2 opacity-50" weight="duotone" />
<p className="text-sm">No workflows yet</p>
<Button size="sm" className="mt-2" onClick={() => setCreateDialogOpen(true)}>
Create First Workflow
</Button>
</div>
</div>
)}
</div>
<div className="flex-1 flex">
<div className="flex-1 flex flex-col">
{selectedWorkflow ? (
<>
<div className="border-b border-border bg-card p-4 flex items-center justify-between">
<div>
<h3 className="font-semibold">{selectedWorkflow.name}</h3>
<p className="text-sm text-muted-foreground">
{selectedWorkflow.description || 'No description'}
</p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => setCreateNodeDialogOpen(true)}>
<Plus size={16} className="mr-2" />
Add Node
</Button>
<Button
size="sm"
variant={selectedWorkflow.isActive ? 'default' : 'outline'}
onClick={() => handleToggleActive(selectedWorkflow.id)}
>
{selectedWorkflow.isActive ? (
<>
<Pause size={16} className="mr-2" />
Deactivate
</>
) : (
<>
<Play size={16} className="mr-2" />
Activate
</>
)}
</Button>
</div>
</div>
<div
ref={canvasRef}
className="flex-1 bg-muted/20 relative overflow-auto"
style={{
backgroundImage: `radial-gradient(circle, var(--color-border) 1px, transparent 1px)`,
backgroundSize: '20px 20px',
}}
>
<svg className="absolute inset-0 pointer-events-none" style={{ zIndex: 1 }}>
{selectedWorkflow.connections.map((conn) => {
const sourceNode = selectedWorkflow.nodes.find((n) => n.id === conn.source)
const targetNode = selectedWorkflow.nodes.find((n) => n.id === conn.target)
if (!sourceNode || !targetNode) return null
const x1 = sourceNode.position.x + 120
const y1 = sourceNode.position.y + 40
const x2 = targetNode.position.x
const y2 = targetNode.position.y + 40
return (
<g key={conn.id}>
<path
d={`M ${x1} ${y1} C ${x1 + 50} ${y1}, ${x2 - 50} ${y2}, ${x2} ${y2}`}
stroke="hsl(var(--primary))"
strokeWidth="2"
fill="none"
markerEnd="url(#arrowhead)"
/>
<circle
cx={(x1 + x2) / 2}
cy={(y1 + y2) / 2}
r="8"
fill="hsl(var(--destructive))"
className="cursor-pointer pointer-events-auto"
onClick={() => handleDeleteConnection(conn.id)}
>
<title>Click to delete connection</title>
</circle>
</g>
)
})}
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="10"
refX="9"
refY="3"
orient="auto"
>
<polygon points="0 0, 10 3, 0 6" fill="hsl(var(--primary))" />
</marker>
</defs>
</svg>
{selectedWorkflow.nodes.map((node) => {
const Icon = getNodeIcon(node.type)
const color = getNodeColor(node.type)
return (
<Card
key={node.id}
className={`absolute w-60 cursor-move ${
selectedNodeId === node.id ? 'ring-2 ring-primary' : ''
}`}
style={{
left: node.position.x,
top: node.position.y,
zIndex: 10,
}}
onMouseDown={(e) => handleNodeMouseDown(node.id, e)}
>
<CardHeader className="p-3">
<div className="flex items-center gap-2">
<Icon size={20} className={color} weight="duotone" />
<CardTitle className="text-sm truncate flex-1">{node.name}</CardTitle>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation()
handleDeleteNode(node.id)
}}
className="h-6 w-6 p-0"
>
<Trash size={12} />
</Button>
</div>
<CardDescription className="text-xs capitalize">
{node.type}
</CardDescription>
</CardHeader>
<CardContent className="p-3 pt-0 flex gap-2">
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation()
handleStartConnection(node.id)
}}
className="flex-1"
disabled={connectingFrom !== null}
>
<Link size={14} className="mr-1" />
Connect
</Button>
{connectingFrom && connectingFrom !== node.id && (
<Button
size="sm"
onClick={(e) => {
e.stopPropagation()
handleEndConnection(node.id)
}}
className="flex-1"
>
<Link size={14} className="mr-1" />
Link Here
</Button>
)}
</CardContent>
</Card>
)
})}
{selectedWorkflow.nodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center text-muted-foreground">
<FlowArrow size={64} className="mx-auto mb-4 opacity-50" weight="duotone" />
<p>No nodes yet</p>
<Button
size="sm"
className="mt-2"
onClick={() => setCreateNodeDialogOpen(true)}
>
Add First Node
</Button>
</div>
</div>
)}
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<FlowArrow size={64} className="mx-auto mb-4 opacity-50" weight="duotone" />
<p>Select a workflow to edit</p>
</div>
</div>
)}
</div>
{selectedNode && selectedWorkflow && (
<div className="w-96 border-l border-border bg-card p-4 overflow-auto">
<div className="space-y-4">
<div>
<h3 className="font-semibold mb-2">Node Configuration</h3>
<p className="text-sm text-muted-foreground mb-4">{selectedNode.name}</p>
</div>
<Tabs defaultValue="general">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="config">Config</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-4">
<div>
<Label>Node Type</Label>
<Input value={selectedNode.type} disabled className="capitalize" />
</div>
<div>
<Label>Node Name</Label>
<Input
value={selectedNode.name}
onChange={(e) => {
onWorkflowsChange((current) =>
current.map((w) =>
w.id === selectedWorkflowId
? {
...w,
nodes: w.nodes.map((n) =>
n.id === selectedNode.id ? { ...n, name: e.target.value } : n
),
}
: w
)
)
}}
/>
</div>
</TabsContent>
<TabsContent value="config" className="space-y-4">
{selectedNode.type === 'lambda' && (
<div>
<Label>Lambda Code</Label>
<div className="border border-border rounded-md overflow-hidden mt-2">
<Editor
height="300px"
defaultLanguage="javascript"
value={selectedNode.config?.lambdaCode || '// Write your lambda code here\n'}
onChange={(value) => handleUpdateNodeConfig('lambdaCode', value || '')}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 12,
}}
/>
</div>
</div>
)}
{selectedNode.type === 'api' && (
<>
<div>
<Label>HTTP Method</Label>
<Select
value={selectedNode.config?.httpMethod || 'GET'}
onValueChange={(value) => handleUpdateNodeConfig('httpMethod', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>API Endpoint</Label>
<Input
value={selectedNode.config?.apiEndpoint || ''}
onChange={(e) => handleUpdateNodeConfig('apiEndpoint', e.target.value)}
placeholder="https://api.example.com/endpoint"
/>
</div>
</>
)}
{selectedNode.type === 'condition' && (
<div>
<Label>Condition Expression</Label>
<Textarea
value={selectedNode.config?.condition || ''}
onChange={(e) => handleUpdateNodeConfig('condition', e.target.value)}
placeholder="e.g., data.status === 'active'"
rows={4}
/>
</div>
)}
{selectedNode.type === 'transform' && (
<div>
<Label>Transform Script</Label>
<div className="border border-border rounded-md overflow-hidden mt-2">
<Editor
height="300px"
defaultLanguage="javascript"
value={
selectedNode.config?.transformScript ||
'// Transform the data\nreturn data;'
}
onChange={(value) =>
handleUpdateNodeConfig('transformScript', value || '')
}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 12,
}}
/>
</div>
</div>
)}
{selectedNode.type === 'database' && (
<div>
<Label>Database Query</Label>
<Textarea
value={selectedNode.config?.databaseQuery || ''}
onChange={(e) => handleUpdateNodeConfig('databaseQuery', e.target.value)}
placeholder="SELECT * FROM users WHERE id = $1"
rows={4}
/>
</div>
)}
{selectedNode.type === 'trigger' && (
<>
<div>
<Label>Trigger Type</Label>
<Select
value={selectedNode.config?.triggerType || 'manual'}
onValueChange={(value) => handleUpdateNodeConfig('triggerType', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="schedule">Schedule</SelectItem>
<SelectItem value="webhook">Webhook</SelectItem>
<SelectItem value="event">Event</SelectItem>
</SelectContent>
</Select>
</div>
{selectedNode.config?.triggerType === 'schedule' && (
<div>
<Label>Schedule Expression (Cron)</Label>
<Input
value={selectedNode.config?.scheduleExpression || ''}
onChange={(e) =>
handleUpdateNodeConfig('scheduleExpression', e.target.value)
}
placeholder="0 0 * * *"
/>
</div>
)}
</>
)}
</TabsContent>
</Tabs>
</div>
</div>
)}
</div>
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Workflow</DialogTitle>
<DialogDescription>Create a new workflow to automate your processes</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="workflow-name">Workflow Name</Label>
<Input
id="workflow-name"
value={newWorkflowName}
onChange={(e) => setNewWorkflowName(e.target.value)}
placeholder="e.g., User Registration Flow"
/>
</div>
<div>
<Label htmlFor="workflow-description">Description</Label>
<Textarea
id="workflow-description"
value={newWorkflowDescription}
onChange={(e) => setNewWorkflowDescription(e.target.value)}
placeholder="Describe what this workflow does"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateWorkflow}>Create Workflow</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={createNodeDialogOpen} onOpenChange={setCreateNodeDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Node</DialogTitle>
<DialogDescription>Add a new node to your workflow</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="node-type">Node Type</Label>
<Select
value={newNodeType}
onValueChange={(value: WorkflowNode['type']) => setNewNodeType(value)}
>
<SelectTrigger id="node-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{NODE_TYPES.map((type) => {
const Icon = type.icon
return (
<SelectItem key={type.value} value={type.value}>
<div className="flex items-center gap-2">
<Icon size={16} className={type.color} />
{type.label}
</div>
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="node-name">Node Name</Label>
<Input
id="node-name"
value={newNodeName}
onChange={(e) => setNewNodeName(e.target.value)}
placeholder="e.g., Send Welcome Email"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateNodeDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleAddNode}>Add Node</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -186,11 +186,87 @@ export interface NpmSettings {
packageManager: 'npm' | 'yarn' | 'pnpm'
}
export interface ComponentTree {
id: string
name: string
description: string
rootNodes: ComponentNode[]
createdAt: number
updatedAt: number
}
export interface WorkflowNode {
id: string
type: 'trigger' | 'action' | 'condition' | 'transform' | 'lambda' | 'api' | 'database'
name: string
position: { x: number; y: number }
data: Record<string, any>
config?: WorkflowNodeConfig
}
export interface WorkflowNodeConfig {
lambdaCode?: string
apiEndpoint?: string
httpMethod?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
headers?: Record<string, string>
queryParams?: Record<string, string>
condition?: string
transformScript?: string
databaseQuery?: string
triggerType?: 'manual' | 'schedule' | 'webhook' | 'event'
scheduleExpression?: string
}
export interface WorkflowConnection {
id: string
source: string
target: string
sourceHandle?: string
targetHandle?: string
label?: string
}
export interface Workflow {
id: string
name: string
description: string
nodes: WorkflowNode[]
connections: WorkflowConnection[]
isActive: boolean
createdAt: number
updatedAt: number
}
export interface Lambda {
id: string
name: string
description: string
code: string
language: 'javascript' | 'typescript' | 'python'
runtime: string
handler: string
timeout: number
memory: number
environment: Record<string, string>
triggers: LambdaTrigger[]
createdAt: number
updatedAt: number
}
export interface LambdaTrigger {
id: string
type: 'http' | 'schedule' | 'event' | 'queue'
config: Record<string, any>
}
export interface Project {
name: string
files: ProjectFile[]
models: PrismaModel[]
components: ComponentNode[]
componentTrees: ComponentTree[]
workflows: Workflow[]
lambdas: Lambda[]
theme: ThemeConfig
playwrightTests?: PlaywrightTest[]
storybookStories?: StorybookStory[]