mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 13:54:57 +00:00
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:
1
.spark-initial-sha
Normal file
1
.spark-initial-sha
Normal file
@@ -0,0 +1 @@
|
||||
a024526c87d7b9829fc5f702d15e3d3dec2b4557
|
||||
119
PRD.md
Normal file
119
PRD.md
Normal 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
231
README.md
@@ -1,23 +1,222 @@
|
||||
# ✨ Welcome to Your Spark Template!
|
||||
You've just launched your brand-new Spark Template Codespace — everything’s 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 don’t 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
290
example-schemas.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
122
src/App.tsx
122
src/App.tsx
@@ -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
|
||||
|
||||
209
src/components/FieldRenderer.tsx
Normal file
209
src/components/FieldRenderer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
317
src/components/ModelListView.tsx
Normal file
317
src/components/ModelListView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
src/components/RecordForm.tsx
Normal file
97
src/components/RecordForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
src/components/SchemaEditor.tsx
Normal file
76
src/components/SchemaEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
308
src/lib/default-schema.ts
Normal 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
57
src/lib/schema-types.ts
Normal 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
189
src/lib/schema-utils.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user