Generated by Spark: https://github.com/johndoe6345789/RevolutionaryWayToServeUpReactApps/tree/main --> I want it to be able to generate like a Django style React admin panel. This will mean being able to set up the data model and everything declaratively. Leverage json to generate boilerplate code and even store strings in json (multiline is just an array).

This commit is contained in:
2025-12-23 20:30:46 +00:00
parent a024526c87
commit 2a476fbd18
14 changed files with 2094 additions and 20 deletions

1
.spark-initial-sha Normal file
View File

@@ -0,0 +1 @@
a024526c87d7b9829fc5f702d15e3d3dec2b4557

119
PRD.md Normal file
View File

@@ -0,0 +1,119 @@
# Planning Guide
A declarative admin panel generator that allows developers to define data models and UI configuration through JSON schemas, automatically generating a complete CRUD interface similar to Django's admin panel.
**Experience Qualities**:
1. **Declarative** - Configuration-driven development where JSON schemas define the entire admin interface without manual component creation
2. **Intuitive** - Familiar Django-admin-style patterns that feel immediately recognizable to developers who've worked with backend admin panels
3. **Powerful** - Full-featured CRUD operations with filtering, sorting, validation, and relationships handled automatically from schema definitions
**Complexity Level**: Complex Application (advanced functionality, likely with multiple views)
This is a code generation and admin panel system that dynamically creates fully functional interfaces from JSON schemas, including list views, detail views, forms, validation, and data persistence - requiring sophisticated state management and dynamic rendering.
## Essential Features
### Schema Definition System
- **Functionality**: Parse JSON schema files that define data models with fields, types, validation rules, and UI hints
- **Purpose**: Enable developers to define entire admin panels declaratively without writing component code
- **Trigger**: JSON schema files loaded at application startup or hot-reloaded during development
- **Progression**: Load JSON schema → Parse model definitions → Validate schema structure → Generate TypeScript types → Initialize data stores → Render admin interface
- **Success criteria**: Valid JSON schemas produce working admin panels; invalid schemas show clear error messages with schema validation feedback
### Dynamic Model List View
- **Functionality**: Auto-generated table/list view showing all records for a model with sortable columns, filters, search, and pagination
- **Purpose**: Provide quick overview and management of all records in a data model
- **Trigger**: User selects a model from the navigation sidebar
- **Progression**: Select model → Load records from KV store → Render table with columns from schema → Apply filters/search/sort → Click row for detail view
- **Success criteria**: All defined fields display correctly; sorting, filtering, and search work; pagination handles large datasets; empty states guide users to create first record
### Dynamic Form Generation
- **Functionality**: Automatically generate create/edit forms based on field types and validation rules in the schema
- **Purpose**: Eliminate boilerplate form code while ensuring data integrity through schema-defined validation
- **Trigger**: User clicks "Create New" button or "Edit" on existing record
- **Progression**: Open form dialog → Render fields based on schema → User inputs data → Validate on blur/submit → Save to KV store → Update list view → Show success toast
- **Success criteria**: All field types render appropriate inputs; validation rules enforced; error messages clear; optimistic updates feel instant
### Relationship Management
- **Functionality**: Handle foreign keys and many-to-many relationships with select dropdowns and relationship tables
- **Purpose**: Support relational data models common in admin panels
- **Trigger**: Form fields defined with relationship types in schema
- **Progression**: Render relationship field → Load related model records → Display searchable select → Save relationship IDs → Update related records view
- **Success criteria**: Related records load and display; cascade operations work correctly; relationship changes persist
### Field Type System
- **Functionality**: Support common field types (text, number, boolean, date, email, URL, select, textarea, rich text, file reference, relationship)
- **Purpose**: Cover the majority of admin panel use cases with appropriate input controls
- **Trigger**: Field type specified in schema
- **Progression**: Parse field type → Select appropriate input component → Apply type-specific validation → Render with schema-defined options
- **Success criteria**: Each field type has appropriate UI control; validation matches type; special types (date, color) use enhanced inputs
## Edge Case Handling
- **Invalid Schema Structure**: Display detailed validation errors in a dev-friendly overlay with line numbers and suggestions
- **Missing Required Fields**: Prevent form submission and highlight missing fields with clear error messages
- **Circular Relationships**: Detect and warn about circular dependencies in schema definitions
- **Large Datasets**: Implement virtual scrolling and pagination for tables with 1000+ records
- **Concurrent Edits**: Show warnings when data has been modified since form opened
- **Migration Scenarios**: Handle schema changes gracefully with field mapping suggestions
## Design Direction
The design should evoke efficiency, clarity, and developer familiarity - similar to the no-nonsense utility of Django admin but with modern polish and visual refinement. Think "power tool" rather than "pretty toy" - dense information displays, keyboard shortcuts, and rapid workflows prioritized over decorative elements.
## Color Selection
A technical, code-editor-inspired palette with high contrast for data clarity and professional development tool aesthetic.
- **Primary Color**: Deep indigo `oklch(0.45 0.15 265)` - Communicates technical sophistication and authority, used for primary actions and active states
- **Secondary Colors**: Cool gray `oklch(0.65 0.02 250)` for secondary UI elements and subtle backgrounds; Slate `oklch(0.25 0.03 250)` for sidebar and chrome
- **Accent Color**: Bright cyan `oklch(0.75 0.15 195)` - High-visibility color for CTAs, links, and "create new" actions that demand attention
- **Foreground/Background Pairings**:
- Primary (Deep Indigo): White text `oklch(0.98 0 0)` - Ratio 7.8:1 ✓
- Background (Near White `oklch(0.98 0 0)`): Charcoal text `oklch(0.25 0 0)` - Ratio 14.2:1 ✓
- Accent (Bright Cyan): Dark slate text `oklch(0.2 0.05 250)` - Ratio 8.1:1 ✓
- Sidebar (Slate): Light gray text `oklch(0.85 0.01 250)` - Ratio 9.4:1 ✓
## Font Selection
Technical clarity with a code-adjacent aesthetic that reinforces the developer tool positioning while maintaining readability for dense data tables.
- **Typographic Hierarchy**:
- H1 (Panel Title): Space Grotesk Bold/32px/tight letter spacing
- H2 (Section Headers): Space Grotesk Semibold/24px/normal spacing
- H3 (Field Labels): Space Grotesk Medium/14px/uppercase/wide letter spacing
- Body (Table Data): IBM Plex Sans Regular/15px/1.5 line height
- Code (IDs, Technical Values): JetBrains Mono Regular/14px/1.4 line height
- Small (Hints, Metadata): IBM Plex Sans Regular/13px/muted color
## Animations
Animations should prioritize immediate feedback for data operations and subtle spatial awareness during navigation - instant response to clicks/inputs with 150ms micro-animations for state changes, 250ms smooth transitions between list/detail views, and purposeful loading states that communicate progress.
## Component Selection
- **Components**:
- Table with Select, Checkbox, and Badge for list views
- Dialog for create/edit forms with Form, Input, Textarea, Select, Switch, Calendar components
- Sidebar for model navigation with collapsible groups
- Command palette (cmdk) for quick model/record search
- Breadcrumb for navigation context
- Tabs for organizing related model groups
- Sheet for sliding detail panels
- Sonner for success/error toasts
- Skeleton for loading states
- **Customizations**:
- Custom JSON schema editor with syntax highlighting
- Dynamic field renderer component that maps schema types to inputs
- Virtualized table component for large datasets
- Relationship selector with async search
- **States**:
- Tables: hover highlights entire row with subtle background shift, selected rows show accent border
- Buttons: primary actions use filled accent color with slight scale on press, secondary use outline
- Inputs: focused state shows accent border with subtle glow, error state shows destructive color
- Forms: dirty state indicators on modified fields
- **Icon Selection**:
- Phosphor icons throughout: List/Table for views, Plus for create, Pencil for edit, Trash for delete, MagnifyingGlass for search, Funnel for filters, ArrowsDownUp for sort, Database for models, FloppyDisk for save
- **Spacing**:
- Dense mode: p-2 for cells, p-4 for cards, gap-2 for tight groups
- Standard: p-4 for containers, p-6 for dialogs, gap-4 for form fields, gap-6 for sections
- Generous whitespace in sidebar (p-6) and around primary actions
- **Mobile**:
- Sidebar collapses to hamburger menu, stacks on small screens with slide-out drawer
- Tables switch to card-based layout with key fields only, expandable for full details
- Forms switch to full-screen sheets on mobile
- Touch-friendly 44px minimum tap targets on all interactive elements
- Bottom navigation bar for primary actions on mobile

