Generated by Spark: Make a React app that can code generate Next.js, Material UI and GHCR apps. It should have Monaco editor for programming actions. It needs a model designer (Prisma?). Try to keep it low code and add gui designers for stylin, component tree and other aspects of generating a react app.

This commit is contained in:
2026-01-16 00:38:18 +00:00
committed by GitHub
parent 0cc2aebec1
commit e5557fbfa9
15 changed files with 1849 additions and 3 deletions

View File

@@ -1,5 +1,251 @@
import { useState } from 'react'
import { useKV } from '@github/spark/hooks'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
import { Code, Database, Tree, PaintBrush, Download, Sparkle } from '@phosphor-icons/react'
import { ProjectFile, PrismaModel, ComponentNode, ThemeConfig } from '@/types/project'
import { CodeEditor } from '@/components/CodeEditor'
import { ModelDesigner } from '@/components/ModelDesigner'
import { ComponentTreeBuilder } from '@/components/ComponentTreeBuilder'
import { StyleDesigner } from '@/components/StyleDesigner'
import { FileExplorer } from '@/components/FileExplorer'
import { generateNextJSProject, generatePrismaSchema, generateMUITheme } from '@/lib/generators'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
const DEFAULT_THEME: ThemeConfig = {
primaryColor: '#1976d2',
secondaryColor: '#dc004e',
errorColor: '#f44336',
warningColor: '#ff9800',
successColor: '#4caf50',
fontFamily: 'Roboto, Arial, sans-serif',
fontSize: { small: 12, medium: 14, large: 20 },
spacing: 8,
borderRadius: 4,
}
const DEFAULT_FILES: ProjectFile[] = [
{
id: 'file-1',
name: 'page.tsx',
path: '/src/app/page.tsx',
content: `'use client'\n\nimport { ThemeProvider } from '@mui/material/styles'\nimport CssBaseline from '@mui/material/CssBaseline'\nimport { theme } from '@/theme'\nimport { Box, Typography, Button } from '@mui/material'\n\nexport default function Home() {\n return (\n <ThemeProvider theme={theme}>\n <CssBaseline />\n <Box sx={{ p: 4 }}>\n <Typography variant="h3" gutterBottom>\n Welcome to Your App\n </Typography>\n <Button variant="contained" color="primary">\n Get Started\n </Button>\n </Box>\n </ThemeProvider>\n )\n}`,
language: 'typescript',
},
{
id: 'file-2',
name: 'layout.tsx',
path: '/src/app/layout.tsx',
content: `export const metadata = {\n title: 'My Next.js App',\n description: 'Generated with CodeForge',\n}\n\nexport default function RootLayout({\n children,\n}: {\n children: React.ReactNode\n}) {\n return (\n <html lang="en">\n <body>{children}</body>\n </html>\n )\n}`,
language: 'typescript',
},
]
function App() {
return <div></div>
const [files, setFiles] = useKV<ProjectFile[]>('project-files', DEFAULT_FILES)
const [models, setModels] = useKV<PrismaModel[]>('project-models', [])
const [components, setComponents] = useKV<ComponentNode[]>('project-components', [])
const [theme, setTheme] = useKV<ThemeConfig>('project-theme', DEFAULT_THEME)
const [activeFileId, setActiveFileId] = useState<string | null>((files || [])[0]?.id || null)
const [activeTab, setActiveTab] = useState('code')
const [exportDialogOpen, setExportDialogOpen] = useState(false)
const [generatedCode, setGeneratedCode] = useState<Record<string, string>>({})
const safeFiles = files || []
const safeModels = models || []
const safeComponents = components || []
const safeTheme = theme || DEFAULT_THEME
const handleFileChange = (fileId: string, content: string) => {
setFiles((currentFiles) =>
(currentFiles || []).map((f) => (f.id === fileId ? { ...f, content } : f))
)
}
const handleFileAdd = (file: ProjectFile) => {
setFiles((currentFiles) => [...(currentFiles || []), file])
setActiveFileId(file.id)
}
const handleFileClose = (fileId: string) => {
if (activeFileId === fileId) {
const currentIndex = safeFiles.findIndex((f) => f.id === fileId)
const nextFile = safeFiles[currentIndex + 1] || safeFiles[currentIndex - 1]
setActiveFileId(nextFile?.id || null)
}
}
const handleExportProject = () => {
const projectFiles = generateNextJSProject('my-nextjs-app', safeModels, safeComponents, safeTheme)
const prismaSchema = generatePrismaSchema(safeModels)
const themeCode = generateMUITheme(safeTheme)
const allFiles = {
...projectFiles,
'prisma/schema.prisma': prismaSchema,
'src/theme.ts': themeCode,
...safeFiles.reduce((acc, file) => {
acc[file.path] = file.content
return acc
}, {} as Record<string, string>),
}
setGeneratedCode(allFiles)
setExportDialogOpen(true)
toast.success('Project files generated!')
}
const handleGenerateWithAI = async () => {
try {
toast.info('AI generation coming soon!')
} catch (error) {
toast.error('AI generation failed')
}
}
return (
<div className="h-screen flex flex-col bg-background text-foreground">
<header className="border-b border-border bg-card px-6 py-4">
<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-primary to-accent flex items-center justify-center">
<Code size={24} weight="duotone" className="text-white" />
</div>
<div>
<h1 className="text-xl font-bold">CodeForge</h1>
<p className="text-xs text-muted-foreground">
Low-Code Next.js App Builder
</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleGenerateWithAI}>
<Sparkle size={16} className="mr-2" weight="duotone" />
AI Generate
</Button>
<Button onClick={handleExportProject}>
<Download size={16} className="mr-2" />
Export Project
</Button>
</div>
</div>
</header>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col">
<div className="border-b border-border bg-card px-6">
<TabsList className="h-12 bg-transparent">
<TabsTrigger value="code" className="gap-2">
<Code size={18} />
Code Editor
</TabsTrigger>
<TabsTrigger value="models" className="gap-2">
<Database size={18} />
Models
</TabsTrigger>
<TabsTrigger value="components" className="gap-2">
<Tree size={18} />
Components
</TabsTrigger>
<TabsTrigger value="styling" className="gap-2">
<PaintBrush size={18} />
Styling
</TabsTrigger>
</TabsList>
</div>
<div className="flex-1 overflow-hidden">
<TabsContent value="code" className="h-full m-0">
<ResizablePanelGroup direction="horizontal">
<ResizablePanel defaultSize={20} minSize={15} maxSize={30}>
<FileExplorer
files={safeFiles}
activeFileId={activeFileId}
onFileSelect={setActiveFileId}
onFileAdd={handleFileAdd}
/>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={80}>
<CodeEditor
files={safeFiles}
activeFileId={activeFileId}
onFileChange={handleFileChange}
onFileSelect={setActiveFileId}
onFileClose={handleFileClose}
/>
</ResizablePanel>
</ResizablePanelGroup>
</TabsContent>
<TabsContent value="models" className="h-full m-0">
<ModelDesigner models={safeModels} onModelsChange={setModels} />
</TabsContent>
<TabsContent value="components" className="h-full m-0">
<ComponentTreeBuilder
components={safeComponents}
onComponentsChange={setComponents}
/>
</TabsContent>
<TabsContent value="styling" className="h-full m-0">
<StyleDesigner theme={safeTheme} onThemeChange={setTheme} />
</TabsContent>
</div>
</Tabs>
<Dialog open={exportDialogOpen} onOpenChange={setExportDialogOpen}>
<DialogContent className="max-w-4xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>Generated Project Files</DialogTitle>
<DialogDescription>
Copy these files to create your Next.js application
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-96">
<div className="space-y-4">
{Object.entries(generatedCode).map(([path, content]) => (
<Card key={path} className="p-4">
<div className="flex items-center justify-between mb-2">
<code className="text-sm font-semibold text-accent">
{path}
</code>
<Button
size="sm"
variant="outline"
onClick={() => {
navigator.clipboard.writeText(content)
toast.success(`Copied ${path}`)
}}
>
Copy
</Button>
</div>
<Textarea
value={content}
readOnly
className="font-mono text-xs h-48"
/>
</Card>
))}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
</div>
)
}
export default App

