mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-24 21:54:56 +00:00
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:
248
src/App.tsx
248
src/App.tsx
@@ -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
|
||||
85
src/components/CodeEditor.tsx
Normal file
85
src/components/CodeEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
268
src/components/ComponentTreeBuilder.tsx
Normal file
268
src/components/ComponentTreeBuilder.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
147
src/components/FileExplorer.tsx
Normal file
147
src/components/FileExplorer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
269
src/components/ModelDesigner.tsx
Normal file
269
src/components/ModelDesigner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
315
src/components/StyleDesigner.tsx
Normal file
315
src/components/StyleDesigner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
174
src/lib/generators.ts
Normal 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
|
||||
}
|
||||
@@ -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
56
src/types/project.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user