mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-25 14:14:57 +00:00
Merge pull request #58 from johndoe6345789/codex/refactor-jsondemopage-to-improve-structure
Refactor JSON demo and showcase into configs and smaller components
This commit is contained in:
@@ -1,16 +1,11 @@
|
||||
import { JSONUIRenderer } from '@/lib/json-ui'
|
||||
import { UIComponent } from '@/lib/json-ui/schema'
|
||||
import { toast } from 'sonner'
|
||||
import { useKV } from '@/hooks/use-kv'
|
||||
import { useState } from 'react'
|
||||
import { buildDemoPageSchema, demoCopy, demoInitialTodos } from '@/components/json-demo/schema'
|
||||
|
||||
export function JSONDemoPage() {
|
||||
const [todos, setTodos] = useKV('json-demo-todos', [
|
||||
{ id: 1, text: 'Design JSON schema', completed: true },
|
||||
{ id: 2, text: 'Build atomic components', completed: false },
|
||||
{ id: 3, text: 'Create custom hooks', completed: false },
|
||||
])
|
||||
|
||||
const [todos, setTodos] = useKV('json-demo-todos', demoInitialTodos)
|
||||
const [newTodo, setNewTodo] = useState('')
|
||||
|
||||
const handleAction = (handler: any, event?: any) => {
|
||||
@@ -19,13 +14,13 @@ export function JSONDemoPage() {
|
||||
if (newTodo.trim()) {
|
||||
setTodos((current: any) => [
|
||||
...current,
|
||||
{ id: Date.now(), text: newTodo, completed: false }
|
||||
{ id: Date.now(), text: newTodo, completed: false },
|
||||
])
|
||||
setNewTodo('')
|
||||
toast.success('Todo added!')
|
||||
toast.success(demoCopy.toastAdded)
|
||||
}
|
||||
break
|
||||
|
||||
|
||||
case 'toggle-todo':
|
||||
setTodos((current: any) =>
|
||||
current.map((todo: any) =>
|
||||
@@ -35,152 +30,21 @@ export function JSONDemoPage() {
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
|
||||
case 'delete-todo':
|
||||
setTodos((current: any) =>
|
||||
current.filter((todo: any) => todo.id !== handler.params?.id)
|
||||
)
|
||||
toast.success('Todo deleted')
|
||||
toast.success(demoCopy.toastDeleted)
|
||||
break
|
||||
|
||||
|
||||
case 'update-input':
|
||||
setNewTodo(event.target.value)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const pageSchema: UIComponent = {
|
||||
id: 'json-demo-page',
|
||||
type: 'div',
|
||||
className: 'h-full overflow-auto p-6',
|
||||
children: [
|
||||
{
|
||||
id: 'header',
|
||||
type: 'div',
|
||||
className: 'mb-6',
|
||||
children: [
|
||||
{
|
||||
id: 'title',
|
||||
type: 'h1',
|
||||
className: 'text-3xl font-bold mb-2',
|
||||
children: 'JSON-Driven UI Demo'
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
type: 'p',
|
||||
className: 'text-muted-foreground',
|
||||
children: 'This entire page is built from a JSON schema - no JSX required!'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'main-card',
|
||||
type: 'Card',
|
||||
className: 'max-w-2xl',
|
||||
children: [
|
||||
{
|
||||
id: 'card-header',
|
||||
type: 'CardHeader',
|
||||
children: [
|
||||
{
|
||||
id: 'card-title',
|
||||
type: 'CardTitle',
|
||||
children: 'Todo List'
|
||||
},
|
||||
{
|
||||
id: 'card-description',
|
||||
type: 'CardDescription',
|
||||
children: 'Manage your tasks with JSON-powered UI'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'card-content',
|
||||
type: 'CardContent',
|
||||
className: 'space-y-4',
|
||||
children: [
|
||||
{
|
||||
id: 'input-group',
|
||||
type: 'div',
|
||||
className: 'flex gap-2',
|
||||
children: [
|
||||
{
|
||||
id: 'todo-input',
|
||||
type: 'Input',
|
||||
props: {
|
||||
placeholder: 'Enter a new todo...',
|
||||
value: newTodo
|
||||
},
|
||||
events: {
|
||||
onChange: { action: 'update-input' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'add-button',
|
||||
type: 'Button',
|
||||
props: {
|
||||
children: 'Add'
|
||||
},
|
||||
events: {
|
||||
onClick: { action: 'add-todo' }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'todo-list',
|
||||
type: 'div',
|
||||
className: 'space-y-2',
|
||||
children: todos.map((todo: any) => ({
|
||||
id: `todo-${todo.id}`,
|
||||
type: 'div',
|
||||
className: 'flex items-center gap-2 p-3 rounded-lg border bg-card',
|
||||
children: [
|
||||
{
|
||||
id: `checkbox-${todo.id}`,
|
||||
type: 'Checkbox',
|
||||
props: {
|
||||
checked: todo.completed
|
||||
},
|
||||
events: {
|
||||
onCheckedChange: {
|
||||
action: 'toggle-todo',
|
||||
params: { id: todo.id }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: `text-${todo.id}`,
|
||||
type: 'span',
|
||||
className: todo.completed
|
||||
? 'flex-1 line-through text-muted-foreground'
|
||||
: 'flex-1',
|
||||
children: todo.text
|
||||
},
|
||||
{
|
||||
id: `delete-${todo.id}`,
|
||||
type: 'Button',
|
||||
props: {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
children: '×'
|
||||
},
|
||||
events: {
|
||||
onClick: {
|
||||
action: 'delete-todo',
|
||||
params: { id: todo.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}))
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
const pageSchema = buildDemoPageSchema(todos, newTodo)
|
||||
|
||||
return (
|
||||
<JSONUIRenderer
|
||||
|
||||
@@ -1,214 +1,32 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { StatCard } from '@/components/atoms'
|
||||
import * as Icons from '@phosphor-icons/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import pageRendererCopy from '@/config/json-page-renderer.json'
|
||||
import { PageSectionRenderer } from '@/components/json-page-renderer/SectionRenderer'
|
||||
import { ComponentRendererProps } from '@/components/json-page-renderer/types'
|
||||
|
||||
export interface PageComponentConfig {
|
||||
id: string
|
||||
type: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface PageLayoutConfig {
|
||||
type: string
|
||||
spacing?: string
|
||||
sections?: PageSectionConfig[]
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface PageSectionConfig {
|
||||
type: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface LegacyPageSchema {
|
||||
id: string
|
||||
layout: PageLayoutConfig
|
||||
dashboardCards?: any[]
|
||||
statCards?: any[]
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface ComponentRendererProps {
|
||||
config?: Record<string, any>
|
||||
schema?: LegacyPageSchema
|
||||
data?: Record<string, any>
|
||||
functions?: Record<string, (...args: any[]) => any>
|
||||
}
|
||||
|
||||
function resolveBinding(binding: string, data: Record<string, any>): any {
|
||||
try {
|
||||
const func = new Function(...Object.keys(data), `return ${binding}`)
|
||||
return func(...Object.values(data))
|
||||
} catch {
|
||||
return binding
|
||||
}
|
||||
}
|
||||
|
||||
function getIcon(iconName: string, props?: any) {
|
||||
const IconComponent = (Icons as any)[iconName]
|
||||
if (!IconComponent) return null
|
||||
return <IconComponent size={24} weight="duotone" {...props} />
|
||||
}
|
||||
|
||||
export function JSONPageRenderer({ config, schema, data = {}, functions = {} }: ComponentRendererProps) {
|
||||
export function JSONPageRenderer({
|
||||
config,
|
||||
schema,
|
||||
data = {},
|
||||
functions = {},
|
||||
}: ComponentRendererProps) {
|
||||
const pageSchema = config || schema
|
||||
if (!pageSchema) {
|
||||
return <div>No schema provided</div>
|
||||
}
|
||||
|
||||
const renderSection = (section: PageSectionConfig, index: number): ReactNode => {
|
||||
switch (section.type) {
|
||||
case 'header':
|
||||
return (
|
||||
<div key={index}>
|
||||
<h1 className="text-3xl font-bold mb-2">{section.title}</h1>
|
||||
{section.description && (
|
||||
<p className="text-muted-foreground">{section.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'cards': {
|
||||
const cards = pageSchema[section.items as string] || []
|
||||
return (
|
||||
<div key={index} className={cn('space-y-' + (section.spacing || '4'))}>
|
||||
{cards.map((card: any) => renderCard(card))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'grid': {
|
||||
const gridItems = pageSchema[section.items as string] || []
|
||||
const { sm = 1, md = 2, lg = 3 } = section.columns || {}
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'grid gap-' + (section.gap || '4'),
|
||||
`grid-cols-${sm} md:grid-cols-${md} lg:grid-cols-${lg}`
|
||||
)}
|
||||
>
|
||||
{gridItems.map((item: any) => renderStatCard(item))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const renderCard = (card: any): ReactNode => {
|
||||
const icon = card.icon ? getIcon(card.icon) : null
|
||||
|
||||
if (card.type === 'gradient-card') {
|
||||
const computeFn = functions[card.dataSource?.compute]
|
||||
const computedData = computeFn ? computeFn(data) : {}
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={card.id}
|
||||
className={cn(
|
||||
'bg-gradient-to-br border-primary/20',
|
||||
card.gradient
|
||||
)}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{icon && <span className="text-primary">{icon}</span>}
|
||||
{card.title}
|
||||
</CardTitle>
|
||||
<CardDescription>{card.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{card.components?.map((comp: any, idx: number) =>
|
||||
renderSubComponent(comp, computedData, idx)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card key={card.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>{card.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{card.component && renderCustomComponent(card.component, card.props || {})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const renderSubComponent = (comp: any, dataContext: any, key: number): ReactNode => {
|
||||
const value = dataContext[comp.binding]
|
||||
|
||||
switch (comp.type) {
|
||||
case 'metric':
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between">
|
||||
<span className={cn(
|
||||
'font-bold',
|
||||
comp.size === 'large' ? 'text-4xl' : 'text-2xl'
|
||||
)}>
|
||||
{comp.format === 'percentage' ? `${value}%` : value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'badge': {
|
||||
const variant = value === 'ready' ? comp.variants?.ready : comp.variants?.inProgress
|
||||
return (
|
||||
<Badge key={key} variant={variant?.variant as any}>
|
||||
{variant?.label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
case 'progress':
|
||||
return <Progress key={key} value={value} className="h-3" />
|
||||
|
||||
case 'text':
|
||||
return (
|
||||
<p key={key} className={comp.className}>
|
||||
{value}
|
||||
</p>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const renderStatCard = (stat: any): ReactNode => {
|
||||
const icon = stat.icon ? getIcon(stat.icon) : undefined
|
||||
const value = resolveBinding(stat.dataBinding, data)
|
||||
const description = `${value} ${stat.description}`
|
||||
|
||||
return (
|
||||
<StatCard
|
||||
key={stat.id}
|
||||
icon={icon}
|
||||
title={stat.title}
|
||||
value={value}
|
||||
description={description}
|
||||
color={stat.color}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const renderCustomComponent = (componentName: string, props: any): ReactNode => {
|
||||
return <div>Custom component: {componentName}</div>
|
||||
return <div>{pageRendererCopy.fallbackText}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6 space-y-6">
|
||||
{pageSchema.layout.sections?.map((section, index) => renderSection(section, index))}
|
||||
{pageSchema.layout.sections?.map((section, index) => (
|
||||
<PageSectionRenderer
|
||||
key={index}
|
||||
index={index}
|
||||
section={section}
|
||||
pageSchema={pageSchema}
|
||||
data={data}
|
||||
functions={functions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type { ComponentRendererProps } from '@/components/json-page-renderer/types'
|
||||
|
||||
@@ -1,138 +1,64 @@
|
||||
import { useState } from 'react'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { JSONUIPage } from '@/components/JSONUIPage'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useMemo, useState } from 'react'
|
||||
import showcaseCopy from '@/config/ui-examples/showcase.json'
|
||||
import dashboardExample from '@/config/ui-examples/dashboard.json'
|
||||
import formExample from '@/config/ui-examples/form.json'
|
||||
import tableExample from '@/config/ui-examples/table.json'
|
||||
import settingsExample from '@/config/ui-examples/settings.json'
|
||||
import { FileCode, Eye, Code, ChartBar, ListBullets, Table, Gear } from '@phosphor-icons/react'
|
||||
import { FileCode, ChartBar, ListBullets, Table, Gear } from '@phosphor-icons/react'
|
||||
import { ShowcaseHeader } from '@/components/json-ui-showcase/ShowcaseHeader'
|
||||
import { ShowcaseTabs } from '@/components/json-ui-showcase/ShowcaseTabs'
|
||||
import { ShowcaseFooter } from '@/components/json-ui-showcase/ShowcaseFooter'
|
||||
import { ShowcaseExample } from '@/components/json-ui-showcase/types'
|
||||
|
||||
const exampleConfigs = {
|
||||
dashboard: dashboardExample,
|
||||
form: formExample,
|
||||
table: tableExample,
|
||||
settings: settingsExample,
|
||||
}
|
||||
|
||||
const exampleIcons = {
|
||||
ChartBar,
|
||||
ListBullets,
|
||||
Table,
|
||||
Gear,
|
||||
}
|
||||
|
||||
export function JSONUIShowcase() {
|
||||
const [selectedExample, setSelectedExample] = useState('dashboard')
|
||||
const [selectedExample, setSelectedExample] = useState(showcaseCopy.defaultExampleKey)
|
||||
const [showJSON, setShowJSON] = useState(false)
|
||||
|
||||
const examples = {
|
||||
dashboard: {
|
||||
name: 'Dashboard',
|
||||
description: 'Complete dashboard with stats, activity feed, and quick actions',
|
||||
icon: ChartBar,
|
||||
config: dashboardExample,
|
||||
},
|
||||
form: {
|
||||
name: 'Form',
|
||||
description: 'Dynamic form with validation and data binding',
|
||||
icon: ListBullets,
|
||||
config: formExample,
|
||||
},
|
||||
table: {
|
||||
name: 'Data Table',
|
||||
description: 'Interactive table with row actions and looping',
|
||||
icon: Table,
|
||||
config: tableExample,
|
||||
},
|
||||
settings: {
|
||||
name: 'Settings',
|
||||
description: 'Tabbed settings panel with switches and selections',
|
||||
icon: Gear,
|
||||
config: settingsExample,
|
||||
},
|
||||
}
|
||||
const examples = useMemo<ShowcaseExample[]>(() => {
|
||||
return showcaseCopy.examples.map((example) => {
|
||||
const icon = exampleIcons[example.icon as keyof typeof exampleIcons] || FileCode
|
||||
const config = exampleConfigs[example.configKey as keyof typeof exampleConfigs]
|
||||
|
||||
const currentExample = examples[selectedExample as keyof typeof examples]
|
||||
return {
|
||||
key: example.key,
|
||||
name: example.name,
|
||||
description: example.description,
|
||||
icon,
|
||||
config,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background">
|
||||
<div className="border-b border-border bg-card px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">JSON UI System</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Build complex UIs from declarative JSON configurations
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="font-mono">
|
||||
EXPERIMENTAL
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<ShowcaseHeader copy={showcaseCopy.header} />
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Tabs value={selectedExample} onValueChange={setSelectedExample} className="h-full flex flex-col">
|
||||
<div className="border-b border-border bg-muted/30 px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList className="bg-transparent border-0">
|
||||
{Object.entries(examples).map(([key, example]) => {
|
||||
const Icon = example.icon
|
||||
return (
|
||||
<TabsTrigger key={key} value={key} className="gap-2">
|
||||
<Icon size={16} />
|
||||
{example.name}
|
||||
</TabsTrigger>
|
||||
)
|
||||
})}
|
||||
</TabsList>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowJSON(!showJSON)}
|
||||
className="gap-2"
|
||||
>
|
||||
{showJSON ? <Eye size={16} /> : <Code size={16} />}
|
||||
{showJSON ? 'Show Preview' : 'Show JSON'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{Object.entries(examples).map(([key, example]) => (
|
||||
<TabsContent key={key} value={key} className="h-full m-0">
|
||||
{showJSON ? (
|
||||
<div className="p-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">JSON Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
{example.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted p-4 rounded-lg overflow-auto text-sm max-h-[600px]">
|
||||
<code>{JSON.stringify(example.config, null, 2)}</code>
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<JSONUIPage jsonConfig={example.config} />
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</div>
|
||||
</Tabs>
|
||||
<ShowcaseTabs
|
||||
examples={examples}
|
||||
copy={showcaseCopy.tabs}
|
||||
selectedExample={selectedExample}
|
||||
onSelectedExampleChange={setSelectedExample}
|
||||
showJSON={showJSON}
|
||||
onShowJSONChange={setShowJSON}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border bg-card px-6 py-3">
|
||||
<div className="flex items-center gap-6 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span>Fully declarative - no React code needed</span>
|
||||
</div>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<span>Data binding with automatic updates</span>
|
||||
</div>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-purple-500" />
|
||||
<span>Event handlers and actions</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ShowcaseFooter items={showcaseCopy.footer.items} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
101
src/components/json-demo/schema.ts
Normal file
101
src/components/json-demo/schema.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import demoConfig from '@/config/json-demo.json'
|
||||
import { UIComponent } from '@/lib/json-ui/schema'
|
||||
|
||||
type TodoItem = {
|
||||
id: number
|
||||
text: string
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
type DemoCopy = {
|
||||
toastAdded: string
|
||||
toastDeleted: string
|
||||
deleteButtonLabel: string
|
||||
}
|
||||
|
||||
const baseSchema = demoConfig.schema as UIComponent
|
||||
|
||||
const cloneSchema = () => JSON.parse(JSON.stringify(baseSchema)) as UIComponent
|
||||
|
||||
const findComponentById = (component: UIComponent, id: string): UIComponent | null => {
|
||||
if (component.id === id) {
|
||||
return component
|
||||
}
|
||||
|
||||
if (!Array.isArray(component.children)) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const child of component.children) {
|
||||
if (typeof child === 'object' && child && 'id' in child) {
|
||||
const match = findComponentById(child as UIComponent, id)
|
||||
if (match) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const buildTodoItem = (todo: TodoItem, copy: DemoCopy): UIComponent => ({
|
||||
id: `todo-${todo.id}`,
|
||||
type: 'div',
|
||||
className: 'flex items-center gap-2 p-3 rounded-lg border bg-card',
|
||||
children: [
|
||||
{
|
||||
id: `checkbox-${todo.id}`,
|
||||
type: 'Checkbox',
|
||||
props: {
|
||||
checked: todo.completed,
|
||||
},
|
||||
events: {
|
||||
onCheckedChange: {
|
||||
action: 'toggle-todo',
|
||||
params: { id: todo.id },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: `text-${todo.id}`,
|
||||
type: 'span',
|
||||
className: todo.completed
|
||||
? 'flex-1 line-through text-muted-foreground'
|
||||
: 'flex-1',
|
||||
children: todo.text,
|
||||
},
|
||||
{
|
||||
id: `delete-${todo.id}`,
|
||||
type: 'Button',
|
||||
props: {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
children: copy.deleteButtonLabel,
|
||||
},
|
||||
events: {
|
||||
onClick: {
|
||||
action: 'delete-todo',
|
||||
params: { id: todo.id },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export const demoCopy = demoConfig.copy as DemoCopy
|
||||
export const demoInitialTodos = demoConfig.initialTodos as TodoItem[]
|
||||
|
||||
export const buildDemoPageSchema = (todos: TodoItem[], newTodo: string): UIComponent => {
|
||||
const schema = cloneSchema()
|
||||
const inputComponent = findComponentById(schema, 'todo-input')
|
||||
if (inputComponent?.props) {
|
||||
inputComponent.props.value = newTodo
|
||||
}
|
||||
|
||||
const todoList = findComponentById(schema, 'todo-list')
|
||||
if (todoList) {
|
||||
todoList.children = todos.map((todo) => buildTodoItem(todo, demoCopy))
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
250
src/components/json-page-renderer/SectionRenderer.tsx
Normal file
250
src/components/json-page-renderer/SectionRenderer.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { StatCard } from '@/components/atoms'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getIcon, resolveBinding } from './utils'
|
||||
import { LegacyPageSchema, PageSectionConfig } from './types'
|
||||
|
||||
interface PageSectionRendererProps {
|
||||
index: number
|
||||
section: PageSectionConfig
|
||||
pageSchema: LegacyPageSchema
|
||||
data: Record<string, any>
|
||||
functions: Record<string, (...args: any[]) => any>
|
||||
}
|
||||
|
||||
export function PageSectionRenderer({
|
||||
index,
|
||||
section,
|
||||
pageSchema,
|
||||
data,
|
||||
functions,
|
||||
}: PageSectionRendererProps): ReactNode {
|
||||
switch (section.type) {
|
||||
case 'header':
|
||||
return (
|
||||
<HeaderSection
|
||||
key={index}
|
||||
title={section.title}
|
||||
description={section.description}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'cards':
|
||||
return (
|
||||
<CardSection
|
||||
key={index}
|
||||
cards={pageSchema[section.items as string] || []}
|
||||
spacing={section.spacing}
|
||||
data={data}
|
||||
functions={functions}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'grid':
|
||||
return (
|
||||
<GridSection
|
||||
key={index}
|
||||
items={pageSchema[section.items as string] || []}
|
||||
columns={section.columns}
|
||||
gap={section.gap}
|
||||
data={data}
|
||||
/>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface HeaderSectionProps {
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
function HeaderSection({ title, description }: HeaderSectionProps) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">{title}</h1>
|
||||
{description && (
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CardSectionProps {
|
||||
cards: any[]
|
||||
spacing?: string
|
||||
data: Record<string, any>
|
||||
functions: Record<string, (...args: any[]) => any>
|
||||
}
|
||||
|
||||
function CardSection({ cards, spacing, data, functions }: CardSectionProps) {
|
||||
return (
|
||||
<div className={cn('space-y-' + (spacing || '4'))}>
|
||||
{cards.map((card) => (
|
||||
<PageCard
|
||||
key={card.id}
|
||||
card={card}
|
||||
data={data}
|
||||
functions={functions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PageCardProps {
|
||||
card: any
|
||||
data: Record<string, any>
|
||||
functions: Record<string, (...args: any[]) => any>
|
||||
}
|
||||
|
||||
function PageCard({ card, data, functions }: PageCardProps) {
|
||||
const icon = card.icon ? getIcon(card.icon) : null
|
||||
|
||||
if (card.type === 'gradient-card') {
|
||||
const computeFn = functions[card.dataSource?.compute]
|
||||
const computedData = computeFn ? computeFn(data) : {}
|
||||
|
||||
return (
|
||||
<Card className={cn('bg-gradient-to-br border-primary/20', card.gradient)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{icon && <span className="text-primary">{icon}</span>}
|
||||
{card.title}
|
||||
</CardTitle>
|
||||
<CardDescription>{card.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{card.components?.map((comp: any, idx: number) => (
|
||||
<CardSubComponent
|
||||
key={`${card.id}-${idx}`}
|
||||
component={comp}
|
||||
dataContext={computedData}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{card.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CustomComponentPlaceholder
|
||||
componentName={card.component}
|
||||
props={card.props || {}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
interface CardSubComponentProps {
|
||||
component: any
|
||||
dataContext: Record<string, any>
|
||||
}
|
||||
|
||||
function CardSubComponent({ component, dataContext }: CardSubComponentProps) {
|
||||
const value = dataContext[component.binding]
|
||||
|
||||
switch (component.type) {
|
||||
case 'metric':
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={cn(
|
||||
'font-bold',
|
||||
component.size === 'large' ? 'text-4xl' : 'text-2xl'
|
||||
)}
|
||||
>
|
||||
{component.format === 'percentage' ? `${value}%` : value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'badge': {
|
||||
const variant =
|
||||
value === 'ready' ? component.variants?.ready : component.variants?.inProgress
|
||||
return (
|
||||
<Badge variant={variant?.variant as any}>
|
||||
{variant?.label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
case 'progress':
|
||||
return <Progress value={value} className="h-3" />
|
||||
|
||||
case 'text':
|
||||
return <p className={component.className}>{value}</p>
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface GridSectionProps {
|
||||
items: any[]
|
||||
columns?: {
|
||||
sm?: number
|
||||
md?: number
|
||||
lg?: number
|
||||
}
|
||||
gap?: string
|
||||
data: Record<string, any>
|
||||
}
|
||||
|
||||
function GridSection({ items, columns, gap, data }: GridSectionProps) {
|
||||
const { sm = 1, md = 2, lg = 3 } = columns || {}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-' + (gap || '4'),
|
||||
`grid-cols-${sm} md:grid-cols-${md} lg:grid-cols-${lg}`
|
||||
)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<StatCardRenderer key={item.id} stat={item} data={data} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface StatCardRendererProps {
|
||||
stat: any
|
||||
data: Record<string, any>
|
||||
}
|
||||
|
||||
function StatCardRenderer({ stat, data }: StatCardRendererProps) {
|
||||
const icon = stat.icon ? getIcon(stat.icon) : undefined
|
||||
const value = resolveBinding(stat.dataBinding, data)
|
||||
const description = `${value} ${stat.description}`
|
||||
|
||||
return (
|
||||
<StatCard
|
||||
icon={icon}
|
||||
title={stat.title}
|
||||
value={value}
|
||||
description={description}
|
||||
color={stat.color}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface CustomComponentPlaceholderProps {
|
||||
componentName?: string
|
||||
props: Record<string, any>
|
||||
}
|
||||
|
||||
function CustomComponentPlaceholder({ componentName }: CustomComponentPlaceholderProps) {
|
||||
return <div>Custom component: {componentName}</div>
|
||||
}
|
||||
32
src/components/json-page-renderer/types.ts
Normal file
32
src/components/json-page-renderer/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface PageComponentConfig {
|
||||
id: string
|
||||
type: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface PageLayoutConfig {
|
||||
type: string
|
||||
spacing?: string
|
||||
sections?: PageSectionConfig[]
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface PageSectionConfig {
|
||||
type: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface LegacyPageSchema {
|
||||
id: string
|
||||
layout: PageLayoutConfig
|
||||
dashboardCards?: any[]
|
||||
statCards?: any[]
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface ComponentRendererProps {
|
||||
config?: Record<string, any>
|
||||
schema?: LegacyPageSchema
|
||||
data?: Record<string, any>
|
||||
functions?: Record<string, (...args: any[]) => any>
|
||||
}
|
||||
16
src/components/json-page-renderer/utils.tsx
Normal file
16
src/components/json-page-renderer/utils.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import * as Icons from '@phosphor-icons/react'
|
||||
|
||||
export function resolveBinding(binding: string, data: Record<string, any>): any {
|
||||
try {
|
||||
const func = new Function(...Object.keys(data), `return ${binding}`)
|
||||
return func(...Object.values(data))
|
||||
} catch {
|
||||
return binding
|
||||
}
|
||||
}
|
||||
|
||||
export function getIcon(iconName: string, props?: any) {
|
||||
const IconComponent = (Icons as any)[iconName]
|
||||
if (!IconComponent) return null
|
||||
return <IconComponent size={24} weight="duotone" {...props} />
|
||||
}
|
||||
27
src/components/json-ui-showcase/ShowcaseFooter.tsx
Normal file
27
src/components/json-ui-showcase/ShowcaseFooter.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Fragment } from 'react'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { ShowcaseFooterItem } from './types'
|
||||
|
||||
interface ShowcaseFooterProps {
|
||||
items: ShowcaseFooterItem[]
|
||||
}
|
||||
|
||||
export function ShowcaseFooter({ items }: ShowcaseFooterProps) {
|
||||
return (
|
||||
<div className="border-t border-border bg-card px-6 py-3">
|
||||
<div className="flex items-center gap-6 text-xs text-muted-foreground">
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={item.label}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${item.colorClass}`} />
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
{index < items.length - 1 && (
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
src/components/json-ui-showcase/ShowcaseHeader.tsx
Normal file
24
src/components/json-ui-showcase/ShowcaseHeader.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ShowcaseHeaderCopy } from './types'
|
||||
|
||||
interface ShowcaseHeaderProps {
|
||||
copy: ShowcaseHeaderCopy
|
||||
}
|
||||
|
||||
export function ShowcaseHeader({ copy }: ShowcaseHeaderProps) {
|
||||
return (
|
||||
<div className="border-b border-border bg-card px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{copy.title}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{copy.description}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="font-mono">
|
||||
{copy.badge}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
src/components/json-ui-showcase/ShowcaseTabs.tsx
Normal file
81
src/components/json-ui-showcase/ShowcaseTabs.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { JSONUIPage } from '@/components/JSONUIPage'
|
||||
import { Eye, Code } from '@phosphor-icons/react'
|
||||
import { ShowcaseExample, ShowcaseTabsCopy } from './types'
|
||||
|
||||
interface ShowcaseTabsProps {
|
||||
examples: ShowcaseExample[]
|
||||
copy: ShowcaseTabsCopy
|
||||
selectedExample: string
|
||||
onSelectedExampleChange: (value: string) => void
|
||||
showJSON: boolean
|
||||
onShowJSONChange: (value: boolean) => void
|
||||
}
|
||||
|
||||
export function ShowcaseTabs({
|
||||
examples,
|
||||
copy,
|
||||
selectedExample,
|
||||
onSelectedExampleChange,
|
||||
showJSON,
|
||||
onShowJSONChange,
|
||||
}: ShowcaseTabsProps) {
|
||||
return (
|
||||
<Tabs
|
||||
value={selectedExample}
|
||||
onValueChange={onSelectedExampleChange}
|
||||
className="h-full flex flex-col"
|
||||
>
|
||||
<div className="border-b border-border bg-muted/30 px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList className="bg-transparent border-0">
|
||||
{examples.map((example) => {
|
||||
const Icon = example.icon
|
||||
return (
|
||||
<TabsTrigger key={example.key} value={example.key} className="gap-2">
|
||||
<Icon size={16} />
|
||||
{example.name}
|
||||
</TabsTrigger>
|
||||
)
|
||||
})}
|
||||
</TabsList>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onShowJSONChange(!showJSON)}
|
||||
className="gap-2"
|
||||
>
|
||||
{showJSON ? <Eye size={16} /> : <Code size={16} />}
|
||||
{showJSON ? copy.showPreviewLabel : copy.showJsonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{examples.map((example) => (
|
||||
<TabsContent key={example.key} value={example.key} className="h-full m-0">
|
||||
{showJSON ? (
|
||||
<div className="p-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{copy.jsonTitle}</CardTitle>
|
||||
<CardDescription>{example.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted p-4 rounded-lg overflow-auto text-sm max-h-[600px]">
|
||||
<code>{JSON.stringify(example.config, null, 2)}</code>
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<JSONUIPage jsonConfig={example.config} />
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</div>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
26
src/components/json-ui-showcase/types.ts
Normal file
26
src/components/json-ui-showcase/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ComponentType } from 'react'
|
||||
|
||||
export interface ShowcaseExample {
|
||||
key: string
|
||||
name: string
|
||||
description: string
|
||||
icon: ComponentType<{ size?: number }>
|
||||
config: Record<string, any>
|
||||
}
|
||||
|
||||
export interface ShowcaseTabsCopy {
|
||||
showJsonLabel: string
|
||||
showPreviewLabel: string
|
||||
jsonTitle: string
|
||||
}
|
||||
|
||||
export interface ShowcaseHeaderCopy {
|
||||
title: string
|
||||
description: string
|
||||
badge: string
|
||||
}
|
||||
|
||||
export interface ShowcaseFooterItem {
|
||||
label: string
|
||||
colorClass: string
|
||||
}
|
||||
Reference in New Issue
Block a user