View File

@@ -0,0 +1,85 @@
import Editor from '@monaco-editor/react'
import { Card } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ProjectFile } from '@/types/project'
import { FileCode, X } from '@phosphor-icons/react'
interface CodeEditorProps {
files: ProjectFile[]
activeFileId: string | null
onFileChange: (fileId: string, content: string) => void
onFileSelect: (fileId: string) => void
onFileClose: (fileId: string) => void
}
export function CodeEditor({
files,
activeFileId,
onFileChange,
onFileSelect,
onFileClose,
}: CodeEditorProps) {
const activeFile = files.find((f) => f.id === activeFileId)
const openFiles = files.filter((f) => f.id === activeFileId || files.length < 5)
return (
<div className="h-full flex flex-col">
{openFiles.length > 0 ? (
<>
<div className="flex items-center gap-1 bg-secondary/50 border-b border-border px-2 py-1">
{openFiles.map((file) => (
<button
key={file.id}
onClick={() => onFileSelect(file.id)}
className={`flex items-center gap-2 px-3 py-1.5 rounded text-sm transition-colors ${
file.id === activeFileId
? 'bg-card text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-card/50'
}`}
>
<FileCode size={16} />
<span>{file.name}</span>
<button
onClick={(e) => {
e.stopPropagation()
onFileClose(file.id)
}}
className="hover:text-destructive"
>
<X size={14} />
</button>
</button>
))}
</div>
<div className="flex-1">
{activeFile && (
<Editor
height="100%"
language={activeFile.language}
value={activeFile.content}
onChange={(value) => onFileChange(activeFile.id, value || '')}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 14,
fontFamily: 'JetBrains Mono, monospace',
fontLigatures: true,
lineNumbers: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
}}
/>
)}
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<FileCode size={48} className="mx-auto mb-4 opacity-50" />
<p>Select a file to edit</p>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,268 @@
import { useState } from 'react'
import { ComponentNode } 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 { ScrollArea } from '@/components/ui/scroll-area'
import { Plus, Trash, Tree, CaretRight, CaretDown } from '@phosphor-icons/react'
import { Textarea } from '@/components/ui/textarea'
interface ComponentTreeBuilderProps {
components: ComponentNode[]
onComponentsChange: (components: ComponentNode[]) => void
}
const MUI_COMPONENTS = [
'Box',
'Container',
'Grid',
'Stack',
'Paper',
'Card',
'CardContent',
'CardActions',
'Button',
'TextField',
'Typography',
'AppBar',
'Toolbar',
'List',
'ListItem',
'ListItemText',
'Divider',
'Avatar',
'Chip',
'IconButton',
]
export function ComponentTreeBuilder({
components,
onComponentsChange,
}: ComponentTreeBuilderProps) {
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set())
const findNodeById = (
nodes: ComponentNode[],
id: string
): ComponentNode | null => {
for (const node of nodes) {
if (node.id === id) return node
const found = findNodeById(node.children, id)
if (found) return found
}
return null
}
const selectedNode = selectedNodeId ? findNodeById(components, selectedNodeId) : null
const addRootComponent = () => {
const newNode: ComponentNode = {
id: `node-${Date.now()}`,
type: 'Box',
name: `Component${components.length + 1}`,
props: {},
children: [],
}
onComponentsChange([...components, newNode])
setSelectedNodeId(newNode.id)
}
const addChildComponent = (parentId: string) => {
const newNode: ComponentNode = {
id: `node-${Date.now()}`,
type: 'Box',
name: 'NewComponent',
props: {},
children: [],
}
const addChild = (nodes: ComponentNode[]): ComponentNode[] => {
return nodes.map((node) => {
if (node.id === parentId) {
return { ...node, children: [...node.children, newNode] }
}
return { ...node, children: addChild(node.children) }
})
}
onComponentsChange(addChild(components))
setExpandedNodes(new Set([...expandedNodes, parentId]))
setSelectedNodeId(newNode.id)
}
const deleteNode = (nodeId: string) => {
const deleteFromTree = (nodes: ComponentNode[]): ComponentNode[] => {
return nodes
.filter((node) => node.id !== nodeId)
.map((node) => ({ ...node, children: deleteFromTree(node.children) }))
}
onComponentsChange(deleteFromTree(components))
if (selectedNodeId === nodeId) {
setSelectedNodeId(null)
}
}
const updateNode = (nodeId: string, updates: Partial<ComponentNode>) => {
const updateInTree = (nodes: ComponentNode[]): ComponentNode[] => {
return nodes.map((node) => {
if (node.id === nodeId) {
return { ...node, ...updates }
}
return { ...node, children: updateInTree(node.children) }
})
}
onComponentsChange(updateInTree(components))
}
const toggleExpand = (nodeId: string) => {
const newExpanded = new Set(expandedNodes)
if (newExpanded.has(nodeId)) {
newExpanded.delete(nodeId)
} else {
newExpanded.add(nodeId)
}
setExpandedNodes(newExpanded)
}
const renderTreeNode = (node: ComponentNode, level: number = 0) => {
const isExpanded = expandedNodes.has(node.id)
const isSelected = selectedNodeId === node.id
const hasChildren = node.children.length > 0
return (
<div key={node.id}>
<button
onClick={() => setSelectedNodeId(node.id)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded text-sm transition-colors ${
isSelected
? 'bg-accent text-accent-foreground'
: 'hover:bg-muted text-foreground'
}`}
style={{ paddingLeft: `${level * 20 + 12}px` }}
>
{hasChildren && (
<button
onClick={(e) => {
e.stopPropagation()
toggleExpand(node.id)
}}
className="hover:text-accent"
>
{isExpanded ? <CaretDown size={16} /> : <CaretRight size={16} />}
</button>
)}
{!hasChildren && <div className="w-4" />}
<Tree size={16} />
<span className="font-medium">{node.name}</span>
<span className="text-muted-foreground text-xs ml-auto">{node.type}</span>
</button>
{isExpanded &&
node.children.map((child) => renderTreeNode(child, level + 1))}
</div>
)
}
return (
<div className="h-full flex gap-4 p-6">
<div className="w-80 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm uppercase tracking-wide">
Component Tree
</h3>
<Button size="sm" onClick={addRootComponent} className="h-8 w-8 p-0">
<Plus size={16} />
</Button>
</div>
<ScrollArea className="flex-1 border rounded-lg">
<div className="p-2 space-y-1">
{components.map((node) => renderTreeNode(node))}
</div>
</ScrollArea>
</div>
<Card className="flex-1 p-6">
{selectedNode ? (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h4 className="text-lg font-semibold">Component Properties</h4>
<Button
variant="destructive"
size="sm"
onClick={() => deleteNode(selectedNode.id)}
>
<Trash size={16} />
</Button>
</div>
<div className="grid gap-4">
<div className="space-y-2">
<Label>Component Name</Label>
<Input
value={selectedNode.name}
onChange={(e) =>
updateNode(selectedNode.id, { name: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label>Component Type</Label>
<Select
value={selectedNode.type}
onValueChange={(value) =>
updateNode(selectedNode.id, { type: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{MUI_COMPONENTS.map((comp) => (
<SelectItem key={comp} value={comp}>
{comp}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Props (JSON)</Label>
<Textarea
value={JSON.stringify(selectedNode.props, null, 2)}
onChange={(e) => {
try {
const props = JSON.parse(e.target.value)
updateNode(selectedNode.id, { props })
} catch (err) {
}
}}
className="font-mono text-sm h-64"
placeholder='{"variant": "contained", "color": "primary"}'
/>
</div>
<Button onClick={() => addChildComponent(selectedNode.id)}>
<Plus size={16} className="mr-2" />
Add Child Component
</Button>
</div>
</div>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Tree size={48} className="mx-auto mb-4 opacity-50" />
<p>Select a component to edit properties</p>
</div>
</div>
)}
</Card>
</div>
)
}

View File

@@ -0,0 +1,147 @@
import { useState } from 'react'
import { ProjectFile } from '@/types/project'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { FileCode, FolderOpen, Plus, Folder } from '@phosphor-icons/react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
interface FileExplorerProps {
files: ProjectFile[]
activeFileId: string | null
onFileSelect: (fileId: string) => void
onFileAdd: (file: ProjectFile) => void
}
export function FileExplorer({
files,
activeFileId,
onFileSelect,
onFileAdd,
}: FileExplorerProps) {
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [newFileName, setNewFileName] = useState('')
const [newFileLanguage, setNewFileLanguage] = useState('typescript')
const handleAddFile = () => {
if (!newFileName.trim()) return
const newFile: ProjectFile = {
id: `file-${Date.now()}`,
name: newFileName,
path: `/src/${newFileName}`,
content: '',
language: newFileLanguage,
}
onFileAdd(newFile)
setNewFileName('')
setIsAddDialogOpen(false)
}
const groupedFiles = files.reduce((acc, file) => {
const dir = file.path.split('/').slice(0, -1).join('/') || '/'
if (!acc[dir]) acc[dir] = []
acc[dir].push(file)
return acc
}, {} as Record<string, ProjectFile[]>)
return (
<div className="h-full flex flex-col border-r border-border bg-card">
<div className="p-3 border-b border-border flex items-center justify-between">
<h3 className="font-semibold text-sm uppercase tracking-wide flex items-center gap-2">
<FolderOpen size={18} weight="duotone" />
Files
</h3>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button size="sm" className="h-7 w-7 p-0">
<Plus size={14} />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add New File</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>File Name</Label>
<Input
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
placeholder="example.tsx"
onKeyDown={(e) => {
if (e.key === 'Enter') handleAddFile()
}}
/>
</div>
<div className="space-y-2">
<Label>Language</Label>
<Select
value={newFileLanguage}
onValueChange={setNewFileLanguage}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="typescript">TypeScript</SelectItem>
<SelectItem value="javascript">JavaScript</SelectItem>
<SelectItem value="css">CSS</SelectItem>
<SelectItem value="json">JSON</SelectItem>
<SelectItem value="prisma">Prisma</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleAddFile} className="w-full">
Add File
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<ScrollArea className="flex-1">
<div className="p-2">
{Object.entries(groupedFiles).map(([dir, dirFiles]) => (
<div key={dir} className="mb-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1 px-2">
<Folder size={14} />
<span>{dir}</span>
</div>
<div className="space-y-0.5">
{dirFiles.map((file) => (
<button
key={file.id}
onClick={() => onFileSelect(file.id)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded text-sm transition-colors ${
activeFileId === file.id
? 'bg-accent text-accent-foreground'
: 'hover:bg-muted text-foreground'
}`}
>
<FileCode size={16} />
<span className="truncate">{file.name}</span>
</button>
))}
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,269 @@
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 } from '@phosphor-icons/react'
import { Badge } from '@/components/ui/badge'
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),
})
}
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>
<Button size="sm" onClick={addModel} className="h-8 w-8 p-0">
<Plus size={16} />
</Button>
</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>
<Button size="sm" onClick={addField}>
<Plus size={16} className="mr-2" />
Add Field
</Button>
</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>
)
}