231
README.md
View File

@@ -1,23 +1,222 @@
# ✨ Welcome to Your Spark Template!
You've just launched your brand-new Spark Template Codespace — everythings fired up and ready for you to explore, build, and create with Spark!
# Django-Style React Admin Panel Generator
This template is your blank canvas. It comes with a minimal setup to help you get started quickly with Spark development.
A declarative admin panel generator that creates full-featured CRUD interfaces from JSON schema definitions. Inspired by Django's admin panel, this tool lets you define data models and UI configuration declaratively without writing component code.
🚀 What's Inside?
- A clean, minimal Spark environment
- Pre-configured for local development
- Ready to scale with your ideas
🧠 What Can You Do?
## Features
Right now, this is just a starting point — the perfect place to begin building and testing your Spark applications.
- **Declarative Schema Definition** - Define your entire data model in JSON
- **Automatic CRUD Generation** - List views, forms, validation automatically generated
- **Field Types** - String, text, number, boolean, date, datetime, email, URL, select, relations, JSON
- **Advanced Features** - Sorting, filtering, search, validation, relationships
- **Persistent Storage** - Data automatically saved using Spark KV storage
- **Live Schema Editing** - Edit schemas in real-time through the UI
🧹 Just Exploring?
No problem! If you were just checking things out and dont need to keep this code:
## Quick Start
- Simply delete your Spark.
- Everything will be cleaned up — no traces left behind.
1. Launch the app
2. Use the sidebar to navigate between models
3. Click "Create New" to add records
4. Edit or delete records using the action buttons
5. Click "Edit Schema" to customize your data models
📄 License For Spark Template Resources
## Schema Structure
The Spark Template files and resources from GitHub are licensed under the terms of the MIT license, Copyright GitHub, Inc.
The schema is a JSON object with the following structure:
```json
{
"apps": [
{
"name": "app_name",
"label": "App Label",
"models": [
{
"name": "model_name",
"label": "Model Label",
"labelPlural": "Models",
"icon": "IconName",
"listDisplay": ["field1", "field2"],
"listFilter": ["field3"],
"searchFields": ["field1", "field2"],
"ordering": ["-field2"],
"fields": [
{
"name": "field_name",
"type": "string",
"label": "Field Label",
"required": true,
"unique": false,
"default": "value",
"helpText": "Help text or array of strings",
"validation": {
"min": 0,
"max": 100,
"minLength": 3,
"maxLength": 200,
"pattern": "^[a-z]+$"
},
"listDisplay": true,
"searchable": true,
"sortable": true,
"editable": true
}
]
}
]
}
]
}
```
## Field Types
### Basic Types
- **string** - Single-line text input
- **text** - Multi-line textarea
- **number** - Numeric input with min/max validation
- **boolean** - Switch/toggle control
- **email** - Email input with validation
- **url** - URL input with validation
### Date/Time
- **date** - Date picker
- **datetime** - Date and time picker
### Advanced Types
- **select** - Dropdown with predefined choices
- **relation** - Foreign key to another model
- **json** - JSON editor for complex data
## Select Field Choices
For select fields, define choices as an array:
```json
{
"name": "status",
"type": "select",
"choices": [
{ "value": "draft", "label": "Draft" },
{ "value": "published", "label": "Published" },
{ "value": "archived", "label": "Archived" }
]
}
```
## Relationships
Define relationships between models using the relation type:
```json
{
"name": "author",
"type": "relation",
"relatedModel": "author",
"required": true
}
```
The related model must exist in the same app.
## Validation
Add validation rules to fields:
```json
{
"validation": {
"min": 0,
"max": 100,
"minLength": 3,
"maxLength": 200,
"pattern": "^[a-z0-9-]+$"
}
}
```
## Help Text
Provide help text as a string or array of strings:
```json
{
"helpText": "Single line help text"
}
```
Or for multi-line help:
```json
{
"helpText": [
"First line of help",
"Second line of help"
]
}
```
## List View Configuration
Control which fields appear in the list view:
```json
{
"listDisplay": ["title", "author", "status", "publishedAt"],
"listFilter": ["status", "author"],
"searchFields": ["title", "content"],
"ordering": ["-publishedAt"]
}
```
- **listDisplay** - Fields to show in the table
- **listFilter** - Fields to offer as filters (select/boolean only)
- **searchFields** - Fields to search when using the search box
- **ordering** - Default sort order (prefix with `-` for descending)
## Example Schemas
See `example-schemas.json` for complete examples including:
- Blog with posts and authors
- Task manager with projects and tasks
- E-commerce with products and categories
## Tips
1. **Start Simple** - Begin with basic string and text fields, add complexity later
2. **Use Relations** - Connect related data with relation fields
3. **Add Validation** - Prevent bad data with field validation rules
4. **Leverage Defaults** - Set sensible defaults for better UX
5. **Help Text** - Guide users with helpful field descriptions
6. **Test Incrementally** - Edit and test schema changes one model at a time
## Technical Details
- Built with React, TypeScript, and Tailwind CSS
- Uses shadcn/ui components for consistent design
- Data persisted with Spark KV storage
- Framer Motion for smooth animations
- Full type safety with TypeScript
## Keyboard Shortcuts
- Click table headers to sort
- Use search box for quick filtering
- Forms validate on blur and submit
## Limitations
- Relations only work within the same app
- No many-to-many relationships (use JSON arrays)
- No file uploads (use URL fields to reference external files)
- Maximum recommended records per model: 1000
## Architecture
The system consists of:
1. **Schema Parser** - Validates and processes JSON schemas
2. **Field Renderer** - Dynamically renders form inputs based on field types
3. **Model List View** - Table view with sorting, filtering, search
4. **Record Form** - Auto-generated create/edit forms with validation
5. **Schema Editor** - Live JSON editor for schema modifications
All data is stored in the Spark KV store with keys like `records_appname_modelname`.

