Generated by Spark: Remove duplicate components, prefer json version.

This commit is contained in:
2026-01-17 21:18:55 +00:00
committed by GitHub
parent 98f4b49edf
commit 613450f8fb
9 changed files with 99 additions and 3883 deletions

42
REMOVED_DUPLICATES.md Normal file
View 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)

View File

@@ -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",

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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 }}
/>
)
}

View File

@@ -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