View File

@@ -0,0 +1,315 @@
import { ThemeConfig } 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 { PaintBrush } from '@phosphor-icons/react'
interface StyleDesignerProps {
theme: ThemeConfig
onThemeChange: (theme: ThemeConfig) => void
}
export function StyleDesigner({ theme, onThemeChange }: StyleDesignerProps) {
const updateTheme = (updates: Partial<ThemeConfig>) => {
onThemeChange({ ...theme, ...updates })
}
return (
<div className="h-full overflow-auto p-6">
<div className="max-w-4xl mx-auto space-y-6">
<div>
<h2 className="text-2xl font-bold mb-2">Material UI Theme Designer</h2>
<p className="text-muted-foreground">
Customize your application's visual theme
</p>
</div>
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<PaintBrush size={20} weight="duotone" />
Color Palette
</h3>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">
<Label>Primary Color</Label>
<div className="flex gap-2">
<Input
type="color"
value={theme.primaryColor}
onChange={(e) => updateTheme({ primaryColor: e.target.value })}
className="w-20 h-10 cursor-pointer"
/>
<Input
value={theme.primaryColor}
onChange={(e) => updateTheme({ primaryColor: e.target.value })}
className="flex-1"
/>
</div>
</div>
<div className="space-y-2">
<Label>Secondary Color</Label>
<div className="flex gap-2">
<Input
type="color"
value={theme.secondaryColor}
onChange={(e) =>
updateTheme({ secondaryColor: e.target.value })
}
className="w-20 h-10 cursor-pointer"
/>
<Input
value={theme.secondaryColor}
onChange={(e) =>
updateTheme({ secondaryColor: e.target.value })
}
className="flex-1"
/>
</div>
</div>
<div className="space-y-2">
<Label>Error Color</Label>
<div className="flex gap-2">
<Input
type="color"
value={theme.errorColor}
onChange={(e) => updateTheme({ errorColor: e.target.value })}
className="w-20 h-10 cursor-pointer"
/>
<Input
value={theme.errorColor}
onChange={(e) => updateTheme({ errorColor: e.target.value })}
className="flex-1"
/>
</div>
</div>
<div className="space-y-2">
<Label>Warning Color</Label>
<div className="flex gap-2">
<Input
type="color"
value={theme.warningColor}
onChange={(e) => updateTheme({ warningColor: e.target.value })}
className="w-20 h-10 cursor-pointer"
/>
<Input
value={theme.warningColor}
onChange={(e) => updateTheme({ warningColor: e.target.value })}
className="flex-1"
/>
</div>
</div>
<div className="space-y-2">
<Label>Success Color</Label>
<div className="flex gap-2">
<Input
type="color"
value={theme.successColor}
onChange={(e) => updateTheme({ successColor: e.target.value })}
className="w-20 h-10 cursor-pointer"
/>
<Input
value={theme.successColor}
onChange={(e) => updateTheme({ successColor: e.target.value })}
className="flex-1"
/>
</div>
</div>
</div>
</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 bg-gradient-to-br from-card to-muted">
<h3 className="text-lg font-semibold mb-4">Preview</h3>
<div className="space-y-4">
<div className="flex gap-2 flex-wrap">
<div
className="w-20 h-20 rounded flex items-center justify-center text-white font-semibold"
style={{
backgroundColor: theme.primaryColor,
borderRadius: `${theme.borderRadius}px`,
}}
>
Primary
</div>
<div
className="w-20 h-20 rounded flex items-center justify-center text-white font-semibold"
style={{
backgroundColor: theme.secondaryColor,
borderRadius: `${theme.borderRadius}px`,
}}
>
Secondary
</div>
<div
className="w-20 h-20 rounded flex items-center justify-center text-white font-semibold"
style={{
backgroundColor: theme.errorColor,
borderRadius: `${theme.borderRadius}px`,
}}
>
Error
</div>
<div
className="w-20 h-20 rounded flex items-center justify-center text-white font-semibold"
style={{
backgroundColor: theme.warningColor,
borderRadius: `${theme.borderRadius}px`,
}}
>
Warning
</div>
<div
className="w-20 h-20 rounded flex items-center justify-center text-white font-semibold"
style={{
backgroundColor: theme.successColor,
borderRadius: `${theme.borderRadius}px`,
}}
>
Success
</div>
</div>
<div
className="p-4 border"
style={{
fontFamily: theme.fontFamily,
borderRadius: `${theme.borderRadius}px`,
}}
>
<p style={{ fontSize: `${theme.fontSize.large}px` }}>
Large Text Sample
</p>
<p style={{ fontSize: `${theme.fontSize.medium}px` }}>
Medium Text Sample
</p>
<p style={{ fontSize: `${theme.fontSize.small}px` }}>
Small Text Sample
</p>
</div>
</div>
</Card>
</div>
</div>
)
}

