mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-24 13:44:54 +00:00
Generated by Spark: Remove duplicate components, prefer json version.
This commit is contained in:
42
REMOVED_DUPLICATES.md
Normal file
42
REMOVED_DUPLICATES.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Removed Duplicate Components
|
||||
|
||||
The following components were replaced in favor of their JSON-based versions as part of the JSON Component Tree architecture migration.
|
||||
|
||||
## Replaced Files (Now Export JSON Versions)
|
||||
1. `ModelDesigner.tsx` → Now exports `JSONModelDesigner`
|
||||
2. `ComponentTreeManager.tsx` → Now exports `JSONComponentTreeManager`
|
||||
3. `WorkflowDesigner.tsx` → Now exports `JSONWorkflowDesigner`
|
||||
4. `LambdaDesigner.tsx` → Now exports `JSONLambdaDesigner`
|
||||
5. `FlaskDesigner.tsx` → Now exports `JSONFlaskDesigner`
|
||||
6. `StyleDesigner.tsx` → Now exports `JSONStyleDesigner`
|
||||
7. `ProjectDashboard.tsx` → Replaced with JSON-driven implementation from `ProjectDashboard.new.tsx`
|
||||
|
||||
## Registry Updates
|
||||
The `component-registry.json` was updated to point all component references to their JSON-based implementations:
|
||||
- All designer components now use JSON-based PageRenderer
|
||||
- Removed "experimental" tags from JSON implementations
|
||||
- Updated descriptions to reflect JSON-driven architecture
|
||||
|
||||
## Implementation Strategy
|
||||
Instead of deleting the old files, they now re-export the JSON versions. This maintains backward compatibility while ensuring all code paths use the JSON-driven architecture.
|
||||
|
||||
**Example:**
|
||||
```tsx
|
||||
// ModelDesigner.tsx
|
||||
export { JSONModelDesigner as ModelDesigner } from './JSONModelDesigner'
|
||||
```
|
||||
|
||||
## Benefits
|
||||
- ✅ Single source of truth using JSON-driven component trees
|
||||
- ✅ More maintainable and configurable components
|
||||
- ✅ Aligns with Redux + IndexedDB integration strategy
|
||||
- ✅ Backward compatible with existing imports
|
||||
- ✅ Reduced code duplication by ~6 components
|
||||
|
||||
## Files Kept (Not Duplicates)
|
||||
The following similarly-named files serve different purposes and were kept:
|
||||
- `ConflictResolutionDemo.tsx` vs `ConflictResolutionPage.tsx` (demo vs UI)
|
||||
- `PersistenceExample.tsx` vs `PersistenceDashboard.tsx` (example vs dashboard)
|
||||
- `StorageExample.tsx` vs `StorageSettings.tsx` (different features)
|
||||
- `AtomicComponentDemo.tsx` vs `AtomicComponentShowcase.tsx` vs `AtomicLibraryShowcase.tsx` (different demos)
|
||||
- `JSONUIShowcase.tsx` vs `JSONUIShowcasePage.tsx` (component vs page wrapper)
|
||||
@@ -36,22 +36,12 @@
|
||||
},
|
||||
{
|
||||
"name": "ModelDesigner",
|
||||
"path": "@/components/ModelDesigner",
|
||||
"export": "ModelDesigner",
|
||||
"type": "feature",
|
||||
"preload": false,
|
||||
"category": "designer",
|
||||
"description": "Visual Prisma model designer"
|
||||
},
|
||||
{
|
||||
"name": "JSONModelDesigner",
|
||||
"path": "@/components/JSONModelDesigner",
|
||||
"export": "JSONModelDesigner",
|
||||
"type": "feature",
|
||||
"preload": false,
|
||||
"category": "designer",
|
||||
"experimental": true,
|
||||
"description": "JSON-based model designer (experimental)"
|
||||
"description": "JSON-based model designer"
|
||||
},
|
||||
{
|
||||
"name": "ComponentTreeBuilder",
|
||||
@@ -64,73 +54,43 @@
|
||||
},
|
||||
{
|
||||
"name": "ComponentTreeManager",
|
||||
"path": "@/components/ComponentTreeManager",
|
||||
"export": "ComponentTreeManager",
|
||||
"type": "feature",
|
||||
"preload": false,
|
||||
"category": "designer",
|
||||
"description": "Manage multiple component trees"
|
||||
},
|
||||
{
|
||||
"name": "JSONComponentTreeManager",
|
||||
"path": "@/components/JSONComponentTreeManager",
|
||||
"export": "JSONComponentTreeManager",
|
||||
"type": "feature",
|
||||
"preload": false,
|
||||
"category": "designer",
|
||||
"experimental": true,
|
||||
"description": "JSON-based component tree manager (experimental)"
|
||||
"description": "JSON-based component tree manager"
|
||||
},
|
||||
{
|
||||
"name": "WorkflowDesigner",
|
||||
"path": "@/components/WorkflowDesigner",
|
||||
"export": "WorkflowDesigner",
|
||||
"type": "feature",
|
||||
"preload": false,
|
||||
"category": "designer",
|
||||
"dependencies": ["monaco-editor"],
|
||||
"preloadDependencies": ["preloadMonacoEditor"],
|
||||
"description": "n8n-style visual workflow designer"
|
||||
},
|
||||
{
|
||||
"name": "JSONWorkflowDesigner",
|
||||
"path": "@/components/JSONWorkflowDesigner",
|
||||
"export": "JSONWorkflowDesigner",
|
||||
"type": "feature",
|
||||
"preload": false,
|
||||
"category": "designer",
|
||||
"experimental": true,
|
||||
"description": "JSON-based workflow designer (experimental)"
|
||||
"dependencies": ["monaco-editor"],
|
||||
"preloadDependencies": ["preloadMonacoEditor"],
|
||||
"description": "JSON-based workflow designer"
|
||||
},
|
||||
{
|
||||
"name": "LambdaDesigner",
|
||||
"path": "@/components/LambdaDesigner",
|
||||
"export": "LambdaDesigner",
|
||||
"type": "feature",
|
||||
"preload": false,
|
||||
"category": "designer",
|
||||
"dependencies": ["monaco-editor"],
|
||||
"preloadDependencies": ["preloadMonacoEditor"],
|
||||
"description": "Serverless function designer with multi-runtime support"
|
||||
},
|
||||
{
|
||||
"name": "JSONLambdaDesigner",
|
||||
"path": "@/components/JSONLambdaDesigner",
|
||||
"export": "JSONLambdaDesigner",
|
||||
"type": "feature",
|
||||
"preload": false,
|
||||
"category": "designer",
|
||||
"experimental": true,
|
||||
"description": "JSON-based lambda designer (experimental)"
|
||||
"dependencies": ["monaco-editor"],
|
||||
"preloadDependencies": ["preloadMonacoEditor"],
|
||||
"description": "JSON-based lambda designer"
|
||||
},
|
||||
{
|
||||
"name": "StyleDesigner",
|
||||
"path": "@/components/StyleDesigner",
|
||||
"export": "StyleDesigner",
|
||||
"path": "@/components/JSONStyleDesigner",
|
||||
"export": "JSONStyleDesigner",
|
||||
"type": "feature",
|
||||
"preload": false,
|
||||
"category": "designer",
|
||||
"description": "Visual theme and styling designer"
|
||||
"description": "JSON-based theme and styling designer"
|
||||
},
|
||||
{
|
||||
"name": "PlaywrightDesigner",
|
||||
@@ -161,12 +121,12 @@
|
||||
},
|
||||
{
|
||||
"name": "FlaskDesigner",
|
||||
"path": "@/components/FlaskDesigner",
|
||||
"export": "FlaskDesigner",
|
||||
"path": "@/components/JSONFlaskDesigner",
|
||||
"export": "JSONFlaskDesigner",
|
||||
"type": "feature",
|
||||
"preload": false,
|
||||
"category": "backend",
|
||||
"description": "Flask REST API designer"
|
||||
"description": "JSON-based Flask REST API designer"
|
||||
},
|
||||
{
|
||||
"name": "ProjectSettingsDesigner",
|
||||
|
||||
@@ -1,239 +1 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { ComponentTree, ComponentNode } from '@/types/project'
|
||||
import { TreeFormDialog } from '@/components/molecules'
|
||||
import { TreeListPanel } from '@/components/organisms'
|
||||
import { ComponentTreeBuilder } from '@/components/ComponentTreeBuilder'
|
||||
import { TreeIcon } from '@/components/atoms'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
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 fileInputRef = useRef<HTMLInputElement>(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)
|
||||
}
|
||||
|
||||
const handleExportJson = () => {
|
||||
if (!selectedTree) {
|
||||
toast.error('No tree selected to export')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const json = JSON.stringify(selectedTree, null, 2)
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${selectedTree.name.toLowerCase().replace(/\s+/g, '-')}-tree.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('Component tree exported as JSON')
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error)
|
||||
toast.error('Failed to export component tree')
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportJson = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
const importedTree = JSON.parse(text) as ComponentTree
|
||||
|
||||
if (!importedTree.id || !importedTree.name || !Array.isArray(importedTree.rootNodes)) {
|
||||
toast.error('Invalid component tree JSON format')
|
||||
return
|
||||
}
|
||||
|
||||
const newTree: ComponentTree = {
|
||||
...importedTree,
|
||||
id: `tree-${Date.now()}`,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
onTreesChange((current) => [...current, newTree])
|
||||
setSelectedTreeId(newTree.id)
|
||||
toast.success(`Component tree "${newTree.name}" imported successfully`)
|
||||
} catch (error) {
|
||||
console.error('Import failed:', error)
|
||||
toast.error('Failed to import component tree. Please check the JSON format.')
|
||||
} finally {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<TreeListPanel
|
||||
trees={trees}
|
||||
selectedTreeId={selectedTreeId}
|
||||
onTreeSelect={setSelectedTreeId}
|
||||
onTreeEdit={openEditDialog}
|
||||
onTreeDuplicate={handleDuplicateTree}
|
||||
onTreeDelete={handleDeleteTree}
|
||||
onCreateNew={() => setCreateDialogOpen(true)}
|
||||
onImportJson={handleImportJson}
|
||||
onExportJson={handleExportJson}
|
||||
/>
|
||||
|
||||
<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">
|
||||
<TreeIcon size={64} className="mx-auto mb-4 opacity-50" />
|
||||
<p>Select a component tree to edit</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TreeFormDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
title="Create Component Tree"
|
||||
description="Create a new component tree to organize your UI components"
|
||||
name={newTreeName}
|
||||
treeDescription={newTreeDescription}
|
||||
onNameChange={setNewTreeName}
|
||||
onDescriptionChange={setNewTreeDescription}
|
||||
onSubmit={handleCreateTree}
|
||||
submitLabel="Create Tree"
|
||||
/>
|
||||
|
||||
<TreeFormDialog
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
title="Edit Component Tree"
|
||||
description="Update the component tree details"
|
||||
name={newTreeName}
|
||||
treeDescription={newTreeDescription}
|
||||
onNameChange={setNewTreeName}
|
||||
onDescriptionChange={setNewTreeDescription}
|
||||
onSubmit={handleEditTree}
|
||||
submitLabel="Save Changes"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export { JSONComponentTreeManager as ComponentTreeManager } from './JSONComponentTreeManager'
|
||||
|
||||
@@ -1,605 +1 @@
|
||||
import { useState } from 'react'
|
||||
import { FlaskBlueprint, FlaskEndpoint, FlaskParam, FlaskConfig } from '@/types/project'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Plus, Trash, Flask, Pencil } from '@phosphor-icons/react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
interface FlaskDesignerProps {
|
||||
config: FlaskConfig
|
||||
onConfigChange: (config: FlaskConfig | ((current: FlaskConfig) => FlaskConfig)) => void
|
||||
}
|
||||
|
||||
export function FlaskDesigner({ config, onConfigChange }: FlaskDesignerProps) {
|
||||
const [selectedBlueprintId, setSelectedBlueprintId] = useState<string | null>(
|
||||
config.blueprints[0]?.id || null
|
||||
)
|
||||
const [blueprintDialogOpen, setBlueprintDialogOpen] = useState(false)
|
||||
const [endpointDialogOpen, setEndpointDialogOpen] = useState(false)
|
||||
const [editingBlueprint, setEditingBlueprint] = useState<FlaskBlueprint | null>(null)
|
||||
const [editingEndpoint, setEditingEndpoint] = useState<FlaskEndpoint | null>(null)
|
||||
|
||||
const selectedBlueprint = config.blueprints.find((b) => b.id === selectedBlueprintId)
|
||||
|
||||
const handleAddBlueprint = () => {
|
||||
setEditingBlueprint({
|
||||
id: `blueprint-${Date.now()}`,
|
||||
name: '',
|
||||
urlPrefix: '/',
|
||||
endpoints: [],
|
||||
description: '',
|
||||
})
|
||||
setBlueprintDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleEditBlueprint = (blueprint: FlaskBlueprint) => {
|
||||
setEditingBlueprint({ ...blueprint })
|
||||
setBlueprintDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveBlueprint = () => {
|
||||
if (!editingBlueprint) return
|
||||
|
||||
onConfigChange((current) => {
|
||||
const existingIndex = current.blueprints.findIndex((b) => b.id === editingBlueprint.id)
|
||||
if (existingIndex >= 0) {
|
||||
const updated = [...current.blueprints]
|
||||
updated[existingIndex] = editingBlueprint
|
||||
return { ...current, blueprints: updated }
|
||||
} else {
|
||||
return { ...current, blueprints: [...current.blueprints, editingBlueprint] }
|
||||
}
|
||||
})
|
||||
|
||||
setSelectedBlueprintId(editingBlueprint.id)
|
||||
setBlueprintDialogOpen(false)
|
||||
setEditingBlueprint(null)
|
||||
}
|
||||
|
||||
const handleDeleteBlueprint = (blueprintId: string) => {
|
||||
onConfigChange((current) => ({
|
||||
...current,
|
||||
blueprints: current.blueprints.filter((b) => b.id !== blueprintId),
|
||||
}))
|
||||
if (selectedBlueprintId === blueprintId) {
|
||||
setSelectedBlueprintId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddEndpoint = () => {
|
||||
setEditingEndpoint({
|
||||
id: `endpoint-${Date.now()}`,
|
||||
path: '/',
|
||||
method: 'GET',
|
||||
name: '',
|
||||
description: '',
|
||||
queryParams: [],
|
||||
pathParams: [],
|
||||
authentication: false,
|
||||
corsEnabled: true,
|
||||
})
|
||||
setEndpointDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleEditEndpoint = (endpoint: FlaskEndpoint) => {
|
||||
setEditingEndpoint({ ...endpoint })
|
||||
setEndpointDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveEndpoint = () => {
|
||||
if (!editingEndpoint || !selectedBlueprintId) return
|
||||
|
||||
onConfigChange((current) => {
|
||||
const blueprints = [...current.blueprints]
|
||||
const blueprintIndex = blueprints.findIndex((b) => b.id === selectedBlueprintId)
|
||||
if (blueprintIndex >= 0) {
|
||||
const blueprint = { ...blueprints[blueprintIndex] }
|
||||
const endpointIndex = blueprint.endpoints.findIndex((e) => e.id === editingEndpoint.id)
|
||||
|
||||
if (endpointIndex >= 0) {
|
||||
blueprint.endpoints[endpointIndex] = editingEndpoint
|
||||
} else {
|
||||
blueprint.endpoints.push(editingEndpoint)
|
||||
}
|
||||
|
||||
blueprints[blueprintIndex] = blueprint
|
||||
}
|
||||
return { ...current, blueprints }
|
||||
})
|
||||
|
||||
setEndpointDialogOpen(false)
|
||||
setEditingEndpoint(null)
|
||||
}
|
||||
|
||||
const handleDeleteEndpoint = (endpointId: string) => {
|
||||
if (!selectedBlueprintId) return
|
||||
|
||||
onConfigChange((current) => {
|
||||
const blueprints = [...current.blueprints]
|
||||
const blueprintIndex = blueprints.findIndex((b) => b.id === selectedBlueprintId)
|
||||
if (blueprintIndex >= 0) {
|
||||
blueprints[blueprintIndex] = {
|
||||
...blueprints[blueprintIndex],
|
||||
endpoints: blueprints[blueprintIndex].endpoints.filter((e) => e.id !== endpointId),
|
||||
}
|
||||
}
|
||||
return { ...current, blueprints }
|
||||
})
|
||||
}
|
||||
|
||||
const addQueryParam = () => {
|
||||
if (!editingEndpoint) return
|
||||
setEditingEndpoint({
|
||||
...editingEndpoint,
|
||||
queryParams: [
|
||||
...(editingEndpoint.queryParams || []),
|
||||
{ id: `param-${Date.now()}`, name: '', type: 'string', required: false },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const removeQueryParam = (paramId: string) => {
|
||||
if (!editingEndpoint) return
|
||||
setEditingEndpoint({
|
||||
...editingEndpoint,
|
||||
queryParams: editingEndpoint.queryParams?.filter((p) => p.id !== paramId) || [],
|
||||
})
|
||||
}
|
||||
|
||||
const updateQueryParam = (paramId: string, updates: Partial<FlaskParam>) => {
|
||||
if (!editingEndpoint) return
|
||||
setEditingEndpoint({
|
||||
...editingEndpoint,
|
||||
queryParams:
|
||||
editingEndpoint.queryParams?.map((p) => (p.id === paramId ? { ...p, ...updates } : p)) || [],
|
||||
})
|
||||
}
|
||||
|
||||
const getMethodColor = (method: string) => {
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
return 'bg-blue-500/10 text-blue-500 border-blue-500/30'
|
||||
case 'POST':
|
||||
return 'bg-green-500/10 text-green-500 border-green-500/30'
|
||||
case 'PUT':
|
||||
return 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30'
|
||||
case 'DELETE':
|
||||
return 'bg-red-500/10 text-red-500 border-red-500/30'
|
||||
case 'PATCH':
|
||||
return 'bg-purple-500/10 text-purple-500 border-purple-500/30'
|
||||
default:
|
||||
return 'bg-muted text-muted-foreground'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="p-6 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-green-500 to-teal-500 flex items-center justify-center">
|
||||
<Flask size={24} weight="duotone" className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">Flask Backend Designer</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Design REST API endpoints and blueprints
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleAddBlueprint}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
New Blueprint
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<div className="w-64 border-r border-border flex flex-col">
|
||||
<div className="p-4 border-b border-border">
|
||||
<h3 className="font-semibold text-sm">Blueprints</h3>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
{config.blueprints.map((blueprint) => (
|
||||
<div
|
||||
key={blueprint.id}
|
||||
className={`group p-3 rounded-lg cursor-pointer transition-colors ${
|
||||
selectedBlueprintId === blueprint.id
|
||||
? 'bg-primary/10 border border-primary/30'
|
||||
: 'hover:bg-muted border border-transparent'
|
||||
}`}
|
||||
onClick={() => setSelectedBlueprintId(blueprint.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-sm truncate">{blueprint.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{blueprint.urlPrefix}
|
||||
</p>
|
||||
<Badge variant="secondary" className="mt-1 text-xs">
|
||||
{blueprint.endpoints.length} endpoints
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEditBlueprint(blueprint)
|
||||
}}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteBlueprint(blueprint.id)
|
||||
}}
|
||||
>
|
||||
<Trash size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
{selectedBlueprint ? (
|
||||
<>
|
||||
<div className="p-6 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold">{selectedBlueprint.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedBlueprint.description || 'No description'}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleAddEndpoint}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
New Endpoint
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 p-6">
|
||||
<div className="space-y-4 max-w-4xl">
|
||||
{selectedBlueprint.endpoints.map((endpoint) => (
|
||||
<Card key={endpoint.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge className={getMethodColor(endpoint.method)}>
|
||||
{endpoint.method}
|
||||
</Badge>
|
||||
<code className="text-sm font-mono">
|
||||
{selectedBlueprint.urlPrefix}
|
||||
{endpoint.path}
|
||||
</code>
|
||||
</div>
|
||||
<CardTitle className="text-base">{endpoint.name}</CardTitle>
|
||||
<CardDescription>{endpoint.description}</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleEditEndpoint(endpoint)}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeleteEndpoint(endpoint.id)}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 text-sm">
|
||||
{endpoint.authentication && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">🔒 Authentication Required</Badge>
|
||||
</div>
|
||||
)}
|
||||
{endpoint.queryParams && endpoint.queryParams.length > 0 && (
|
||||
<div>
|
||||
<p className="font-semibold mb-1">Query Parameters:</p>
|
||||
<div className="space-y-1">
|
||||
{endpoint.queryParams.map((param) => (
|
||||
<div key={param.id} className="flex items-center gap-2 text-xs">
|
||||
<code className="text-primary">{param.name}</code>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{param.type}
|
||||
</Badge>
|
||||
{param.required && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{selectedBlueprint.endpoints.length === 0 && (
|
||||
<Card className="p-12 text-center">
|
||||
<p className="text-muted-foreground">No endpoints yet</p>
|
||||
<Button variant="link" onClick={handleAddEndpoint} className="mt-2">
|
||||
Create your first endpoint
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Flask size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p>Select a blueprint or create a new one</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={blueprintDialogOpen} onOpenChange={setBlueprintDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingBlueprint?.name ? 'Edit Blueprint' : 'New Blueprint'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Configure your Flask blueprint</DialogDescription>
|
||||
</DialogHeader>
|
||||
{editingBlueprint && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="blueprint-name">Blueprint Name</Label>
|
||||
<Input
|
||||
id="blueprint-name"
|
||||
value={editingBlueprint.name}
|
||||
onChange={(e) =>
|
||||
setEditingBlueprint({ ...editingBlueprint, name: e.target.value })
|
||||
}
|
||||
placeholder="e.g., users, auth, products"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="blueprint-prefix">URL Prefix</Label>
|
||||
<Input
|
||||
id="blueprint-prefix"
|
||||
value={editingBlueprint.urlPrefix}
|
||||
onChange={(e) =>
|
||||
setEditingBlueprint({ ...editingBlueprint, urlPrefix: e.target.value })
|
||||
}
|
||||
placeholder="/api/v1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="blueprint-description">Description</Label>
|
||||
<Textarea
|
||||
id="blueprint-description"
|
||||
value={editingBlueprint.description}
|
||||
onChange={(e) =>
|
||||
setEditingBlueprint({ ...editingBlueprint, description: e.target.value })
|
||||
}
|
||||
placeholder="What does this blueprint handle?"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setBlueprintDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSaveBlueprint}>Save Blueprint</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={endpointDialogOpen} onOpenChange={setEndpointDialogOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingEndpoint?.name ? 'Edit Endpoint' : 'New Endpoint'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Configure your API endpoint</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="flex-1 pr-4">
|
||||
{editingEndpoint && (
|
||||
<div className="space-y-4">
|
||||
<Tabs defaultValue="basic" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="basic">Basic</TabsTrigger>
|
||||
<TabsTrigger value="params">Parameters</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="basic" className="space-y-4 mt-4">
|
||||
<div>
|
||||
<Label htmlFor="endpoint-name">Endpoint Name</Label>
|
||||
<Input
|
||||
id="endpoint-name"
|
||||
value={editingEndpoint.name}
|
||||
onChange={(e) =>
|
||||
setEditingEndpoint({ ...editingEndpoint, name: e.target.value })
|
||||
}
|
||||
placeholder="e.g., Get User List"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="endpoint-method">Method</Label>
|
||||
<Select
|
||||
value={editingEndpoint.method}
|
||||
onValueChange={(value: any) =>
|
||||
setEditingEndpoint({ ...editingEndpoint, method: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="endpoint-method">
|
||||
<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 htmlFor="endpoint-path">Path</Label>
|
||||
<Input
|
||||
id="endpoint-path"
|
||||
value={editingEndpoint.path}
|
||||
onChange={(e) =>
|
||||
setEditingEndpoint({ ...editingEndpoint, path: e.target.value })
|
||||
}
|
||||
placeholder="/users"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="endpoint-description">Description</Label>
|
||||
<Textarea
|
||||
id="endpoint-description"
|
||||
value={editingEndpoint.description}
|
||||
onChange={(e) =>
|
||||
setEditingEndpoint({ ...editingEndpoint, description: e.target.value })
|
||||
}
|
||||
placeholder="What does this endpoint do?"
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="endpoint-auth">Require Authentication</Label>
|
||||
<Switch
|
||||
id="endpoint-auth"
|
||||
checked={editingEndpoint.authentication}
|
||||
onCheckedChange={(checked) =>
|
||||
setEditingEndpoint({ ...editingEndpoint, authentication: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="endpoint-cors">Enable CORS</Label>
|
||||
<Switch
|
||||
id="endpoint-cors"
|
||||
checked={editingEndpoint.corsEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setEditingEndpoint({ ...editingEndpoint, corsEnabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="params" className="space-y-4 mt-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Label>Query Parameters</Label>
|
||||
<Button size="sm" variant="outline" onClick={addQueryParam}>
|
||||
<Plus size={14} className="mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{editingEndpoint.queryParams?.map((param) => (
|
||||
<Card key={param.id} className="p-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Parameter name"
|
||||
value={param.name}
|
||||
onChange={(e) =>
|
||||
updateQueryParam(param.id, { name: e.target.value })
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select
|
||||
value={param.type}
|
||||
onValueChange={(value: any) =>
|
||||
updateQueryParam(param.id, { type: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">string</SelectItem>
|
||||
<SelectItem value="number">number</SelectItem>
|
||||
<SelectItem value="boolean">boolean</SelectItem>
|
||||
<SelectItem value="array">array</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeQueryParam(param.id)}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={param.required}
|
||||
onCheckedChange={(checked) =>
|
||||
updateQueryParam(param.id, { required: checked })
|
||||
}
|
||||
/>
|
||||
<Label className="text-xs">Required</Label>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{(!editingEndpoint.queryParams ||
|
||||
editingEndpoint.queryParams.length === 0) && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No query parameters defined
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEndpointDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSaveEndpoint}>Save Endpoint</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export { JSONFlaskDesigner as FlaskDesigner } from './JSONFlaskDesigner'
|
||||
|
||||
@@ -1,770 +1 @@
|
||||
import { useState } from 'react'
|
||||
import { Lambda, LambdaTrigger } from '@/types/project'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, 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 { LazyInlineMonacoEditor } from '@/components/molecules/LazyInlineMonacoEditor'
|
||||
|
||||
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">
|
||||
<LazyInlineMonacoEditor
|
||||
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>
|
||||
)
|
||||
}
|
||||
export { JSONLambdaDesigner as LambdaDesigner } from './JSONLambdaDesigner'
|
||||
|
||||
@@ -1,345 +1 @@
|
||||
import { useState } from 'react'
|
||||
import { PrismaModel, PrismaField } from '@/types/project'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Plus, Trash, Database, Sparkle, Lightbulb } from '@phosphor-icons/react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { AIService } from '@/lib/ai-service'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface ModelDesignerProps {
|
||||
models: PrismaModel[]
|
||||
onModelsChange: (models: PrismaModel[]) => void
|
||||
}
|
||||
|
||||
const FIELD_TYPES = [
|
||||
'String',
|
||||
'Int',
|
||||
'Float',
|
||||
'Boolean',
|
||||
'DateTime',
|
||||
'Json',
|
||||
'Bytes',
|
||||
]
|
||||
|
||||
export function ModelDesigner({ models, onModelsChange }: ModelDesignerProps) {
|
||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(
|
||||
models[0]?.id || null
|
||||
)
|
||||
|
||||
const selectedModel = models.find((m) => m.id === selectedModelId)
|
||||
|
||||
const addModel = () => {
|
||||
const newModel: PrismaModel = {
|
||||
id: `model-${Date.now()}`,
|
||||
name: `Model${models.length + 1}`,
|
||||
fields: [
|
||||
{
|
||||
id: `field-${Date.now()}`,
|
||||
name: 'id',
|
||||
type: 'String',
|
||||
isRequired: true,
|
||||
isUnique: true,
|
||||
isArray: false,
|
||||
defaultValue: 'cuid()',
|
||||
},
|
||||
],
|
||||
}
|
||||
onModelsChange([...models, newModel])
|
||||
setSelectedModelId(newModel.id)
|
||||
}
|
||||
|
||||
const deleteModel = (modelId: string) => {
|
||||
const newModels = models.filter((m) => m.id !== modelId)
|
||||
onModelsChange(newModels)
|
||||
if (selectedModelId === modelId) {
|
||||
setSelectedModelId(newModels[0]?.id || null)
|
||||
}
|
||||
}
|
||||
|
||||
const updateModel = (modelId: string, updates: Partial<PrismaModel>) => {
|
||||
onModelsChange(
|
||||
models.map((m) => (m.id === modelId ? { ...m, ...updates } : m))
|
||||
)
|
||||
}
|
||||
|
||||
const addField = () => {
|
||||
if (!selectedModel) return
|
||||
const newField: PrismaField = {
|
||||
id: `field-${Date.now()}`,
|
||||
name: `field${selectedModel.fields.length + 1}`,
|
||||
type: 'String',
|
||||
isRequired: false,
|
||||
isUnique: false,
|
||||
isArray: false,
|
||||
}
|
||||
updateModel(selectedModel.id, {
|
||||
fields: [...selectedModel.fields, newField],
|
||||
})
|
||||
}
|
||||
|
||||
const updateField = (fieldId: string, updates: Partial<PrismaField>) => {
|
||||
if (!selectedModel) return
|
||||
updateModel(selectedModel.id, {
|
||||
fields: selectedModel.fields.map((f) =>
|
||||
f.id === fieldId ? { ...f, ...updates } : f
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
const deleteField = (fieldId: string) => {
|
||||
if (!selectedModel) return
|
||||
updateModel(selectedModel.id, {
|
||||
fields: selectedModel.fields.filter((f) => f.id !== fieldId),
|
||||
})
|
||||
}
|
||||
|
||||
const generateModelWithAI = async () => {
|
||||
const description = prompt('Describe the database model you want to create:')
|
||||
if (!description) return
|
||||
|
||||
try {
|
||||
toast.info('Generating model with AI...')
|
||||
const model = await AIService.generatePrismaModel(description, models)
|
||||
|
||||
if (model) {
|
||||
onModelsChange([...models, model])
|
||||
setSelectedModelId(model.id)
|
||||
toast.success(`Model "${model.name}" created successfully!`)
|
||||
} else {
|
||||
toast.error('AI generation failed. Please try again.')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to generate model')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const suggestFields = async () => {
|
||||
if (!selectedModel) return
|
||||
|
||||
try {
|
||||
toast.info('Getting field suggestions...')
|
||||
const existingFieldNames = selectedModel.fields.map(f => f.name)
|
||||
const suggestions = await AIService.suggestFieldsForModel(selectedModel.name, existingFieldNames)
|
||||
|
||||
if (suggestions && suggestions.length > 0) {
|
||||
const newFields: PrismaField[] = suggestions.map((fieldName, index) => ({
|
||||
id: `field-${Date.now()}-${index}`,
|
||||
name: fieldName,
|
||||
type: 'String',
|
||||
isRequired: false,
|
||||
isUnique: false,
|
||||
isArray: false,
|
||||
}))
|
||||
|
||||
updateModel(selectedModel.id, {
|
||||
fields: [...selectedModel.fields, ...newFields],
|
||||
})
|
||||
toast.success(`Added ${suggestions.length} suggested fields!`)
|
||||
} else {
|
||||
toast.error('No suggestions available')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to get suggestions')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex gap-4 p-6">
|
||||
<div className="w-64 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-sm uppercase tracking-wide">Models</h3>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={generateModelWithAI}
|
||||
className="h-8 w-8 p-0"
|
||||
title="Generate model with AI"
|
||||
>
|
||||
<Sparkle size={16} weight="duotone" />
|
||||
</Button>
|
||||
<Button size="sm" onClick={addModel} className="h-8 w-8 p-0">
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-2">
|
||||
{models.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => setSelectedModelId(model.id)}
|
||||
className={`w-full flex items-center justify-between p-3 rounded-lg border transition-colors ${
|
||||
selectedModelId === model.id
|
||||
? 'bg-accent text-accent-foreground border-accent'
|
||||
: 'bg-card text-card-foreground border-border hover:border-accent/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database size={18} weight="duotone" />
|
||||
<span className="font-medium">{model.name}</span>
|
||||
</div>
|
||||
<Badge variant="secondary">{model.fields.length}</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<Card className="flex-1 p-6">
|
||||
{selectedModel ? (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2 flex-1 mr-4">
|
||||
<Label>Model Name</Label>
|
||||
<Input
|
||||
value={selectedModel.name}
|
||||
onChange={(e) =>
|
||||
updateModel(selectedModel.id, { name: e.target.value })
|
||||
}
|
||||
className="text-lg font-semibold"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteModel(selectedModel.id)}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-semibold text-sm uppercase tracking-wide">Fields</h4>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={suggestFields}
|
||||
title="AI suggest fields"
|
||||
>
|
||||
<Lightbulb size={16} className="mr-2" weight="duotone" />
|
||||
Suggest
|
||||
</Button>
|
||||
<Button size="sm" onClick={addField}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Add Field
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-96">
|
||||
<div className="space-y-4">
|
||||
{selectedModel.fields.map((field) => (
|
||||
<Card key={field.id} className="p-4 bg-secondary/30">
|
||||
<div className="grid gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Field Name</Label>
|
||||
<Input
|
||||
value={field.name}
|
||||
onChange={(e) =>
|
||||
updateField(field.id, { name: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Type</Label>
|
||||
<Select
|
||||
value={field.type}
|
||||
onValueChange={(value) =>
|
||||
updateField(field.id, { type: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={field.isRequired}
|
||||
onCheckedChange={(checked) =>
|
||||
updateField(field.id, { isRequired: checked })
|
||||
}
|
||||
/>
|
||||
<Label>Required</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={field.isUnique}
|
||||
onCheckedChange={(checked) =>
|
||||
updateField(field.id, { isUnique: checked })
|
||||
}
|
||||
/>
|
||||
<Label>Unique</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={field.isArray}
|
||||
onCheckedChange={(checked) =>
|
||||
updateField(field.id, { isArray: checked })
|
||||
}
|
||||
/>
|
||||
<Label>Array</Label>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteField(field.id)}
|
||||
className="ml-auto text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Default Value (optional)</Label>
|
||||
<Input
|
||||
value={field.defaultValue || ''}
|
||||
onChange={(e) =>
|
||||
updateField(field.id, {
|
||||
defaultValue: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="e.g., now(), cuid(), autoincrement()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Database size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p>Create a model to get started</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export { JSONModelDesigner as ModelDesigner } from './JSONModelDesigner'
|
||||
|
||||
@@ -1,35 +1,6 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Code,
|
||||
Database,
|
||||
Tree,
|
||||
PaintBrush,
|
||||
Flask,
|
||||
Play,
|
||||
Cube,
|
||||
FileText,
|
||||
Rocket,
|
||||
GitBranch,
|
||||
Package,
|
||||
} from '@phosphor-icons/react'
|
||||
import { JSONPageRenderer } from '@/components/JSONPageRenderer'
|
||||
import dashboardSchema from '@/config/pages/dashboard.json'
|
||||
import { ProjectFile, PrismaModel, ComponentNode, ThemeConfig, PlaywrightTest, StorybookStory, UnitTest, FlaskConfig, NextJsConfig } from '@/types/project'
|
||||
import {
|
||||
SeedDataStatus,
|
||||
DetailRow,
|
||||
CompletionCard,
|
||||
TipsCard,
|
||||
StatCard,
|
||||
QuickActionButton,
|
||||
PanelHeader,
|
||||
Heading,
|
||||
Text,
|
||||
Stack,
|
||||
Container,
|
||||
ResponsiveGrid,
|
||||
} from '@/components/atoms'
|
||||
import { GitHubBuildStatus } from '@/components/molecules/GitHubBuildStatus'
|
||||
import { useDashboardMetrics } from '@/hooks/ui/use-dashboard-metrics'
|
||||
import { useDashboardTips } from '@/hooks/ui/use-dashboard-tips'
|
||||
|
||||
interface ProjectDashboardProps {
|
||||
files: ProjectFile[]
|
||||
@@ -41,192 +12,44 @@ interface ProjectDashboardProps {
|
||||
unitTests: UnitTest[]
|
||||
flaskConfig: FlaskConfig
|
||||
nextjsConfig: NextJsConfig
|
||||
onNavigate?: (page: string) => void
|
||||
}
|
||||
|
||||
function calculateCompletionScore(data: any) {
|
||||
const { files = [], models = [], components = [], playwrightTests = [], storybookStories = [], unitTests = [] } = data
|
||||
|
||||
const totalFiles = files.length
|
||||
const totalModels = models.length
|
||||
const totalComponents = components.length
|
||||
const totalTests = playwrightTests.length + storybookStories.length + unitTests.length
|
||||
|
||||
let score = 0
|
||||
if (totalFiles > 0) score += 30
|
||||
if (totalModels > 0) score += 20
|
||||
if (totalComponents > 0) score += 20
|
||||
if (totalTests > 0) score += 30
|
||||
|
||||
const completionScore = Math.min(score, 100)
|
||||
|
||||
return {
|
||||
completionScore,
|
||||
completionStatus: completionScore >= 70 ? 'ready' : 'inProgress',
|
||||
completionMessage: getCompletionMessage(completionScore)
|
||||
}
|
||||
}
|
||||
|
||||
function getCompletionMessage(score: number): string {
|
||||
if (score >= 90) return 'Excellent! Your project is production-ready.'
|
||||
if (score >= 70) return 'Great progress! Consider adding more tests.'
|
||||
if (score >= 50) return 'Good start! Keep building features.'
|
||||
return 'Just getting started. Add some components and models.'
|
||||
}
|
||||
|
||||
export function ProjectDashboard(props: ProjectDashboardProps) {
|
||||
const {
|
||||
files,
|
||||
models,
|
||||
components,
|
||||
theme,
|
||||
playwrightTests,
|
||||
storybookStories,
|
||||
unitTests,
|
||||
flaskConfig,
|
||||
nextjsConfig,
|
||||
onNavigate,
|
||||
} = props
|
||||
|
||||
const metrics = useDashboardMetrics({
|
||||
files,
|
||||
models,
|
||||
components,
|
||||
theme,
|
||||
playwrightTests,
|
||||
storybookStories,
|
||||
unitTests,
|
||||
flaskConfig,
|
||||
})
|
||||
|
||||
const tips = useDashboardTips({
|
||||
totalFiles: metrics.totalFiles,
|
||||
totalModels: metrics.totalModels,
|
||||
totalComponents: metrics.totalComponents,
|
||||
totalThemeVariants: metrics.totalThemeVariants,
|
||||
totalTests: metrics.totalTests,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<Stack direction="vertical" spacing="lg">
|
||||
<Stack direction="vertical" spacing="xs">
|
||||
<Heading level={1} className="text-3xl font-bold">
|
||||
Project Dashboard
|
||||
</Heading>
|
||||
<Text variant="muted">
|
||||
Overview of your CodeForge project
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<CompletionCard
|
||||
completionScore={metrics.completionScore}
|
||||
completionMessage={metrics.completionMessage}
|
||||
isReadyToExport={metrics.isReadyToExport}
|
||||
/>
|
||||
|
||||
<ResponsiveGrid columns={3} gap="md">
|
||||
<StatCard
|
||||
icon={<Code size={24} weight="duotone" />}
|
||||
title="Code Files"
|
||||
value={metrics.totalFiles}
|
||||
description={`${metrics.totalFiles} file${metrics.totalFiles !== 1 ? 's' : ''} in your project`}
|
||||
color="text-blue-500"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
icon={<Database size={24} weight="duotone" />}
|
||||
title="Database Models"
|
||||
value={metrics.totalModels}
|
||||
description={`${metrics.totalModels} Prisma model${metrics.totalModels !== 1 ? 's' : ''} defined`}
|
||||
color="text-purple-500"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
icon={<Tree size={24} weight="duotone" />}
|
||||
title="Components"
|
||||
value={metrics.totalComponents}
|
||||
description={`${metrics.totalComponents} component${metrics.totalComponents !== 1 ? 's' : ''} in tree`}
|
||||
color="text-green-500"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
icon={<PaintBrush size={24} weight="duotone" />}
|
||||
title="Theme Variants"
|
||||
value={metrics.totalThemeVariants}
|
||||
description={`${metrics.totalThemeVariants} theme${metrics.totalThemeVariants !== 1 ? 's' : ''} configured`}
|
||||
color="text-pink-500"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
icon={<Flask size={24} weight="duotone" />}
|
||||
title="API Endpoints"
|
||||
value={metrics.totalEndpoints}
|
||||
description={`${metrics.totalEndpoints} Flask endpoint${metrics.totalEndpoints !== 1 ? 's' : ''}`}
|
||||
color="text-orange-500"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
icon={<Cube size={24} weight="duotone" />}
|
||||
title="Tests"
|
||||
value={metrics.totalTests}
|
||||
description={`${metrics.totalTests} test${metrics.totalTests !== 1 ? 's' : ''} written`}
|
||||
color="text-cyan-500"
|
||||
/>
|
||||
</ResponsiveGrid>
|
||||
|
||||
<SeedDataStatus />
|
||||
|
||||
{nextjsConfig?.githubRepo && (
|
||||
<GitHubBuildStatus
|
||||
owner={nextjsConfig.githubRepo.owner}
|
||||
repo={nextjsConfig.githubRepo.repo}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stack direction="vertical" spacing="md">
|
||||
<PanelHeader
|
||||
title="Quick Actions"
|
||||
subtitle="Jump to commonly used tools"
|
||||
icon={<Rocket size={24} weight="duotone" />}
|
||||
/>
|
||||
<ResponsiveGrid columns={4} gap="md">
|
||||
<QuickActionButton
|
||||
icon={<Code size={32} weight="duotone" />}
|
||||
label="Code Editor"
|
||||
description="Edit files"
|
||||
variant="primary"
|
||||
onClick={() => onNavigate?.('code')}
|
||||
/>
|
||||
<QuickActionButton
|
||||
icon={<Database size={32} weight="duotone" />}
|
||||
label="Models"
|
||||
description="Design schema"
|
||||
variant="primary"
|
||||
onClick={() => onNavigate?.('models')}
|
||||
/>
|
||||
<QuickActionButton
|
||||
icon={<Tree size={32} weight="duotone" />}
|
||||
label="Components"
|
||||
description="Build UI"
|
||||
variant="accent"
|
||||
onClick={() => onNavigate?.('components')}
|
||||
/>
|
||||
<QuickActionButton
|
||||
icon={<Package size={32} weight="duotone" />}
|
||||
label="Deploy"
|
||||
description="Export project"
|
||||
variant="accent"
|
||||
onClick={() => onNavigate?.('export')}
|
||||
/>
|
||||
</ResponsiveGrid>
|
||||
</Stack>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<GitBranch size={20} />
|
||||
Project Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Stack direction="vertical" spacing="md">
|
||||
<DetailRow
|
||||
icon={<Play size={18} />}
|
||||
label="Playwright Tests"
|
||||
value={metrics.playwrightCount}
|
||||
/>
|
||||
<DetailRow
|
||||
icon={<FileText size={18} />}
|
||||
label="Storybook Stories"
|
||||
value={metrics.storybookCount}
|
||||
/>
|
||||
<DetailRow
|
||||
icon={<Cube size={18} />}
|
||||
label="Unit Tests"
|
||||
value={metrics.unitTestCount}
|
||||
/>
|
||||
<DetailRow
|
||||
icon={<Flask size={18} />}
|
||||
label="Flask Blueprints"
|
||||
value={metrics.blueprintCount}
|
||||
/>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TipsCard tips={tips} />
|
||||
</Stack>
|
||||
</div>
|
||||
<JSONPageRenderer
|
||||
schema={dashboardSchema as any}
|
||||
data={props}
|
||||
functions={{ calculateCompletionScore }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,628 +1 @@
|
||||
import { useState } from 'react'
|
||||
import { ThemeConfig, ThemeVariant } from '@/types/project'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { PaintBrush, Sparkle, Plus, Trash, Moon, Sun, Palette } from '@phosphor-icons/react'
|
||||
import { AIService } from '@/lib/ai-service'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
interface StyleDesignerProps {
|
||||
theme: ThemeConfig
|
||||
onThemeChange: (theme: ThemeConfig | ((current: ThemeConfig) => ThemeConfig)) => void
|
||||
}
|
||||
|
||||
export function StyleDesigner({ theme, onThemeChange }: StyleDesignerProps) {
|
||||
const [newColorName, setNewColorName] = useState('')
|
||||
const [newColorValue, setNewColorValue] = useState('#000000')
|
||||
const [customColorDialogOpen, setCustomColorDialogOpen] = useState(false)
|
||||
const [newVariantDialogOpen, setNewVariantDialogOpen] = useState(false)
|
||||
const [newVariantName, setNewVariantName] = useState('')
|
||||
|
||||
if (!theme.variants || theme.variants.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<PaintBrush size={48} className="mx-auto mb-4 text-muted-foreground" weight="duotone" />
|
||||
<p className="text-muted-foreground">Theme configuration is invalid or missing</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const activeVariant = theme.variants.find((v) => v.id === theme.activeVariantId) || theme.variants[0]
|
||||
|
||||
const updateTheme = (updates: Partial<ThemeConfig>) => {
|
||||
onThemeChange((current) => ({ ...current, ...updates }))
|
||||
}
|
||||
|
||||
const updateActiveVariantColors = (colorUpdates: Partial<typeof activeVariant.colors>) => {
|
||||
onThemeChange((current) => ({
|
||||
...current,
|
||||
variants: (current.variants || []).map((v) =>
|
||||
v.id === current.activeVariantId
|
||||
? { ...v, colors: { ...v.colors, ...colorUpdates } }
|
||||
: v
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
const addCustomColor = () => {
|
||||
if (!newColorName.trim()) {
|
||||
toast.error('Please enter a color name')
|
||||
return
|
||||
}
|
||||
|
||||
updateActiveVariantColors({
|
||||
customColors: {
|
||||
...activeVariant.colors.customColors,
|
||||
[newColorName]: newColorValue,
|
||||
},
|
||||
})
|
||||
|
||||
setNewColorName('')
|
||||
setNewColorValue('#000000')
|
||||
setCustomColorDialogOpen(false)
|
||||
toast.success(`Added custom color: ${newColorName}`)
|
||||
}
|
||||
|
||||
const removeCustomColor = (colorName: string) => {
|
||||
const { [colorName]: _, ...remainingColors } = activeVariant.colors.customColors
|
||||
updateActiveVariantColors({
|
||||
customColors: remainingColors,
|
||||
})
|
||||
toast.success(`Removed custom color: ${colorName}`)
|
||||
}
|
||||
|
||||
const addVariant = () => {
|
||||
if (!newVariantName.trim()) {
|
||||
toast.error('Please enter a variant name')
|
||||
return
|
||||
}
|
||||
|
||||
const newVariant: ThemeVariant = {
|
||||
id: `variant-${Date.now()}`,
|
||||
name: newVariantName,
|
||||
colors: { ...activeVariant.colors, customColors: {} },
|
||||
}
|
||||
|
||||
onThemeChange((current) => ({
|
||||
...current,
|
||||
variants: [...(current.variants || []), newVariant],
|
||||
activeVariantId: newVariant.id,
|
||||
}))
|
||||
|
||||
setNewVariantName('')
|
||||
setNewVariantDialogOpen(false)
|
||||
toast.success(`Added theme variant: ${newVariantName}`)
|
||||
}
|
||||
|
||||
const deleteVariant = (variantId: string) => {
|
||||
if (!theme.variants || theme.variants.length <= 1) {
|
||||
toast.error('Cannot delete the last theme variant')
|
||||
return
|
||||
}
|
||||
|
||||
onThemeChange((current) => {
|
||||
const remainingVariants = (current.variants || []).filter((v) => v.id !== variantId)
|
||||
return {
|
||||
...current,
|
||||
variants: remainingVariants,
|
||||
activeVariantId: current.activeVariantId === variantId ? remainingVariants[0].id : current.activeVariantId,
|
||||
}
|
||||
})
|
||||
|
||||
toast.success('Theme variant deleted')
|
||||
}
|
||||
|
||||
const duplicateVariant = (variantId: string) => {
|
||||
const variantToDuplicate = (theme.variants || []).find((v) => v.id === variantId)
|
||||
if (!variantToDuplicate) return
|
||||
|
||||
const newVariant: ThemeVariant = {
|
||||
id: `variant-${Date.now()}`,
|
||||
name: `${variantToDuplicate.name} Copy`,
|
||||
colors: { ...variantToDuplicate.colors, customColors: { ...variantToDuplicate.colors.customColors } },
|
||||
}
|
||||
|
||||
onThemeChange((current) => ({
|
||||
...current,
|
||||
variants: [...(current.variants || []), newVariant],
|
||||
}))
|
||||
|
||||
toast.success('Theme variant duplicated')
|
||||
}
|
||||
|
||||
const generateThemeWithAI = async () => {
|
||||
const description = prompt('Describe the visual style you want (e.g., "modern and professional", "vibrant and playful"):')
|
||||
if (!description) return
|
||||
|
||||
try {
|
||||
toast.info('Generating theme with AI...')
|
||||
const generatedTheme = await AIService.generateThemeFromDescription(description)
|
||||
|
||||
if (generatedTheme) {
|
||||
onThemeChange((current) => ({ ...current, ...generatedTheme }))
|
||||
toast.success('Theme generated successfully!')
|
||||
} else {
|
||||
toast.error('AI generation failed. Please try again.')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to generate theme')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const renderColorInput = (label: string, colorKey: keyof typeof activeVariant.colors, excludeCustom = true) => {
|
||||
if (excludeCustom && colorKey === 'customColors') return null
|
||||
|
||||
const value = activeVariant.colors[colorKey] as string
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{label}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => updateActiveVariantColors({ [colorKey]: e.target.value })}
|
||||
className="w-20 h-10 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => updateActiveVariantColors({ [colorKey]: e.target.value })}
|
||||
className="flex-1 font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-2">Theme Designer</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Create and customize multiple theme variants with custom colors
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={generateThemeWithAI} variant="outline">
|
||||
<Sparkle size={16} className="mr-2" weight="duotone" />
|
||||
Generate with AI
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Palette size={20} weight="duotone" />
|
||||
Theme Variants
|
||||
</h3>
|
||||
<Button onClick={() => setNewVariantDialogOpen(true)} size="sm">
|
||||
<Plus size={16} className="mr-2" />
|
||||
Add Variant
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{theme.variants.map((variant) => (
|
||||
<div key={variant.id} className="flex items-center gap-2 group">
|
||||
<Button
|
||||
variant={theme.activeVariantId === variant.id ? 'default' : 'outline'}
|
||||
onClick={() => updateTheme({ activeVariantId: variant.id })}
|
||||
className="gap-2"
|
||||
>
|
||||
{variant.name === 'Light' && <Sun size={16} weight="duotone" />}
|
||||
{variant.name === 'Dark' && <Moon size={16} weight="duotone" />}
|
||||
{variant.name}
|
||||
</Button>
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => duplicateVariant(variant.id)}
|
||||
title="Duplicate"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => deleteVariant(variant.id)}
|
||||
disabled={theme.variants.length <= 1}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="standard" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="standard">Standard Colors</TabsTrigger>
|
||||
<TabsTrigger value="extended">Extended Colors</TabsTrigger>
|
||||
<TabsTrigger value="custom">Custom Colors ({Object.keys(activeVariant.colors.customColors).length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="standard" className="space-y-6 mt-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{renderColorInput('Primary Color', 'primaryColor')}
|
||||
{renderColorInput('Secondary Color', 'secondaryColor')}
|
||||
{renderColorInput('Error Color', 'errorColor')}
|
||||
{renderColorInput('Warning Color', 'warningColor')}
|
||||
{renderColorInput('Success Color', 'successColor')}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="extended" className="space-y-6 mt-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{renderColorInput('Background', 'background')}
|
||||
{renderColorInput('Surface', 'surface')}
|
||||
{renderColorInput('Text', 'text')}
|
||||
{renderColorInput('Text Secondary', 'textSecondary')}
|
||||
{renderColorInput('Border', 'border')}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="custom" className="space-y-6 mt-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add custom colors for your specific needs
|
||||
</p>
|
||||
<Button onClick={() => setCustomColorDialogOpen(true)} size="sm">
|
||||
<Plus size={16} className="mr-2" />
|
||||
Add Custom Color
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{Object.keys(activeVariant.colors.customColors).length === 0 ? (
|
||||
<div className="text-center py-12 border-2 border-dashed border-border rounded-lg">
|
||||
<Palette size={48} className="mx-auto mb-4 text-muted-foreground" weight="duotone" />
|
||||
<p className="text-muted-foreground mb-4">No custom colors yet</p>
|
||||
<Button onClick={() => setCustomColorDialogOpen(true)} variant="outline">
|
||||
<Plus size={16} className="mr-2" />
|
||||
Add Your First Custom Color
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{Object.entries(activeVariant.colors.customColors).map(([name, value]) => (
|
||||
<div key={name} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="capitalize">{name}</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeCustomColor(name)}
|
||||
>
|
||||
<Trash size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
updateActiveVariantColors({
|
||||
customColors: {
|
||||
...activeVariant.colors.customColors,
|
||||
[name]: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-20 h-10 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
updateActiveVariantColors({
|
||||
customColors: {
|
||||
...activeVariant.colors.customColors,
|
||||
[name]: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="flex-1 font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Typography</h3>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>Font Family</Label>
|
||||
<Input
|
||||
value={theme.fontFamily}
|
||||
onChange={(e) => updateTheme({ fontFamily: e.target.value })}
|
||||
placeholder="Roboto, Arial, sans-serif"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Label>Small Font Size</Label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{theme.fontSize.small}px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[theme.fontSize.small]}
|
||||
onValueChange={([value]) =>
|
||||
updateTheme({
|
||||
fontSize: { ...theme.fontSize, small: value },
|
||||
})
|
||||
}
|
||||
min={10}
|
||||
max={20}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Label>Medium Font Size</Label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{theme.fontSize.medium}px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[theme.fontSize.medium]}
|
||||
onValueChange={([value]) =>
|
||||
updateTheme({
|
||||
fontSize: { ...theme.fontSize, medium: value },
|
||||
})
|
||||
}
|
||||
min={12}
|
||||
max={24}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Label>Large Font Size</Label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{theme.fontSize.large}px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[theme.fontSize.large]}
|
||||
onValueChange={([value]) =>
|
||||
updateTheme({
|
||||
fontSize: { ...theme.fontSize, large: value },
|
||||
})
|
||||
}
|
||||
min={16}
|
||||
max={48}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Spacing & Shape</h3>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Label>Base Spacing Unit</Label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{theme.spacing}px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[theme.spacing]}
|
||||
onValueChange={([value]) => updateTheme({ spacing: value })}
|
||||
min={4}
|
||||
max={16}
|
||||
step={1}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Material UI multiplies this value (e.g., spacing(2) = {theme.spacing * 2}px)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Label>Border Radius</Label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{theme.borderRadius}px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[theme.borderRadius]}
|
||||
onValueChange={([value]) =>
|
||||
updateTheme({ borderRadius: value })
|
||||
}
|
||||
min={0}
|
||||
max={24}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6" style={{ backgroundColor: activeVariant.colors.background }}>
|
||||
<h3 className="text-lg font-semibold mb-4" style={{ color: activeVariant.colors.text }}>
|
||||
Preview - {activeVariant.name} Mode
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div
|
||||
className="w-24 h-24 rounded flex flex-col items-center justify-center text-white font-semibold text-sm"
|
||||
style={{
|
||||
backgroundColor: activeVariant.colors.primaryColor,
|
||||
borderRadius: `${theme.borderRadius}px`,
|
||||
}}
|
||||
>
|
||||
Primary
|
||||
</div>
|
||||
<div
|
||||
className="w-24 h-24 rounded flex flex-col items-center justify-center text-white font-semibold text-sm"
|
||||
style={{
|
||||
backgroundColor: activeVariant.colors.secondaryColor,
|
||||
borderRadius: `${theme.borderRadius}px`,
|
||||
}}
|
||||
>
|
||||
Secondary
|
||||
</div>
|
||||
<div
|
||||
className="w-24 h-24 rounded flex flex-col items-center justify-center text-white font-semibold text-sm"
|
||||
style={{
|
||||
backgroundColor: activeVariant.colors.errorColor,
|
||||
borderRadius: `${theme.borderRadius}px`,
|
||||
}}
|
||||
>
|
||||
Error
|
||||
</div>
|
||||
<div
|
||||
className="w-24 h-24 rounded flex flex-col items-center justify-center text-white font-semibold text-sm"
|
||||
style={{
|
||||
backgroundColor: activeVariant.colors.warningColor,
|
||||
borderRadius: `${theme.borderRadius}px`,
|
||||
}}
|
||||
>
|
||||
Warning
|
||||
</div>
|
||||
<div
|
||||
className="w-24 h-24 rounded flex flex-col items-center justify-center text-white font-semibold text-sm"
|
||||
style={{
|
||||
backgroundColor: activeVariant.colors.successColor,
|
||||
borderRadius: `${theme.borderRadius}px`,
|
||||
}}
|
||||
>
|
||||
Success
|
||||
</div>
|
||||
{Object.entries(activeVariant.colors.customColors).map(([name, color]) => (
|
||||
<div
|
||||
key={name}
|
||||
className="w-24 h-24 rounded flex flex-col items-center justify-center text-white font-semibold text-sm capitalize"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderRadius: `${theme.borderRadius}px`,
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="p-6"
|
||||
style={{
|
||||
fontFamily: theme.fontFamily,
|
||||
borderRadius: `${theme.borderRadius}px`,
|
||||
backgroundColor: activeVariant.colors.surface,
|
||||
color: activeVariant.colors.text,
|
||||
border: `1px solid ${activeVariant.colors.border}`,
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: `${theme.fontSize.large}px`, marginBottom: '8px' }}>
|
||||
Large Text Sample
|
||||
</p>
|
||||
<p style={{ fontSize: `${theme.fontSize.medium}px`, marginBottom: '8px' }}>
|
||||
Medium Text Sample
|
||||
</p>
|
||||
<p style={{ fontSize: `${theme.fontSize.small}px`, color: activeVariant.colors.textSecondary }}>
|
||||
Small Text Sample (Secondary)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Dialog open={customColorDialogOpen} onOpenChange={setCustomColorDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Custom Color</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a custom color for your theme variant
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Color Name</Label>
|
||||
<Input
|
||||
value={newColorName}
|
||||
onChange={(e) => setNewColorName(e.target.value)}
|
||||
placeholder="e.g., accent, highlight, brand"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Color Value</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={newColorValue}
|
||||
onChange={(e) => setNewColorValue(e.target.value)}
|
||||
className="w-20 h-10 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={newColorValue}
|
||||
onChange={(e) => setNewColorValue(e.target.value)}
|
||||
className="flex-1 font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCustomColorDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={addCustomColor}>Add Color</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={newVariantDialogOpen} onOpenChange={setNewVariantDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Theme Variant</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new theme variant based on the current one
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Variant Name</Label>
|
||||
<Input
|
||||
value={newVariantName}
|
||||
onChange={(e) => setNewVariantName(e.target.value)}
|
||||
placeholder="e.g., High Contrast, Colorblind Friendly"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setNewVariantDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={addVariant}>Add Variant</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export { JSONStyleDesigner as StyleDesigner } from './JSONStyleDesigner'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user