mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-26 14:44:55 +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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user