View File

@@ -1 +1,81 @@
/* This is where custom CSS goes */
@import 'tailwindcss';
@import "tw-animate-css";
@layer base {
* {
@apply border-border
}
body {
font-family: 'Inter', sans-serif;
}
h1, h2, h3 {
font-family: 'Space Grotesk', sans-serif;
}
code, pre {
font-family: 'JetBrains Mono', monospace;
}
}
:root {
--background: oklch(0.14 0.02 250);
--foreground: oklch(0.93 0.005 250);
--card: oklch(0.18 0.02 250);
--card-foreground: oklch(0.93 0.005 250);
--popover: oklch(0.18 0.02 250);
--popover-foreground: oklch(0.93 0.005 250);
--primary: oklch(0.45 0.15 270);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.35 0.02 250);
--secondary-foreground: oklch(0.93 0.005 250);
--muted: oklch(0.22 0.02 250);
--muted-foreground: oklch(0.65 0.01 250);
--accent: oklch(0.70 0.15 200);
--accent-foreground: oklch(0.14 0.02 250);
--destructive: oklch(0.55 0.22 25);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.28 0.02 250);
--input: oklch(0.28 0.02 250);
--ring: oklch(0.70 0.15 200);
--radius: 0.5rem;
}
@theme {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) * 0.5);
--radius-md: var(--radius);
--radius-lg: calc(var(--radius) * 1.5);
--radius-xl: calc(var(--radius) * 2);
--radius-2xl: calc(var(--radius) * 3);
--radius-full: 9999px;
}