290
example-schemas.json Normal file
View File

@@ -0,0 +1,290 @@
{
"description": "Example schema configurations for the Admin Panel Generator",
"examples": [
{
"name": "Simple Blog",
"description": "Basic blog with posts and authors",
"schema": {
"apps": [
{
"name": "blog",
"label": "Blog",
"models": [
{
"name": "post",
"label": "Post",
"labelPlural": "Posts",
"listDisplay": ["title", "author", "status", "publishedAt"],
"fields": [
{
"name": "id",
"type": "string",
"required": true,
"unique": true,
"editable": false
},
{
"name": "title",
"type": "string",
"label": "Title",
"required": true,
"validation": {
"minLength": 3,
"maxLength": 200
}
},
{
"name": "content",
"type": "text",
"label": "Content",
"required": true
},
{
"name": "author",
"type": "relation",
"label": "Author",
"required": true,
"relatedModel": "author"
},
{
"name": "status",
"type": "select",
"label": "Status",
"required": true,
"default": "draft",
"choices": [
{ "value": "draft", "label": "Draft" },
{ "value": "published", "label": "Published" }
]
},
{
"name": "publishedAt",
"type": "datetime",
"label": "Published At"
}
]
},
{
"name": "author",
"label": "Author",
"labelPlural": "Authors",
"listDisplay": ["name", "email"],
"fields": [
{
"name": "id",
"type": "string",
"required": true,
"unique": true,
"editable": false
},
{
"name": "name",
"type": "string",
"label": "Name",
"required": true
},
{
"name": "email",
"type": "email",
"label": "Email",
"required": true,
"unique": true
}
]
}
]
}
]
}
},
{
"name": "Task Manager",
"description": "Task management system with projects and tasks",
"schema": {
"apps": [
{
"name": "tasks",
"label": "Task Manager",
"models": [
{
"name": "project",
"label": "Project",
"labelPlural": "Projects",
"listDisplay": ["name", "status", "dueDate"],
"fields": [
{
"name": "id",
"type": "string",
"required": true,
"editable": false
},
{
"name": "name",
"type": "string",
"required": true
},
{
"name": "description",
"type": "text"
},
{
"name": "status",
"type": "select",
"default": "planning",
"choices": [
{ "value": "planning", "label": "Planning" },
{ "value": "active", "label": "Active" },
{ "value": "completed", "label": "Completed" }
]
},
{
"name": "dueDate",
"type": "date"
}
]
},
{
"name": "task",
"label": "Task",
"labelPlural": "Tasks",
"listDisplay": ["title", "project", "priority", "completed"],
"fields": [
{
"name": "id",
"type": "string",
"required": true,
"editable": false
},
{
"name": "title",
"type": "string",
"required": true
},
{
"name": "description",
"type": "text"
},
{
"name": "project",
"type": "relation",
"required": true,
"relatedModel": "project"
},
{
"name": "priority",
"type": "select",
"default": "medium",
"choices": [
{ "value": "low", "label": "Low" },
{ "value": "medium", "label": "Medium" },
{ "value": "high", "label": "High" }
]
},
{
"name": "completed",
"type": "boolean",
"default": false
}
]
}
]
}
]
}
},
{
"name": "E-Commerce",
"description": [
"Product catalog with categories and inventory",
"Includes price and stock management"
],
"schema": {
"apps": [
{
"name": "store",
"label": "Store",
"models": [
{
"name": "category",
"label": "Category",
"labelPlural": "Categories",
"fields": [
{
"name": "id",
"type": "string",
"required": true,
"editable": false
},
{
"name": "name",
"type": "string",
"required": true
},
{
"name": "description",
"type": "text"
}
]
},
{
"name": "product",
"label": "Product",
"labelPlural": "Products",
"listDisplay": ["name", "category", "price", "stock", "available"],
"fields": [
{
"name": "id",
"type": "string",
"required": true,
"editable": false
},
{
"name": "name",
"type": "string",
"required": true,
"validation": {
"minLength": 3
}
},
{
"name": "description",
"type": "text"
},
{
"name": "category",
"type": "relation",
"required": true,
"relatedModel": "category"
},
{
"name": "price",
"type": "number",
"required": true,
"validation": {
"min": 0
},
"helpText": "Price in USD"
},
{
"name": "stock",
"type": "number",
"required": true,
"default": 0,
"validation": {
"min": 0
}
},
{
"name": "available",
"type": "boolean",
"default": true
}
]
}
]
}
]
}
}
]
}

View File

@@ -4,9 +4,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title></title>
<title>Admin Panel Generator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=IBM+Plex+Sans:wght@400;500&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link href="/src/main.css" rel="stylesheet" />
</head>

View File