174
src/lib/generators.ts Normal file
View File

@@ -0,0 +1,174 @@
import { PrismaModel, ComponentNode, ThemeConfig } from '@/types/project'
export function generatePrismaSchema(models: PrismaModel[]): string {
let schema = `generator client {\n provider = "prisma-client-js"\n}\n\n`
schema += `datasource db {\n provider = "postgresql"\n url = env("DATABASE_URL")\n}\n\n`
models.forEach((model) => {
schema += `model ${model.name} {\n`
model.fields.forEach((field) => {
let fieldLine = ` ${field.name} ${field.type}`
if (field.isArray) fieldLine += '[]'
if (field.isRequired && !field.defaultValue) fieldLine += ''
else if (!field.isRequired) fieldLine += '?'
if (field.isUnique) fieldLine += ' @unique'
if (field.defaultValue) fieldLine += ` @default(${field.defaultValue})`
schema += fieldLine + '\n'
})
schema += `}\n\n`
})
return schema
}
export function generateComponentCode(node: ComponentNode, indent: number = 0): string {
const spaces = ' '.repeat(indent)
const propsStr = Object.entries(node.props)
.map(([key, value]) => {
if (typeof value === 'string') return `${key}="${value}"`
if (typeof value === 'boolean') return value ? key : ''
return `${key}={${JSON.stringify(value)}}`
})
.filter(Boolean)
.join(' ')
if (node.children.length === 0) {
return `${spaces}<${node.type}${propsStr ? ' ' + propsStr : ''} />`
}
let code = `${spaces}<${node.type}${propsStr ? ' ' + propsStr : ''}>\n`
node.children.forEach((child) => {
code += generateComponentCode(child, indent + 1) + '\n'
})
code += `${spaces}</${node.type}>`
return code
}
export function generateMUITheme(theme: ThemeConfig): string {
return `import { createTheme } from '@mui/material/styles';
export const theme = createTheme({
palette: {
primary: {
main: '${theme.primaryColor}',
},
secondary: {
main: '${theme.secondaryColor}',
},
error: {
main: '${theme.errorColor}',
},
warning: {
main: '${theme.warningColor}',
},
success: {
main: '${theme.successColor}',
},
},
typography: {
fontFamily: '${theme.fontFamily}',
fontSize: ${theme.fontSize.medium},
},
spacing: ${theme.spacing},
shape: {
borderRadius: ${theme.borderRadius},
},
});`
}
export function generateNextJSProject(
projectName: string,
models: PrismaModel[],
components: ComponentNode[],
theme: ThemeConfig
): Record<string, string> {
const files: Record<string, string> = {}
files['package.json'] = JSON.stringify(
{
name: projectName,
version: '0.1.0',
private: true,
scripts: {
dev: 'next dev',
build: 'next build',
start: 'next start',
lint: 'next lint',
},
dependencies: {
'@mui/material': '^5.15.0',
'@emotion/react': '^11.11.0',
'@emotion/styled': '^11.11.0',
'@prisma/client': '^5.8.0',
next: '14.1.0',
react: '^18.2.0',
'react-dom': '^18.2.0',
},
devDependencies: {
'@types/node': '^20',
'@types/react': '^18',
'@types/react-dom': '^18',
prisma: '^5.8.0',
typescript: '^5',
},
},
null,
2
)
files['prisma/schema.prisma'] = generatePrismaSchema(models)
files['src/theme.ts'] = generateMUITheme(theme)
files['src/app/page.tsx'] = `'use client'
import { ThemeProvider } from '@mui/material/styles'
import CssBaseline from '@mui/material/CssBaseline'
import { theme } from '@/theme'
export default function Home() {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<main>
{/* Your components here */}
</main>
</ThemeProvider>
)
}`
files['next.config.js'] = `/** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = nextConfig`
files['.env'] = `DATABASE_URL="postgresql://user:password@localhost:5432/mydb"`
files['README.md'] = `# ${projectName}
Generated with CodeForge
## Getting Started
1. Install dependencies:
\`\`\`bash
npm install
\`\`\`
2. Set up your database in .env
3. Run Prisma migrations:
\`\`\`bash
npx prisma migrate dev
\`\`\`
4. Start the development server:
\`\`\`bash
npm run dev
\`\`\`
Open [http://localhost:3000](http://localhost:3000) with your browser.`
return files
}

View File

@@ -4,6 +4,7 @@ import "@github/spark/spark"
import App from './App.tsx'
import { ErrorFallback } from './ErrorFallback.tsx'
import { Toaster } from './components/ui/sonner.tsx'
import "./main.css"
import "./styles/theme.css"
@@ -12,5 +13,6 @@ import "./index.css"
createRoot(document.getElementById('root')!).render(
<ErrorBoundary FallbackComponent={ErrorFallback}>
<App />
<Toaster />
</ErrorBoundary>
)

56
src/types/project.ts Normal file
View File

@@ -0,0 +1,56 @@
export interface ProjectFile {
id: string
name: string
path: string
content: string
language: string
}
export interface PrismaModel {
id: string
name: string
fields: PrismaField[]
}
export interface PrismaField {
id: string
name: string
type: string
isRequired: boolean
isUnique: boolean
isArray: boolean
defaultValue?: string
relation?: string
}
export interface ComponentNode {
id: string
type: string
props: Record<string, any>
children: ComponentNode[]
name: string
}
export interface ThemeConfig {
primaryColor: string
secondaryColor: string
errorColor: string
warningColor: string
successColor: string
fontFamily: string
fontSize: {
small: number
medium: number
large: number
}
spacing: number
borderRadius: number
}
export interface Project {
name: string
files: ProjectFile[]
models: PrismaModel[]
components: ComponentNode[]
theme: ThemeConfig
}