@@ -1,5 +1,123 @@
import { useState } from 'react'
import { useKV } from '@github/spark/hooks'
import { Toaster } from '@/components/ui/sonner'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import type { SchemaConfig } from '@/lib/schema-types'
import { defaultSchema } from '@/lib/default-schema'
import { getModelLabelPlural, getModelLabel } from '@/lib/schema-utils'
import { ModelListView } from '@/components/ModelListView'
import { SchemaEditor } from '@/components/SchemaEditor'
import { Database, Code, List } from '@phosphor-icons/react'
function App() {
return <div></div>
const [schema, setSchema] = useKV<SchemaConfig>('admin_schema', defaultSchema)
const [selectedApp, setSelectedApp] = useState(schema?.apps[0]?.name || '')
const [selectedModel, setSelectedModel] = useState(schema?.apps[0]?.models[0]?.name || '')
const [schemaEditorOpen, setSchemaEditorOpen] = useState(false)
if (!schema) return null
const currentApp = schema.apps.find(app => app.name === selectedApp)
const currentModel = currentApp?.models.find(model => model.name === selectedModel)
return (
<div className="flex h-screen overflow-hidden bg-background">
<aside className="w-64 bg-sidebar text-sidebar-foreground flex flex-col border-r border-sidebar-border">
<div className="p-6 border-b border-sidebar-border">
<h1 className="text-2xl font-bold text-sidebar-foreground flex items-center gap-2">
<Database weight="fill" />
Admin Panel
</h1>
<p className="text-sm text-sidebar-foreground/70 mt-1">Django-Style Generator</p>
</div>
<ScrollArea className="flex-1 p-4">
<div className="space-y-6">
{schema.apps.map(app => (
<div key={app.name} className="space-y-2">
<h3 className="text-xs uppercase font-semibold text-sidebar-foreground/60 tracking-wider px-2">
{app.label || app.name}
</h3>
<div className="space-y-1">
{app.models.map(model => (
<Button
key={model.name}
variant={selectedApp === app.name && selectedModel === model.name ? 'default' : 'ghost'}
className={`w-full justify-start ${
selectedApp === app.name && selectedModel === model.name
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'
}`}
onClick={() => {
setSelectedApp(app.name)
setSelectedModel(model.name)
}}
>
<List className="mr-2" size={18} />
{getModelLabelPlural(model)}
</Button>
))}
</div>
</div>
))}
</div>
</ScrollArea>
<Separator className="bg-sidebar-border" />
<div className="p-4">
<Button
variant="outline"
className="w-full border-sidebar-border text-sidebar-foreground hover:bg-sidebar-accent"
onClick={() => setSchemaEditorOpen(true)}
>
<Code className="mr-2" />
Edit Schema
</Button>
</div>
</aside>
<main className="flex-1 flex flex-col overflow-hidden">
<header className="border-b border-border bg-card px-6 py-4">
{currentModel && (
<div>
<h2 className="text-3xl font-bold text-foreground">
{getModelLabelPlural(currentModel)}
</h2>
<p className="text-sm text-muted-foreground mt-1">
Manage {getModelLabel(currentModel).toLowerCase()} records
</p>
</div>
)}
</header>
<div className="flex-1 overflow-auto p-6">
{currentModel && currentApp ? (
<ModelListView
model={currentModel}
schema={schema}
currentApp={currentApp.name}
/>
) : (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">Select a model from the sidebar</p>
</div>
)}
</div>
</main>
<SchemaEditor
open={schemaEditorOpen}
onClose={() => setSchemaEditorOpen(false)}
schema={schema}
onSave={setSchema}
/>
<Toaster />
</div>
)
}
export default App
export default App

View File

@@ -0,0 +1,209 @@
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import type { FieldSchema, SchemaConfig } from '@/lib/schema-types'
import { getFieldLabel, getHelpText, findModel, getModelLabel } from '@/lib/schema-utils'
import { useKV } from '@github/spark/hooks'
import { getRecordsKey } from '@/lib/schema-utils'
interface FieldRendererProps {
field: FieldSchema
value: any
onChange: (value: any) => void
error?: string
schema: SchemaConfig
currentApp: string
}
export function FieldRenderer({ field, value, onChange, error, schema, currentApp }: FieldRendererProps) {
const label = getFieldLabel(field)
const helpText = getHelpText(field)
const relatedRecordsKey = field.relatedModel ? getRecordsKey(currentApp, field.relatedModel) : 'dummy'
const [relatedModelRecords] = useKV<any[]>(relatedRecordsKey, [])
const relatedModel = field.type === 'relation' && field.relatedModel
? findModel(schema, currentApp, field.relatedModel)
: null
const renderInput = () => {
if (field.editable === false) {
return (
<Input
id={field.name}
value={value || ''}
disabled
className="bg-muted font-mono text-sm"
/>
)
}
switch (field.type) {
case 'string':
case 'email':
case 'url':
return (
<Input
id={field.name}
type={field.type === 'email' ? 'email' : field.type === 'url' ? 'url' : 'text'}
value={value || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={label}
className={error ? 'border-destructive' : ''}
/>
)
case 'text':
return (
<Textarea
id={field.name}
value={value || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={label}
rows={6}
className={error ? 'border-destructive' : ''}
/>
)
case 'number':
return (
<Input
id={field.name}
type="number"
value={value ?? ''}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
placeholder={label}
min={field.validation?.min}
max={field.validation?.max}
className={error ? 'border-destructive' : ''}
/>
)
case 'boolean':
return (
<div className="flex items-center gap-2">
<Switch
id={field.name}
checked={!!value}
onCheckedChange={onChange}
/>
<Label htmlFor={field.name} className="text-sm font-normal">
{value ? 'Yes' : 'No'}
</Label>
</div>
)
case 'date':
return (
<Input
id={field.name}
type="date"
value={value ? new Date(value).toISOString().split('T')[0] : ''}
onChange={(e) => onChange(e.target.value ? new Date(e.target.value).toISOString() : null)}
className={error ? 'border-destructive' : ''}
/>
)
case 'datetime':
return (
<Input
id={field.name}
type="datetime-local"
value={value ? new Date(value).toISOString().slice(0, 16) : ''}
onChange={(e) => onChange(e.target.value ? new Date(e.target.value).toISOString() : null)}
className={error ? 'border-destructive' : ''}
/>
)
case 'select':
return (
<Select value={value || ''} onValueChange={onChange}>
<SelectTrigger id={field.name} className={error ? 'border-destructive' : ''}>
<SelectValue placeholder={`Select ${label}`} />
</SelectTrigger>
<SelectContent>
{field.choices?.map((choice) => (
<SelectItem key={choice.value} value={choice.value}>
{choice.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
case 'relation':
if (!relatedModel || !relatedModelRecords || relatedModelRecords.length === 0) {
return (
<div className="text-sm text-muted-foreground p-2 border border-dashed rounded">
No {field.relatedModel} records available
</div>
)
}
const displayField = relatedModel.fields.find(f => f.name === 'name' || f.name === 'title')?.name || 'id'
return (
<Select value={value || ''} onValueChange={onChange}>
<SelectTrigger id={field.name} className={error ? 'border-destructive' : ''}>
<SelectValue placeholder={`Select ${label}`} />
</SelectTrigger>
<SelectContent>
{relatedModelRecords.map((record: any) => (
<SelectItem key={record.id} value={record.id}>
{record[displayField] || record.id}
</SelectItem>
))}
</SelectContent>
</Select>
)
case 'json':
return (
<Textarea
id={field.name}
value={value ? JSON.stringify(value, null, 2) : ''}
onChange={(e) => {
try {
const parsed = e.target.value ? JSON.parse(e.target.value) : null
onChange(parsed)
} catch {
onChange(e.target.value)
}
}}
placeholder='{"key": "value"}'
rows={6}
className={`font-mono text-sm ${error ? 'border-destructive' : ''}`}
/>
)
default:
return (
<Input
id={field.name}
value={value || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={label}
className={error ? 'border-destructive' : ''}
/>
)
}
}
return (
<div className="space-y-2">
<Label htmlFor={field.name} className="text-sm font-medium uppercase tracking-wide">
{label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{renderInput()}
{helpText && !error && (
<p className="text-sm text-muted-foreground">{helpText}</p>
)}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
)
}

View File

@@ -0,0 +1,317 @@
import { useState, useMemo } from 'react'
import { useKV } from '@github/spark/hooks'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { ModelSchema, SchemaConfig } from '@/lib/schema-types'
import { getRecordsKey, getFieldLabel, sortRecords, filterRecords, findModel } from '@/lib/schema-utils'
import { RecordForm } from './RecordForm'
import { Plus, Pencil, Trash, MagnifyingGlass, ArrowUp, ArrowDown } from '@phosphor-icons/react'
import { toast } from 'sonner'
import { motion } from 'framer-motion'
interface RelationCellValueProps {
value: string
relatedModel: string
currentApp: string
schema: SchemaConfig
}
function RelationCellValue({ value, relatedModel, currentApp, schema }: RelationCellValueProps) {
const relatedRecordsKey = getRecordsKey(currentApp, relatedModel)
const [relatedRecords] = useKV<any[]>(relatedRecordsKey, [])
const relatedRecord = relatedRecords?.find((r: any) => r.id === value)
if (!relatedRecord) return <span className="font-mono text-sm text-muted-foreground">{value}</span>
const relatedModelDef = findModel(schema, currentApp, relatedModel)
const displayField = relatedModelDef?.fields.find(f => f.name === 'name' || f.name === 'title')?.name || 'id'
return <Badge variant="outline" className="font-normal">{relatedRecord[displayField]}</Badge>
}
interface ModelListViewProps {
model: ModelSchema
schema: SchemaConfig
currentApp: string
}
export function ModelListView({ model, schema, currentApp }: ModelListViewProps) {
const [records, setRecords] = useKV<any[]>(getRecordsKey(currentApp, model.name), [])
const [searchTerm, setSearchTerm] = useState('')
const [sortField, setSortField] = useState<string | null>(model.ordering?.[0]?.replace('-', '') || null)
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(
model.ordering?.[0]?.startsWith('-') ? 'desc' : 'asc'
)
const [filters, setFilters] = useState<Record<string, any>>({})
const [formOpen, setFormOpen] = useState(false)
const [editingRecord, setEditingRecord] = useState<any | null>(null)
const displayFields = model.listDisplay || model.fields.filter(f => f.listDisplay !== false).slice(0, 5).map(f => f.name)
const searchFields = model.searchFields || model.fields.filter(f => f.searchable).map(f => f.name)
const filterFields = model.listFilter || model.fields.filter(f => f.type === 'select' || f.type === 'boolean').map(f => f.name)
const filteredAndSortedRecords = useMemo(() => {
if (!records) return []
let result = filterRecords(records, searchTerm, searchFields, filters)
if (sortField) {
result = sortRecords(result, sortField, sortDirection)
}
return result
}, [records, searchTerm, searchFields, filters, sortField, sortDirection])
const handleSort = (fieldName: string) => {
if (sortField === fieldName) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
} else {
setSortField(fieldName)
setSortDirection('asc')
}
}
const handleCreate = () => {
setEditingRecord(null)
setFormOpen(true)
}
const handleEdit = (record: any) => {
setEditingRecord(record)
setFormOpen(true)
}
const handleDelete = (recordId: string) => {
setRecords((current) => (current || []).filter((r) => r.id !== recordId))
toast.success('Record deleted')
}
const handleSave = (record: any) => {
if (editingRecord) {
setRecords((current) => (current || []).map((r) => (r.id === record.id ? record : r)))
toast.success('Record updated')
} else {
setRecords((current) => [...(current || []), record])
toast.success('Record created')
}
}
const renderCellValue = (record: any, fieldName: string) => {
const field = model.fields.find(f => f.name === fieldName)
if (!field) return null
const value = record[fieldName]
if (value === null || value === undefined) {
return <span className="text-muted-foreground"></span>
}
switch (field.type) {
case 'boolean':
return value ? (
<Badge className="bg-accent text-accent-foreground">Yes</Badge>
) : (
<Badge variant="outline">No</Badge>
)
case 'date':
case 'datetime':
return new Date(value).toLocaleString()
case 'select':
const choice = field.choices?.find(c => c.value === value)
return <Badge variant="secondary">{choice?.label || value}</Badge>
case 'relation':
return (
<RelationCellValue
value={value}
relatedModel={field.relatedModel || ''}
currentApp={currentApp}
schema={schema}
/>
)
case 'json':
return <code className="text-xs bg-muted px-2 py-1 rounded">{JSON.stringify(value)}</code>
case 'number':
return <span className="font-mono">{value}</span>
case 'text':
return <span className="truncate max-w-xs block">{String(value).substring(0, 100)}</span>
default:
return String(value)
}
}
if (!records) return null
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between mb-6 gap-4 flex-wrap">
<div className="flex items-center gap-4 flex-1">
<div className="relative flex-1 max-w-md">
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={`Search ${searchFields.join(', ')}...`}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
{filterFields.map(fieldName => {
const field = model.fields.find(f => f.name === fieldName)
if (!field) return null
if (field.type === 'select') {
return (
<Select
key={fieldName}
value={filters[fieldName] || ''}
onValueChange={(value) => setFilters({ ...filters, [fieldName]: value || null })}
>
<SelectTrigger className="w-40">
<SelectValue placeholder={getFieldLabel(field)} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All</SelectItem>
{field.choices?.map(choice => (
<SelectItem key={choice.value} value={choice.value}>
{choice.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
if (field.type === 'boolean') {
return (
<Select
key={fieldName}
value={filters[fieldName] === true ? 'true' : filters[fieldName] === false ? 'false' : ''}
onValueChange={(value) => setFilters({ ...filters, [fieldName]: value === 'true' ? true : value === 'false' ? false : null })}
>
<SelectTrigger className="w-40">
<SelectValue placeholder={getFieldLabel(field)} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All</SelectItem>
<SelectItem value="true">Yes</SelectItem>
<SelectItem value="false">No</SelectItem>
</SelectContent>
</Select>
)
}
return null
})}
</div>
<Button onClick={handleCreate} className="bg-accent text-accent-foreground hover:bg-accent/90">
<Plus className="mr-2" weight="bold" />
Create New
</Button>
</div>
{filteredAndSortedRecords.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 border-2 border-dashed rounded-lg">
<p className="text-lg text-muted-foreground mb-4">
{records.length === 0 ? 'No records yet' : 'No records match your filters'}
</p>
{records.length === 0 && (
<Button onClick={handleCreate} variant="outline">
<Plus className="mr-2" />
Create First Record
</Button>
)}
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
{displayFields.map(fieldName => {
const field = model.fields.find(f => f.name === fieldName)
if (!field) return null
const isSortable = field.sortable !== false
return (
<TableHead
key={fieldName}
className={isSortable ? 'cursor-pointer select-none hover:bg-muted' : ''}
onClick={() => isSortable && handleSort(fieldName)}
>
<div className="flex items-center gap-2">
<span className="uppercase text-xs font-semibold tracking-wider">
{getFieldLabel(field)}
</span>
{isSortable && sortField === fieldName && (
sortDirection === 'asc' ? <ArrowUp size={14} /> : <ArrowDown size={14} />
)}
</div>
</TableHead>
)
})}
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAndSortedRecords.map((record, index) => (
<motion.tr
key={record.id}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.02, duration: 0.15 }}
className="hover:bg-muted/30 border-b"
>
{displayFields.map(fieldName => (
<TableCell key={fieldName} className="py-3">
{renderCellValue(record, fieldName)}
</TableCell>
))}
<TableCell>
<div className="flex gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => handleEdit(record)}
>
<Pencil size={16} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDelete(record.id)}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash size={16} />
</Button>
</div>
</TableCell>
</motion.tr>
))}
</TableBody>
</Table>
</div>
)}
<RecordForm
open={formOpen}
onClose={() => setFormOpen(false)}
model={model}
schema={schema}
currentApp={currentApp}
record={editingRecord}
onSave={handleSave}
/>
</div>
)
}

View File

@@ -0,0 +1,97 @@
import { useState, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import type { ModelSchema, SchemaConfig } from '@/lib/schema-types'
import { validateRecord, createEmptyRecord } from '@/lib/schema-utils'
import { FieldRenderer } from './FieldRenderer'
import { FloppyDisk, X } from '@phosphor-icons/react'
interface RecordFormProps {
open: boolean
onClose: () => void
model: ModelSchema
schema: SchemaConfig
currentApp: string
record?: any
onSave: (record: any) => void
}
export function RecordForm({ open, onClose, model, schema, currentApp, record, onSave }: RecordFormProps) {
const [formData, setFormData] = useState<any>(record || createEmptyRecord(model))
const [errors, setErrors] = useState<Record<string, string>>({})
useEffect(() => {
if (record) {
setFormData(record)
} else {
setFormData(createEmptyRecord(model))
}
setErrors({})
}, [record, model, open])
const handleFieldChange = (fieldName: string, value: any) => {
setFormData((prev: any) => ({
...prev,
[fieldName]: value,
}))
if (errors[fieldName]) {
setErrors((prev) => {
const newErrors = { ...prev }
delete newErrors[fieldName]
return newErrors
})
}
}
const handleSave = () => {
const validationErrors = validateRecord(model, formData)
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors)
return
}
onSave(formData)
onClose()
}
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[90vh]">
<DialogHeader>
<DialogTitle className="text-2xl">
{record ? 'Edit' : 'Create'} {model.label || model.name}
</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[60vh] pr-4">
<div className="space-y-6">
{model.fields.map((field) => (
<FieldRenderer
key={field.name}
field={field}
value={formData[field.name]}
onChange={(value) => handleFieldChange(field.name, value)}
error={errors[field.name]}
schema={schema}
currentApp={currentApp}
/>
))}
</div>
</ScrollArea>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={onClose}>
<X className="mr-2" />
Cancel
</Button>
<Button onClick={handleSave} className="bg-accent text-accent-foreground hover:bg-accent/90">
<FloppyDisk className="mr-2" />
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,76 @@
import { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Alert, AlertDescription } from '@/components/ui/alert'
import type { SchemaConfig } from '@/lib/schema-types'
import { FloppyDisk, X, Warning } from '@phosphor-icons/react'
interface SchemaEditorProps {
open: boolean
onClose: () => void
schema: SchemaConfig
onSave: (schema: SchemaConfig) => void
}
export function SchemaEditor({ open, onClose, schema, onSave }: SchemaEditorProps) {
const [schemaText, setSchemaText] = useState(JSON.stringify(schema, null, 2))
const [error, setError] = useState<string | null>(null)
const handleSave = () => {
try {
const parsed = JSON.parse(schemaText)
if (!parsed.apps || !Array.isArray(parsed.apps)) {
setError('Schema must have an "apps" array')
return
}
onSave(parsed)
setError(null)
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'Invalid JSON')
}
}
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh]">
<DialogHeader>
<DialogTitle className="text-2xl">Edit Schema Configuration</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{error && (
<Alert variant="destructive">
<Warning className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Textarea
value={schemaText}
onChange={(e) => {
setSchemaText(e.target.value)
setError(null)
}}
className="font-mono text-sm min-h-[500px]"
placeholder="Enter JSON schema..."
/>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={onClose}>
<X className="mr-2" />
Cancel
</Button>
<Button onClick={handleSave} className="bg-accent text-accent-foreground hover:bg-accent/90">
<FloppyDisk className="mr-2" />
Save Schema
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1 +1,94 @@
/* This is where custom CSS goes */
@import 'tailwindcss';
@import "tw-animate-css";
@layer base {
* {
@apply border-border
}
}
:root {
--background: oklch(0.98 0 0);
--foreground: oklch(0.25 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.25 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.25 0 0);
--primary: oklch(0.45 0.15 265);
--primary-foreground: oklch(0.98 0 0);
--secondary: oklch(0.65 0.02 250);
--secondary-foreground: oklch(0.25 0 0);
--muted: oklch(0.95 0.01 250);
--muted-foreground: oklch(0.5 0.01 250);
--accent: oklch(0.75 0.15 195);
--accent-foreground: oklch(0.2 0.05 250);
--destructive: oklch(0.55 0.22 25);
--destructive-foreground: oklch(0.98 0 0);
--border: oklch(0.88 0.01 250);
--input: oklch(0.88 0.01 250);
--ring: oklch(0.75 0.15 195);
--sidebar: oklch(0.25 0.03 250);
--sidebar-foreground: oklch(0.85 0.01 250);
--sidebar-primary: oklch(0.75 0.15 195);
--sidebar-primary-foreground: oklch(0.98 0 0);
--sidebar-accent: oklch(0.35 0.04 250);
--sidebar-accent-foreground: oklch(0.85 0.01 250);
--sidebar-border: oklch(0.35 0.04 250);
--sidebar-ring: oklch(0.75 0.15 195);
--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);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-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;
}
body {
font-family: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Space Grotesk', system-ui, -apple-system, sans-serif;
}
code, pre, .font-mono {
font-family: 'JetBrains Mono', 'Courier New', monospace;
}

308
src/lib/default-schema.ts Normal file
View File

@@ -0,0 +1,308 @@
import type { SchemaConfig } from './schema-types'
export const defaultSchema: SchemaConfig = {
apps: [
{
name: 'blog',
label: 'Blog',
models: [
{
name: 'post',
label: 'Post',
labelPlural: 'Posts',
icon: 'Article',
listDisplay: ['title', 'author', 'status', 'publishedAt'],
listFilter: ['status', 'author'],
searchFields: ['title', 'content'],
ordering: ['-publishedAt'],
fields: [
{
name: 'id',
type: 'string',
label: 'ID',
required: true,
unique: true,
editable: false,
listDisplay: false,
},
{
name: 'title',
type: 'string',
label: 'Title',
required: true,
validation: {
minLength: 3,
maxLength: 200,
},
listDisplay: true,
searchable: true,
sortable: true,
},
{
name: 'slug',
type: 'string',
label: 'Slug',
required: true,
unique: true,
helpText: 'URL-friendly version of the title',
validation: {
pattern: '^[a-z0-9-]+$',
},
listDisplay: false,
sortable: true,
},
{
name: 'content',
type: 'text',
label: 'Content',
required: true,
helpText: 'Main post content',
listDisplay: false,
searchable: true,
},
{
name: 'excerpt',
type: 'text',
label: 'Excerpt',
required: false,
helpText: ['Short summary of the post', 'Used in list views and previews'],
validation: {
maxLength: 500,
},
listDisplay: false,
},
{
name: 'author',
type: 'relation',
label: 'Author',
required: true,
relatedModel: 'author',
listDisplay: true,
sortable: true,
},
{
name: 'status',
type: 'select',
label: 'Status',
required: true,
default: 'draft',
choices: [
{ value: 'draft', label: 'Draft' },
{ value: 'published', label: 'Published' },
{ value: 'archived', label: 'Archived' },
],
listDisplay: true,
sortable: true,
},
{
name: 'featured',
type: 'boolean',
label: 'Featured',
default: false,
helpText: 'Display on homepage',
listDisplay: true,
},
{
name: 'publishedAt',
type: 'datetime',
label: 'Published At',
required: false,
listDisplay: true,
sortable: true,
},
{
name: 'tags',
type: 'json',
label: 'Tags',
required: false,
helpText: 'JSON array of tag strings',
listDisplay: false,
},
{
name: 'views',
type: 'number',
label: 'Views',
default: 0,
validation: {
min: 0,
},
listDisplay: false,
},
],
},
{
name: 'author',
label: 'Author',
labelPlural: 'Authors',
icon: 'User',
listDisplay: ['name', 'email', 'active', 'createdAt'],
listFilter: ['active'],
searchFields: ['name', 'email'],
ordering: ['name'],
fields: [
{
name: 'id',
type: 'string',
label: 'ID',
required: true,
unique: true,
editable: false,
listDisplay: false,
},
{
name: 'name',
type: 'string',
label: 'Name',
required: true,
validation: {
minLength: 2,
maxLength: 100,
},
listDisplay: true,
searchable: true,
sortable: true,
},
{
name: 'email',
type: 'email',
label: 'Email',
required: true,
unique: true,
listDisplay: true,
searchable: true,
sortable: true,
},
{
name: 'bio',
type: 'text',
label: 'Bio',
required: false,
helpText: 'Author biography',
validation: {
maxLength: 1000,
},
listDisplay: false,
},
{
name: 'website',
type: 'url',
label: 'Website',
required: false,
listDisplay: false,
},
{
name: 'active',
type: 'boolean',
label: 'Active',
default: true,
listDisplay: true,
},
{
name: 'createdAt',
type: 'datetime',
label: 'Created At',
required: true,
editable: false,
listDisplay: true,
sortable: true,
},
],
},
],
},
{
name: 'ecommerce',
label: 'E-Commerce',
models: [
{
name: 'product',
label: 'Product',
labelPlural: 'Products',
icon: 'ShoppingCart',
listDisplay: ['name', 'price', 'stock', 'available'],
listFilter: ['available', 'category'],
searchFields: ['name', 'description'],
ordering: ['name'],
fields: [
{
name: 'id',
type: 'string',
label: 'ID',
required: true,
unique: true,
editable: false,
listDisplay: false,
},
{
name: 'name',
type: 'string',
label: 'Product Name',
required: true,
validation: {
minLength: 3,
maxLength: 200,
},
listDisplay: true,
searchable: true,
sortable: true,
},
{
name: 'description',
type: 'text',
label: 'Description',
required: false,
helpText: 'Product description',
listDisplay: false,
searchable: true,
},
{
name: 'price',
type: 'number',
label: 'Price',
required: true,
validation: {
min: 0,
},
listDisplay: true,
sortable: true,
},
{
name: 'stock',
type: 'number',
label: 'Stock',
required: true,
default: 0,
validation: {
min: 0,
},
listDisplay: true,
sortable: true,
},
{
name: 'category',
type: 'select',
label: 'Category',
required: true,
choices: [
{ value: 'electronics', label: 'Electronics' },
{ value: 'clothing', label: 'Clothing' },
{ value: 'books', label: 'Books' },
{ value: 'home', label: 'Home & Garden' },
{ value: 'toys', label: 'Toys' },
],
listDisplay: false,
sortable: true,
},
{
name: 'available',
type: 'boolean',
label: 'Available',
default: true,
listDisplay: true,
},
],
},
],
},
],
}

57
src/lib/schema-types.ts Normal file
View File

@@ -0,0 +1,57 @@
export type FieldType =
| 'string'
| 'text'
| 'number'
| 'boolean'
| 'date'
| 'datetime'
| 'email'
| 'url'
| 'select'
| 'relation'
| 'json'
export interface FieldSchema {
name: string
type: FieldType
label?: string
required?: boolean
unique?: boolean
default?: any
choices?: Array<{ value: string; label: string }>
relatedModel?: string
helpText?: string | string[]
validation?: {
min?: number
max?: number
minLength?: number
maxLength?: number
pattern?: string
}
listDisplay?: boolean
searchable?: boolean
sortable?: boolean
editable?: boolean
}
export interface ModelSchema {
name: string
label?: string
labelPlural?: string
icon?: string
fields: FieldSchema[]
listDisplay?: string[]
listFilter?: string[]
searchFields?: string[]
ordering?: string[]
}
export interface AppSchema {
name: string
label?: string
models: ModelSchema[]
}
export interface SchemaConfig {
apps: AppSchema[]
}

189
src/lib/schema-utils.ts Normal file
View File

@@ -0,0 +1,189 @@
import type { FieldSchema, ModelSchema, SchemaConfig } from './schema-types'
export function getModelKey(appName: string, modelName: string): string {
return `${appName}_${modelName}`
}
export function getRecordsKey(appName: string, modelName: string): string {
return `records_${appName}_${modelName}`
}
export function findModel(schema: SchemaConfig, appName: string, modelName: string): ModelSchema | undefined {
const app = schema.apps.find(a => a.name === appName)
if (!app) return undefined
return app.models.find(m => m.name === modelName)
}
export function getFieldLabel(field: FieldSchema): string {
return field.label || field.name.charAt(0).toUpperCase() + field.name.slice(1)
}
export function getModelLabel(model: ModelSchema): string {
return model.label || model.name.charAt(0).toUpperCase() + model.name.slice(1)
}
export function getModelLabelPlural(model: ModelSchema): string {
return model.labelPlural || getModelLabel(model) + 's'
}
export function getHelpText(field: FieldSchema): string {
if (!field.helpText) return ''
if (Array.isArray(field.helpText)) {
return field.helpText.join(' ')
}
return field.helpText
}
export function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).substring(2)
}
export function validateField(field: FieldSchema, value: any): string | null {
if (field.required && (value === undefined || value === null || value === '')) {
return `${getFieldLabel(field)} is required`
}
if (!value && !field.required) return null
if (field.validation) {
const { min, max, minLength, maxLength, pattern } = field.validation
if (field.type === 'number') {
const numValue = Number(value)
if (min !== undefined && numValue < min) {
return `${getFieldLabel(field)} must be at least ${min}`
}
if (max !== undefined && numValue > max) {
return `${getFieldLabel(field)} must be at most ${max}`
}
}
if (field.type === 'string' || field.type === 'text' || field.type === 'email' || field.type === 'url') {
const strValue = String(value)
if (minLength !== undefined && strValue.length < minLength) {
return `${getFieldLabel(field)} must be at least ${minLength} characters`
}
if (maxLength !== undefined && strValue.length > maxLength) {
return `${getFieldLabel(field)} must be at most ${maxLength} characters`
}
if (pattern && !new RegExp(pattern).test(strValue)) {
return `${getFieldLabel(field)} format is invalid`
}
}
}
if (field.type === 'email' && value) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(value)) {
return `${getFieldLabel(field)} must be a valid email address`
}
}
if (field.type === 'url' && value) {
try {
new URL(value)
} catch {
return `${getFieldLabel(field)} must be a valid URL`
}
}
return null
}
export function validateRecord(model: ModelSchema, record: any): Record<string, string> {
const errors: Record<string, string> = {}
for (const field of model.fields) {
if (field.editable === false) continue
const error = validateField(field, record[field.name])
if (error) {
errors[field.name] = error
}
}
return errors
}
export function getDefaultValue(field: FieldSchema): any {
if (field.default !== undefined) return field.default
switch (field.type) {
case 'string':
case 'text':
case 'email':
case 'url':
return ''
case 'number':
return 0
case 'boolean':
return false
case 'date':
case 'datetime':
return null
case 'select':
return field.choices?.[0]?.value || ''
case 'relation':
return null
case 'json':
return null
default:
return null
}
}
export function createEmptyRecord(model: ModelSchema): any {
const record: any = {}
for (const field of model.fields) {
if (field.name === 'id') {
record.id = generateId()
} else if (field.name === 'createdAt' && field.type === 'datetime') {
record.createdAt = new Date().toISOString()
} else {
record[field.name] = getDefaultValue(field)
}
}
return record
}
export function sortRecords(records: any[], field: string, direction: 'asc' | 'desc'): any[] {
return [...records].sort((a, b) => {
const aVal = a[field]
const bVal = b[field]
if (aVal === bVal) return 0
if (aVal === null || aVal === undefined) return 1
if (bVal === null || bVal === undefined) return -1
const comparison = aVal < bVal ? -1 : 1
return direction === 'asc' ? comparison : -comparison
})
}
export function filterRecords(
records: any[],
searchTerm: string,
searchFields: string[],
filters: Record<string, any>
): any[] {
let filtered = records
if (searchTerm) {
filtered = filtered.filter(record => {
return searchFields.some(field => {
const value = record[field]
if (value === null || value === undefined) return false
return String(value).toLowerCase().includes(searchTerm.toLowerCase())
})
})
}
Object.entries(filters).forEach(([field, filterValue]) => {
if (filterValue !== null && filterValue !== undefined && filterValue !== '') {
filtered = filtered.filter(record => record[field] === filterValue)
}
})
return filtered
}