mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-25 06:04:54 +00:00
Compare commits
29 Commits
copilot/re
...
56376b1094
| Author | SHA1 | Date | |
|---|---|---|---|
| 56376b1094 | |||
| 809803283b | |||
| f0c5680b44 | |||
|
|
5a70926d9d | ||
|
|
fc231f727d | ||
|
|
4ac334a12c | ||
|
|
e549f4e0de | ||
|
|
db8759f9e0 | ||
|
|
4c17cc49c1 | ||
|
|
0514e61000 | ||
|
|
ad68fe8f44 | ||
|
|
c2112afe29 | ||
|
|
9a6c7f8652 | ||
|
|
a65a994ec4 | ||
|
|
a386551f23 | ||
|
|
1bb2eadbe8 | ||
|
|
56aeb492e6 | ||
| b3fa462527 | |||
| 2478948bcb | |||
| ff37033102 | |||
| e63d32eb05 | |||
| 5f92fbbf54 | |||
|
|
d9f2688a91 | ||
| bef28e8c91 | |||
| f69220e7e4 | |||
| 043eb427d3 | |||
| 3864fd247a | |||
| aa51074380 | |||
| cf74c35e0a |
@@ -3,7 +3,42 @@
|
||||
"allow": [
|
||||
"Bash(ls:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(grep:*)"
|
||||
"Bash(grep:*)",
|
||||
"Bash(wc:*)",
|
||||
"Bash(for file in accordion alert aspect-ratio avatar badge button card checkbox collapsible dialog hover-card input label popover progress radio-group resizable scroll-area separator skeleton sheet switch tabs textarea toggle tooltip)",
|
||||
"Bash(do)",
|
||||
"Bash([ -f \"src/config/pages/ui/$file.json\" ])",
|
||||
"Bash(echo:*)",
|
||||
"Bash(done)",
|
||||
"Bash(for file in data-source-card editor-toolbar empty-editor-state monaco-editor-panel search-bar)",
|
||||
"Bash([ -f \"src/config/pages/molecules/$file.json\" ])",
|
||||
"Bash(for file in empty-canvas-state page-header schema-editor-canvas schema-editor-properties-panel schema-editor-sidebar schema-editor-status-bar schema-editor-toolbar toolbar-actions)",
|
||||
"Bash([ -f \"src/config/pages/organisms/$file.json\" ])",
|
||||
"Bash([ -f \"src/config/pages/atoms/input.json\" ])",
|
||||
"Bash(npm run tsx:*)",
|
||||
"Bash(npx tsx:*)",
|
||||
"Bash(npm run test:e2e:*)",
|
||||
"Bash(npx playwright:*)",
|
||||
"Bash(timeout 15 npm run dev:*)",
|
||||
"Bash(netstat:*)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(taskkill:*)",
|
||||
"Bash(xargs:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(npm install)",
|
||||
"Bash(for file in ComponentBindingDialog.tsx DataSourceEditorDialog.tsx GitHubBuildStatus.tsx SaveIndicator.tsx ComponentTree.tsx SeedDataManager.tsx LazyD3BarChart.tsx StorageSettings.tsx)",
|
||||
"Bash(do if [ -f \"$file\" ])",
|
||||
"Bash(then echo \"EXISTS: $file\")",
|
||||
"Bash(fi)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(for file in AppBranding BindingEditor Breadcrumb CanvasRenderer CodeExplanationDialog ComponentPalette)",
|
||||
"Bash(do echo -n \"$file: \")",
|
||||
"Bash(tsx scripts/audit-json-components.ts:*)",
|
||||
"Bash(npm run audit:json:*)",
|
||||
"Bash(bash:*)",
|
||||
"Bash(git restore:*)",
|
||||
"Bash(tree:*)",
|
||||
"Bash(powershell:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
347
CLAUDE.md
Normal file
347
CLAUDE.md
Normal file
@@ -0,0 +1,347 @@
|
||||
# Claude Code Documentation
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
This is a low-code React application builder that is migrating from TypeScript/TSX components to a JSON-driven architecture.
|
||||
|
||||
### Current State (Jan 2026)
|
||||
|
||||
- **~420 TSX files** in `src/components/` (legacy - being phased out)
|
||||
- **338 JSON definitions** in `src/config/pages/` (target architecture)
|
||||
- **342 entries** in `json-components-registry.json`
|
||||
- **19 complete JSON implementations** in `src/components/json-definitions/`
|
||||
- **141 duplicate TSX files deleted** (had JSON equivalents)
|
||||
- **5 atoms remaining** to convert: Accordion, FileUpload, Image, Menu, Popover
|
||||
- **1 molecule remaining**: BindingEditor
|
||||
- **3 organisms remaining**: DataSourceManager, NavigationMenu, TreeListPanel
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Core Principle
|
||||
|
||||
**ALL components can be converted to JSON except the application entrypoint**, because custom hooks can handle any stateful/complex logic.
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # 🔴 LEGACY - Phase out
|
||||
│ ├── atoms/ # Basic UI components (6 TSX remaining)
|
||||
│ │ ├── json-ui/ # JSON-specific atoms
|
||||
│ │ ├── Accordion.tsx
|
||||
│ │ ├── FileUpload.tsx
|
||||
│ │ ├── Image.tsx
|
||||
│ │ ├── Menu.tsx
|
||||
│ │ └── Popover.tsx
|
||||
│ ├── molecules/ # Composite components (1 TSX remaining)
|
||||
│ │ └── BindingEditor.tsx
|
||||
│ ├── organisms/ # Complex feature components (3 TSX remaining)
|
||||
│ │ ├── DataSourceManager.tsx
|
||||
│ │ ├── NavigationMenu.tsx
|
||||
│ │ └── TreeListPanel.tsx
|
||||
│ └── json-definitions/ # ✅ JSON implementations (19 files)
|
||||
│ ├── loading-fallback.json
|
||||
│ ├── navigation-item.json
|
||||
│ ├── page-header-content.json
|
||||
│ ├── component-binding-dialog.json
|
||||
│ ├── data-source-editor-dialog.json
|
||||
│ ├── github-build-status.json
|
||||
│ ├── save-indicator.json
|
||||
│ ├── component-tree.json
|
||||
│ ├── seed-data-manager.json
|
||||
│ ├── lazy-d3-bar-chart.json
|
||||
│ ├── storage-settings.json
|
||||
│ ├── tree-card.json
|
||||
│ ├── filter-input.json
|
||||
│ ├── copy-button.json
|
||||
│ ├── input.json
|
||||
│ └── password-input.json
|
||||
│
|
||||
├── config/
|
||||
│ ├── pages/ # ✅ TARGET - JSON definitions (338 files)
|
||||
│ │ ├── atoms/ # JSON schema for atoms
|
||||
│ │ ├── molecules/ # JSON schema for molecules
|
||||
│ │ ├── organisms/ # JSON schema for organisms
|
||||
│ │ ├── templates/ # Page templates
|
||||
│ │ └── *.json # Page definitions
|
||||
│ └── pages.json # Central routing manifest
|
||||
│
|
||||
├── hooks/ # ✅ Custom hooks for JSON components
|
||||
│ ├── use-save-indicator.ts
|
||||
│ ├── use-component-tree.ts
|
||||
│ ├── use-storage-backend-info.ts
|
||||
│ ├── use-d3-bar-chart.ts
|
||||
│ ├── use-focus-state.ts # NEW: For FilterInput
|
||||
│ ├── use-copy-state.ts # NEW: For CopyButton
|
||||
│ ├── use-password-visibility.ts # NEW: For PasswordInput
|
||||
│ └── index.ts
|
||||
│
|
||||
├── lib/
|
||||
│ └── json-ui/
|
||||
│ ├── component-registry.ts # Component resolver
|
||||
│ ├── component-renderer.tsx # JSON → React renderer
|
||||
│ ├── json-components.ts # JSON component exports (27 components)
|
||||
│ ├── create-json-component.tsx # Pure JSON component factory
|
||||
│ ├── create-json-component-with-hooks.tsx # JSON + hooks factory
|
||||
│ ├── hooks.ts # Data source/action hooks
|
||||
│ ├── hooks-registry.ts # Hook registration (12 hooks registered)
|
||||
│ ├── constants/ # Shared constants for JSON transforms
|
||||
│ │ ├── sizes.ts # Button sizes, icon sizes, dimensions
|
||||
│ │ ├── placements.ts # Popover/tooltip positioning
|
||||
│ │ ├── styles.ts # Common CSS classes (transitions, animations, etc.)
|
||||
│ │ ├── object-fit.ts # Image object-fit classes
|
||||
│ │ └── index.ts
|
||||
│ └── interfaces/ # TypeScript interfaces (1 per file)
|
||||
│ ├── loading-fallback.ts
|
||||
│ ├── navigation-item.ts
|
||||
│ ├── page-header-content.ts
|
||||
│ ├── save-indicator.ts
|
||||
│ ├── lazy-bar-chart.ts
|
||||
│ ├── lazy-line-chart.ts
|
||||
│ ├── lazy-d3-bar-chart.ts
|
||||
│ ├── seed-data-manager.ts
|
||||
│ ├── storage-settings.ts
|
||||
│ ├── github-build-status.ts
|
||||
│ ├── component-binding-dialog.ts
|
||||
│ ├── data-source-editor-dialog.ts
|
||||
│ ├── component-tree.ts
|
||||
│ ├── tree-card.ts
|
||||
│ ├── filter-input.ts
|
||||
│ ├── copy-button.ts
|
||||
│ ├── input.ts
|
||||
│ ├── password-input.ts
|
||||
│ ├── image.ts
|
||||
│ ├── popover.ts
|
||||
│ ├── menu.ts
|
||||
│ ├── file-upload.ts
|
||||
│ ├── accordion.ts
|
||||
│ └── index.ts
|
||||
│
|
||||
├── scripts/ # Migration and audit tools
|
||||
│ ├── audit-json-components.ts
|
||||
│ ├── analyze-duplicates.ts
|
||||
│ ├── cleanup-registry.ts
|
||||
│ └── fix-index-files.ts
|
||||
│
|
||||
└── json-components-registry.json # Master component registry
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. Routing Flow
|
||||
|
||||
```
|
||||
pages.json → json-components-registry.json → Component Implementation
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```json
|
||||
// pages.json
|
||||
{
|
||||
"id": "dashboard",
|
||||
"component": "ProjectDashboard"
|
||||
}
|
||||
|
||||
// json-components-registry.json
|
||||
{
|
||||
"type": "ProjectDashboard",
|
||||
"source": "organisms",
|
||||
"load": {
|
||||
"path": "@/components/ProjectDashboard",
|
||||
"export": "ProjectDashboard"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Component Types
|
||||
|
||||
#### Pure JSON Components (No Hooks)
|
||||
Simple stateless components defined entirely in JSON:
|
||||
|
||||
```json
|
||||
// src/components/json-definitions/tree-card.json
|
||||
{
|
||||
"id": "tree-card-container",
|
||||
"type": "Card",
|
||||
"bindings": {
|
||||
"className": {
|
||||
"source": "isSelected",
|
||||
"transform": "data ? 'ring-2 ring-primary' : 'hover:bg-accent/50'"
|
||||
}
|
||||
},
|
||||
"children": [...]
|
||||
}
|
||||
```
|
||||
|
||||
Exported from `src/lib/json-ui/json-components.ts`:
|
||||
```typescript
|
||||
import treeCardDef from '@/components/json-definitions/tree-card.json'
|
||||
export const TreeCard = createJsonComponent<TreeCardProps>(treeCardDef)
|
||||
```
|
||||
|
||||
#### JSON Components with Hooks
|
||||
Stateful components using custom hooks (**NO WRAPPER FILES NEEDED**):
|
||||
|
||||
```typescript
|
||||
// src/lib/json-ui/json-components.ts
|
||||
export const ComponentTree = createJsonComponentWithHooks<ComponentTreeProps>(
|
||||
componentTreeDef,
|
||||
{
|
||||
hooks: {
|
||||
treeData: {
|
||||
hookName: 'useComponentTree',
|
||||
args: (props) => [props.components || [], props.selectedId || null]
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
The custom hook is defined in `src/hooks/use-component-tree.ts` (or other hook files) and registered in `src/lib/json-ui/hooks-registry.ts`.
|
||||
|
||||
#### TSX Components (Legacy)
|
||||
Currently imported directly - these need migration:
|
||||
|
||||
```typescript
|
||||
// ❌ OLD: Direct TSX import
|
||||
import { AppBranding } from '@/components/molecules/AppBranding'
|
||||
|
||||
// ✅ NEW: JSON-based import
|
||||
import { AppBranding } from '@/lib/json-ui/json-components'
|
||||
```
|
||||
|
||||
### 3. Registry System
|
||||
|
||||
The `json-components-registry.json` defines how components are loaded:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "SaveIndicator",
|
||||
"source": "molecules",
|
||||
"jsonCompatible": true
|
||||
}
|
||||
```
|
||||
|
||||
- **jsonCompatible**: Whether component can be expressed as JSON
|
||||
- **load.path**: Explicit path to component file (for TSX legacy components)
|
||||
- **source**: Where the component comes from (atoms, molecules, organisms, ui)
|
||||
|
||||
**Note:** `wrapperRequired` and `wrapperComponent` fields in the registry are **obsolete** and should be removed. All stateful logic is handled via `createJsonComponentWithHooks`.
|
||||
|
||||
## Current Issues (Jan 2026)
|
||||
|
||||
### Audit Results
|
||||
|
||||
Run `npm run audit:json` to see current status:
|
||||
|
||||
- ❌ **Errors**
|
||||
- 6 orphaned JSON files (no registry entry)
|
||||
- 7 broken load paths
|
||||
|
||||
- ⚠️ **153 warnings**
|
||||
- 153 duplicate implementations (TSX + JSON)
|
||||
|
||||
### Critical Tasks
|
||||
|
||||
1. **Phase Out `src/components/`**
|
||||
- 153 components have both TSX and JSON definitions
|
||||
- TSX versions should be deleted and routed through JSON
|
||||
|
||||
2. **Clean Up Registry**
|
||||
- Remove `wrapperRequired` and `wrapperComponent` fields (obsolete)
|
||||
- All stateful logic is handled via `createJsonComponentWithHooks`
|
||||
- Custom hooks defined in `src/lib/json-ui/hooks.ts`
|
||||
|
||||
3. **Fix Registry Issues**
|
||||
- Add missing registry entries for orphaned JSON
|
||||
- Fix broken load paths
|
||||
- Verify all source mappings
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
For each component:
|
||||
|
||||
- [ ] Create JSON definition in `src/components/json-definitions/`
|
||||
- [ ] Add TypeScript interface in `src/lib/json-ui/interfaces/` (one file per interface)
|
||||
- [ ] If stateful: Define custom hook in `src/hooks/use-[component-name].ts`
|
||||
- [ ] If stateful: Register hook in `src/lib/json-ui/hooks-registry.ts`
|
||||
- [ ] If stateful: Export hook from `src/hooks/index.ts`
|
||||
- [ ] Export from `src/lib/json-ui/json-components.ts`:
|
||||
- Use `createJsonComponent` for pure/stateless
|
||||
- Use `createJsonComponentWithHooks` for stateful
|
||||
- [ ] Update registry in `json-components-registry.json`
|
||||
- [ ] Update all imports to use `@/lib/json-ui/json-components`
|
||||
- [ ] Delete legacy TSX file from `src/components/`
|
||||
- [ ] Run tests and build to verify
|
||||
|
||||
## Useful Commands
|
||||
|
||||
```bash
|
||||
# Run audit to check migration status
|
||||
npm run audit:json
|
||||
|
||||
# Generate component types
|
||||
npm run components:generate-types
|
||||
|
||||
# Build (will fail if components missing)
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
- `json-components-registry.json` - Master registry of all components
|
||||
- `src/config/pages.json` - Page routing configuration
|
||||
- `src/lib/json-ui/component-registry.ts` - Component resolver logic
|
||||
- `src/lib/json-ui/json-components.ts` - JSON component exports
|
||||
- `src/lib/json-ui/hooks.ts` - Custom hooks for stateful components
|
||||
- `src/lib/json-ui/hooks-registry.ts` - Hook registration
|
||||
- `scripts/audit-json-components.ts` - Audit tool
|
||||
|
||||
## Notes
|
||||
|
||||
- **Never create new TSX components** - use JSON instead
|
||||
- **All components can be JSON** except the app entrypoint
|
||||
- **Use custom hooks** for stateful logic (via `createJsonComponentWithHooks`)
|
||||
- **NO wrapper files needed** - hooks are defined in `hooks.ts` and registered in `hooks-registry.ts`
|
||||
- **One interface per file** in `src/lib/json-ui/interfaces/`
|
||||
- **Meta JSON files** in `src/config/pages/` are routing schemas
|
||||
- **Full JSON definitions** live in `src/components/json-definitions/`
|
||||
|
||||
## Recent Changes (Jan 2026)
|
||||
|
||||
### Phase 1: Setup & Cleanup
|
||||
- ✅ Fixed e2e build failures (TreeCard, TreeListHeader routing)
|
||||
- ✅ Removed 8 initial duplicate TSX files with JSON equivalents
|
||||
- ✅ Split wrapper-interfaces.ts into individual interface files
|
||||
- ✅ Created audit script to track migration progress
|
||||
- ✅ Updated imports to use `@/lib/json-ui/json-components`
|
||||
- ✅ Clarified: NO wrapper system - use JSON + custom hooks
|
||||
|
||||
### Phase 2: Mass Cleanup
|
||||
- ✅ Cleaned registry - removed 107 obsolete `wrapperRequired`/`wrapperComponent` fields
|
||||
- ✅ Analyzed 153 duplicates, categorized safe deletions
|
||||
- ✅ Deleted 141 duplicate TSX files (had complete JSON implementations)
|
||||
- ✅ Created fix-index-files.ts script to auto-update exports
|
||||
|
||||
### Phase 3: Active Conversions (In Progress)
|
||||
- ✅ Converted FilterInput to JSON with useFocusState hook
|
||||
- ✅ Converted CopyButton to JSON with useCopyState hook
|
||||
- ✅ Converted Input to JSON (pure component with forwardRef support)
|
||||
- ✅ Converted PasswordInput to JSON with usePasswordVisibility hook
|
||||
- ✅ Moved custom hooks from `lib/json-ui/hooks.ts` to `src/hooks/` directory
|
||||
- ✅ Created use-focus-state.ts, use-copy-state.ts, and use-password-visibility.ts
|
||||
- ✅ Updated hooks-registry.ts to include 7 registered hooks
|
||||
|
||||
### Remaining Work
|
||||
- 🔄 5 atoms left: Accordion, FileUpload, Image, Menu, Popover
|
||||
- 🔄 1 molecule left: BindingEditor
|
||||
- 🔄 3 organisms left: DataSourceManager, NavigationMenu, TreeListPanel
|
||||
- ✅ 20 JSON components complete (up from 12)
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Clean up registry - remove `wrapperRequired` and `wrapperComponent` fields
|
||||
2. Convert the 153 duplicate TSX components to JSON-only
|
||||
3. Fix 6 orphaned JSON files (add registry entries)
|
||||
4. Fix 7 broken load paths in registry
|
||||
5. Complete full migration of `src/components/` to JSON
|
||||
59
CLAUDE_UPDATE.md
Normal file
59
CLAUDE_UPDATE.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Update for Current State section (lines 8-15)
|
||||
|
||||
- **~400 TSX files** in `src/components/` (legacy - being phased out)
|
||||
- **338 JSON definitions** in `src/config/pages/` (target architecture)
|
||||
- **342 entries** in `json-components-registry.json`
|
||||
- **27 complete JSON implementations** in `src/components/json-definitions/`
|
||||
- **141 duplicate TSX files deleted** (had JSON equivalents)
|
||||
- **✅ ALL ATOMS CONVERTED!** (0 remaining)
|
||||
- **1 molecule remaining**: BindingEditor
|
||||
- **3 organisms remaining**: DataSourceManager, NavigationMenu, TreeListPanel
|
||||
|
||||
# Update for atoms section (lines 28-34)
|
||||
|
||||
│ ├── atoms/ # ✅ ALL CONVERTED! (0 TSX remaining)
|
||||
│ │ └── json-ui/ # JSON-specific atoms
|
||||
|
||||
# Update for json-definitions (lines 41-57)
|
||||
|
||||
│ └── json-definitions/ # ✅ JSON implementations (27 files)
|
||||
│ ├── loading-fallback.json
|
||||
│ ├── navigation-item.json
|
||||
│ ├── page-header-content.json
|
||||
│ ├── component-binding-dialog.json
|
||||
│ ├── data-source-editor-dialog.json
|
||||
│ ├── github-build-status.json
|
||||
│ ├── save-indicator.json
|
||||
│ ├── component-tree.json
|
||||
│ ├── seed-data-manager.json
|
||||
│ ├── lazy-d3-bar-chart.json
|
||||
│ ├── storage-settings.json
|
||||
│ ├── tree-card.json
|
||||
│ ├── filter-input.json
|
||||
│ ├── copy-button.json
|
||||
│ ├── input.json
|
||||
│ ├── password-input.json
|
||||
│ ├── image.json
|
||||
│ ├── popover.json
|
||||
│ ├── menu.json
|
||||
│ ├── file-upload.json
|
||||
│ └── accordion.json
|
||||
|
||||
# Update for hooks (lines 73-76)
|
||||
|
||||
│ ├── use-focus-state.ts # For FilterInput
|
||||
│ ├── use-copy-state.ts # For CopyButton
|
||||
│ ├── use-password-visibility.ts # For PasswordInput
|
||||
│ ├── use-image-state.ts # For Image
|
||||
│ ├── use-popover-state.ts # For Popover
|
||||
│ ├── use-menu-state.ts # For Menu
|
||||
│ ├── use-file-upload.ts # For FileUpload
|
||||
│ ├── use-accordion.ts # For Accordion
|
||||
|
||||
# Update for json-components count (line 82)
|
||||
|
||||
│ ├── json-components.ts # JSON component exports (27 components)
|
||||
|
||||
# Update for hooks-registry count (line 86)
|
||||
|
||||
│ ├── hooks-registry.ts # Hook registration (12 hooks registered)
|
||||
195
ROOT_CAUSE_ANALYSIS.md
Normal file
195
ROOT_CAUSE_ANALYSIS.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Root Cause Analysis: JSON-Based React Component System
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The repository is attempting to transition from a traditional TypeScript React component architecture to a JSON-based declarative UI system. The build is currently failing because the transition is incomplete - some TypeScript components were deleted but their imports remain, and the JSON component system cannot yet fully replace them.
|
||||
|
||||
## Current State: Hybrid System Failure
|
||||
|
||||
### What Was Attempted
|
||||
1. **123 TypeScript components were deleted** (commit aa51074) and marked as "json-compatible" in the registry
|
||||
2. **JSON component registry created** with 375+ component definitions
|
||||
3. **JSON UI rendering system built** with component-renderer.tsx, expression evaluator, data binding, etc.
|
||||
4. **Wrapper components created** for complex molecules that need special handling
|
||||
|
||||
### What's Broken
|
||||
The build fails with these errors:
|
||||
```
|
||||
✘ [ERROR] No matching export in "src/components/molecules/index.ts" for import "NavigationItem"
|
||||
✘ [ERROR] No matching export in "src/components/molecules/index.ts" for import "PageHeaderContent"
|
||||
✘ [ERROR] No matching export in "src/components/molecules/index.ts" for import "TreeCard"
|
||||
✘ [ERROR] No matching export in "src/components/molecules/index.ts" for import "TreeListHeader"
|
||||
✘ [ERROR] No matching export in "src/components/molecules/index.ts" for import "preloadMonacoEditor"
|
||||
✘ [ERROR] No matching export in "src/components/molecules/index.ts" for import "LoadingFallback"
|
||||
```
|
||||
|
||||
## Root Causes
|
||||
|
||||
### 1. **Incomplete Conversion Strategy**
|
||||
Components were marked as JSON-compatible and deleted, but:
|
||||
- The **consuming code still imports them as TypeScript modules**
|
||||
- No migration was done to convert consumers to use the JSON renderer
|
||||
- The JSON system exists but isn't wired into the main application flow
|
||||
|
||||
### 2. **Misunderstanding of JSON Component Architecture**
|
||||
The JSON system is designed for **declarative page configurations**, not as a drop-in replacement for React components. Example:
|
||||
|
||||
**Traditional React:**
|
||||
```tsx
|
||||
import { TreeCard } from '@/components/molecules'
|
||||
<TreeCard tree={data} onSelect={handleSelect} />
|
||||
```
|
||||
|
||||
**JSON System:**
|
||||
```json
|
||||
{
|
||||
"type": "TreeCard",
|
||||
"bindings": {
|
||||
"tree": { "source": "currentTree" }
|
||||
},
|
||||
"events": {
|
||||
"onSelect": { "action": "selectTree" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The JSON system requires:
|
||||
- JSON configuration files
|
||||
- JSONSchemaPageLoader or PageRenderer wrapper
|
||||
- Data sources defined in JSON
|
||||
- Event handlers defined in JSON
|
||||
- Cannot be imported like a normal React component
|
||||
|
||||
### 3. **Deleted Components Still Referenced**
|
||||
Components deleted but still imported:
|
||||
- **TreeCard** - Used in TreeListPanel.tsx
|
||||
- **TreeListHeader** - Used in TreeListPanel.tsx
|
||||
- **LoadingFallback** - Used in JSONSchemaPageLoader.tsx and routes.tsx
|
||||
- **NavigationItem** - File exists but not exported from index.ts
|
||||
- **PageHeaderContent** - File exists but not exported from index.ts
|
||||
- **preloadMonacoEditor** - Function exists but not exported from index.ts
|
||||
|
||||
### 4. **Module System vs Component Registry Mismatch**
|
||||
The component-registry.ts uses `import.meta.glob` to load ALL .tsx files:
|
||||
```ts
|
||||
const moleculeModules = import.meta.glob('@/components/molecules/*.tsx', { eager: true })
|
||||
```
|
||||
|
||||
This means:
|
||||
- It CAN dynamically load TreeCard, TreeListHeader, etc. IF they exist as .tsx files
|
||||
- But they were DELETED, so they can't be found
|
||||
- The registry says they're "json-compatible" but provides no fallback
|
||||
- The JSON renderer can use them IF loaded via JSON config, but direct imports fail
|
||||
|
||||
## The Fundamental Problem: No Working JSON System Examples
|
||||
|
||||
**Key Issue:** While the JSON UI infrastructure exists, there are NO working examples of pages that successfully:
|
||||
1. Define a complex page entirely in JSON
|
||||
2. Handle state management in JSON
|
||||
3. Wire up all events in JSON
|
||||
4. Replace an existing TypeScript page
|
||||
|
||||
The infrastructure exists but hasn't been proven to work end-to-end.
|
||||
|
||||
## Architecture Deep Dive
|
||||
|
||||
### JSON UI System Components
|
||||
```
|
||||
src/lib/json-ui/
|
||||
├── component-renderer.tsx # Renders individual components from JSON
|
||||
├── page-renderer.tsx # Renders full pages from JSON
|
||||
├── component-registry.ts # Maps component names to React components
|
||||
├── expression-evaluator.ts # Evaluates data binding expressions
|
||||
├── hooks.ts # Data source hooks
|
||||
├── schema.ts # TypeScript types
|
||||
└── wrappers/ # Special wrappers for complex components
|
||||
```
|
||||
|
||||
### How It Should Work (Theory)
|
||||
1. Create JSON page definition in `src/config/ui-examples/my-page.json`
|
||||
2. Load it with `<JSONSchemaPageLoader schemaPath="/config/ui-examples/my-page.json" />`
|
||||
3. JSON renderer looks up components in registry
|
||||
4. Registry loads them via import.meta.glob
|
||||
5. Components render with data bindings and events
|
||||
|
||||
### Why It Doesn't Work (Reality)
|
||||
1. **Deleted components can't be loaded** - glob can't find non-existent files
|
||||
2. **Existing TypeScript pages import components directly** - they don't use JSON loader
|
||||
3. **No migration path** - can't gradually convert pages
|
||||
4. **Registry assumes all components exist as .tsx files** - no JSON-only components
|
||||
|
||||
## Two Possible Solutions
|
||||
|
||||
### Option A: Restore Components (Backward Compatibility)
|
||||
**Goal:** Make the build work by restoring deleted components
|
||||
|
||||
Steps:
|
||||
1. Restore TreeCard, TreeListHeader, LoadingFallback as .tsx files
|
||||
2. Export NavigationItem, PageHeaderContent, preloadMonacoEditor
|
||||
3. Keep JSON system for future use
|
||||
4. Gradual migration when JSON system proven
|
||||
|
||||
**Pros:** Quick fix, maintains compatibility, low risk
|
||||
**Cons:** Delays JSON transition, maintains technical debt
|
||||
|
||||
### Option B: Full JSON Transition (Forward-Looking)
|
||||
**Goal:** Convert consuming pages to use JSON system
|
||||
|
||||
Steps:
|
||||
1. Convert TreeListPanel.tsx to use JSON renderer
|
||||
2. Convert routes.tsx to load JSON configs
|
||||
3. Create JSON definitions for missing components
|
||||
4. Delete rigid TypeScript components
|
||||
5. Prove JSON system works end-to-end
|
||||
|
||||
**Pros:** Achieves goal of JSON system, modern architecture
|
||||
**Cons:** High risk, requires extensive testing, may reveal more issues
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Start with Option A**, then gradually move toward Option B:
|
||||
|
||||
1. **Immediate Fix** (Option A):
|
||||
- Restore the 3 deleted components (TreeCard, TreeListHeader, LoadingFallback)
|
||||
- Fix exports for existing components (NavigationItem, PageHeaderContent, preloadMonacoEditor)
|
||||
- Get the build working
|
||||
|
||||
2. **Validation Phase**:
|
||||
- Create 1-2 complete working examples of JSON pages
|
||||
- Test all JSON system features (data binding, events, conditionals, loops)
|
||||
- Document the conversion process
|
||||
- Identify limitations
|
||||
|
||||
3. **Gradual Migration** (Option B):
|
||||
- Convert simple pages first
|
||||
- Build tooling to help convert TypeScript to JSON
|
||||
- Only delete TypeScript after JSON proven working
|
||||
- Keep wrappers for complex components
|
||||
|
||||
## Files Requiring Immediate Attention
|
||||
|
||||
1. `src/components/molecules/TreeCard.tsx` - RESTORE from aa51074~1
|
||||
2. `src/components/molecules/TreeListHeader.tsx` - RESTORE from aa51074~1
|
||||
3. `src/components/molecules/LoadingFallback.tsx` - RESTORE from aa51074~1
|
||||
4. `src/components/molecules/index.ts` - ADD exports for NavigationItem, PageHeaderContent
|
||||
5. `src/components/molecules/LazyMonacoEditor.tsx` - Already exports preloadMonacoEditor, just needs index.ts export
|
||||
|
||||
## Testing Plan
|
||||
|
||||
After fixes:
|
||||
1. Run `npm run dev` - should start without errors
|
||||
2. Run `npm run build` - should complete successfully
|
||||
3. Run `npm run test:e2e` - should pass
|
||||
4. Manually test pages that use restored components
|
||||
5. Test JSON UI showcase page to verify JSON system still works
|
||||
|
||||
## Long-Term Vision Questions
|
||||
|
||||
1. Can complex state management work in JSON?
|
||||
2. How do we handle TypeScript types and intellisense for JSON configs?
|
||||
3. What about component composition and reusability?
|
||||
4. Performance implications of JSON parsing and dynamic loading?
|
||||
5. How do non-developers edit JSON configs safely?
|
||||
6. Can we generate JSON from existing TypeScript components?
|
||||
7. What's the migration path for 250+ existing pages?
|
||||
|
||||
1175
audit-report.json
Normal file
1175
audit-report.json
Normal file
File diff suppressed because it is too large
Load Diff
141
delete-duplicates.sh
Normal file
141
delete-duplicates.sh
Normal file
@@ -0,0 +1,141 @@
|
||||
rm "src\components\atoms\ActionButton.tsx"
|
||||
rm "src\components\atoms\ActionCard.tsx"
|
||||
rm "src\components\atoms\ActionIcon.tsx"
|
||||
rm "src\components\atoms\Alert.tsx"
|
||||
rm "src\components\atoms\AppLogo.tsx"
|
||||
rm "src\components\atoms\Avatar.tsx"
|
||||
rm "src\components\atoms\AvatarGroup.tsx"
|
||||
rm "src\components\atoms\Badge.tsx"
|
||||
rm "src\components\atoms\BindingIndicator.tsx"
|
||||
rm "src\components\atoms\Breadcrumb.tsx"
|
||||
rm "src\components\atoms\Button.tsx"
|
||||
rm "src\components\atoms\ButtonGroup.tsx"
|
||||
rm "src\components\atoms\Calendar.tsx"
|
||||
rm "src\components\atoms\Card.tsx"
|
||||
rm "src\components\atoms\Checkbox.tsx"
|
||||
rm "src\components\atoms\Chip.tsx"
|
||||
rm "src\components\atoms\CircularProgress.tsx"
|
||||
rm "src\components\atoms\Code.tsx"
|
||||
rm "src\components\atoms\ColorSwatch.tsx"
|
||||
rm "src\components\atoms\CommandPalette.tsx"
|
||||
rm "src\components\atoms\CompletionCard.tsx"
|
||||
rm "src\components\atoms\ComponentPaletteItem.tsx"
|
||||
rm "src\components\atoms\ComponentTreeNode.tsx"
|
||||
rm "src\components\atoms\ConfirmButton.tsx"
|
||||
rm "src\components\atoms\Container.tsx"
|
||||
rm "src\components\atoms\ContextMenu.tsx"
|
||||
rm "src\components\atoms\CountBadge.tsx"
|
||||
rm "src\components\atoms\DataList.tsx"
|
||||
rm "src\components\atoms\DataSourceBadge.tsx"
|
||||
rm "src\components\atoms\DataTable.tsx"
|
||||
rm "src\components\atoms\DatePicker.tsx"
|
||||
rm "src\components\atoms\DetailRow.tsx"
|
||||
rm "src\components\atoms\Divider.tsx"
|
||||
rm "src\components\atoms\Dot.tsx"
|
||||
rm "src\components\atoms\Drawer.tsx"
|
||||
rm "src\components\atoms\EmptyMessage.tsx"
|
||||
rm "src\components\atoms\EmptyState.tsx"
|
||||
rm "src\components\atoms\EmptyStateIcon.tsx"
|
||||
rm "src\components\atoms\ErrorBadge.tsx"
|
||||
rm "src\components\atoms\FileIcon.tsx"
|
||||
rm "src\components\atoms\Flex.tsx"
|
||||
rm "src\components\atoms\Form.tsx"
|
||||
rm "src\components\atoms\GlowCard.tsx"
|
||||
rm "src\components\atoms\Grid.tsx"
|
||||
rm "src\components\atoms\Heading.tsx"
|
||||
rm "src\components\atoms\HelperText.tsx"
|
||||
rm "src\components\atoms\HoverCard.tsx"
|
||||
rm "src\components\atoms\IconButton.tsx"
|
||||
rm "src\components\atoms\IconText.tsx"
|
||||
rm "src\components\atoms\IconWrapper.tsx"
|
||||
rm "src\components\atoms\InfoBox.tsx"
|
||||
rm "src\components\atoms\InfoPanel.tsx"
|
||||
rm "src\components\atoms\Input.tsx"
|
||||
rm "src\components\atoms\Kbd.tsx"
|
||||
rm "src\components\atoms\KeyValue.tsx"
|
||||
rm "src\components\atoms\Label.tsx"
|
||||
rm "src\components\atoms\Link.tsx"
|
||||
rm "src\components\atoms\List.tsx"
|
||||
rm "src\components\atoms\ListItem.tsx"
|
||||
rm "src\components\atoms\LiveIndicator.tsx"
|
||||
rm "src\components\atoms\LoadingSpinner.tsx"
|
||||
rm "src\components\atoms\LoadingState.tsx"
|
||||
rm "src\components\atoms\MetricCard.tsx"
|
||||
rm "src\components\atoms\MetricDisplay.tsx"
|
||||
rm "src\components\atoms\Modal.tsx"
|
||||
rm "src\components\atoms\Notification.tsx"
|
||||
rm "src\components\atoms\NumberInput.tsx"
|
||||
rm "src\components\atoms\PageHeader.tsx"
|
||||
rm "src\components\atoms\PanelHeader.tsx"
|
||||
rm "src\components\atoms\ProgressBar.tsx"
|
||||
rm "src\components\atoms\PropertyEditorField.tsx"
|
||||
rm "src\components\atoms\Pulse.tsx"
|
||||
rm "src\components\atoms\QuickActionButton.tsx"
|
||||
rm "src\components\atoms\Radio.tsx"
|
||||
rm "src\components\atoms\RangeSlider.tsx"
|
||||
rm "src\components\atoms\Rating.tsx"
|
||||
rm "src\components\atoms\ResponsiveGrid.tsx"
|
||||
rm "src\components\atoms\ScrollArea.tsx"
|
||||
rm "src\components\atoms\SearchInput.tsx"
|
||||
rm "src\components\atoms\Section.tsx"
|
||||
rm "src\components\atoms\SeedDataStatus.tsx"
|
||||
rm "src\components\atoms\Select.tsx"
|
||||
rm "src\components\atoms\Separator.tsx"
|
||||
rm "src\components\atoms\Skeleton.tsx"
|
||||
rm "src\components\atoms\Slider.tsx"
|
||||
rm "src\components\atoms\Spacer.tsx"
|
||||
rm "src\components\atoms\Sparkle.tsx"
|
||||
rm "src\components\atoms\Spinner.tsx"
|
||||
rm "src\components\atoms\Stack.tsx"
|
||||
rm "src\components\atoms\StatCard.tsx"
|
||||
rm "src\components\atoms\StatusBadge.tsx"
|
||||
rm "src\components\atoms\StatusIcon.tsx"
|
||||
rm "src\components\atoms\StepIndicator.tsx"
|
||||
rm "src\components\atoms\Stepper.tsx"
|
||||
rm "src\components\atoms\Switch.tsx"
|
||||
rm "src\components\atoms\TabIcon.tsx"
|
||||
rm "src\components\atoms\Table.tsx"
|
||||
rm "src\components\atoms\Tabs.tsx"
|
||||
rm "src\components\atoms\Tag.tsx"
|
||||
rm "src\components\atoms\Text.tsx"
|
||||
rm "src\components\atoms\TextArea.tsx"
|
||||
rm "src\components\atoms\TextGradient.tsx"
|
||||
rm "src\components\atoms\TextHighlight.tsx"
|
||||
rm "src\components\atoms\Timeline.tsx"
|
||||
rm "src\components\atoms\Timestamp.tsx"
|
||||
rm "src\components\atoms\TipsCard.tsx"
|
||||
rm "src\components\atoms\Toggle.tsx"
|
||||
rm "src\components\atoms\Tooltip.tsx"
|
||||
rm "src\components\atoms\TreeIcon.tsx"
|
||||
rm "src\components\molecules\AppBranding.tsx"
|
||||
rm "src\components\molecules\Breadcrumb.tsx"
|
||||
rm "src\components\molecules\CanvasRenderer.tsx"
|
||||
rm "src\components\molecules\CodeExplanationDialog.tsx"
|
||||
rm "src\components\molecules\ComponentPalette.tsx"
|
||||
rm "src\components\molecules\DataSourceCard.tsx"
|
||||
rm "src\components\molecules\EditorActions.tsx"
|
||||
rm "src\components\molecules\EditorToolbar.tsx"
|
||||
rm "src\components\molecules\EmptyEditorState.tsx"
|
||||
rm "src\components\molecules\FileTabs.tsx"
|
||||
rm "src\components\molecules\LazyBarChart.tsx"
|
||||
rm "src\components\molecules\LazyInlineMonacoEditor.tsx"
|
||||
rm "src\components\molecules\LazyLineChart.tsx"
|
||||
rm "src\components\molecules\LazyMonacoEditor.tsx"
|
||||
rm "src\components\molecules\MonacoEditorPanel.tsx"
|
||||
rm "src\components\molecules\NavigationGroupHeader.tsx"
|
||||
rm "src\components\molecules\PropertyEditor.tsx"
|
||||
rm "src\components\molecules\SearchBar.tsx"
|
||||
rm "src\components\molecules\SearchInput.tsx"
|
||||
rm "src\components\molecules\ToolbarButton.tsx"
|
||||
rm "src\components\molecules\TreeFormDialog.tsx"
|
||||
rm "src\components\organisms\AppHeader.tsx"
|
||||
rm "src\components\organisms\EmptyCanvasState.tsx"
|
||||
rm "src\components\organisms\PageHeader.tsx"
|
||||
rm "src\components\organisms\SchemaCodeViewer.tsx"
|
||||
rm "src\components\organisms\SchemaEditorCanvas.tsx"
|
||||
rm "src\components\organisms\SchemaEditorLayout.tsx"
|
||||
rm "src\components\organisms\SchemaEditorPropertiesPanel.tsx"
|
||||
rm "src\components\organisms\SchemaEditorSidebar.tsx"
|
||||
rm "src\components\organisms\SchemaEditorStatusBar.tsx"
|
||||
rm "src\components\organisms\SchemaEditorToolbar.tsx"
|
||||
rm "src\components\organisms\ToolbarActions.tsx"
|
||||
107
docs/JSON_CONSTANTS.md
Normal file
107
docs/JSON_CONSTANTS.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# JSON Constants Migration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This document tracks the extraction of hardcoded constants from JSON component definitions into the centralized constants folder.
|
||||
|
||||
## Status
|
||||
|
||||
### ✅ Constants Folder Created
|
||||
Location: `src/lib/json-ui/constants/`
|
||||
|
||||
Files:
|
||||
- `sizes.ts` - Size-related constants
|
||||
- `placements.ts` - Positioning constants
|
||||
- `styles.ts` - CSS class constants
|
||||
- `object-fit.ts` - Image object-fit constants
|
||||
- `index.ts` - Centralized exports
|
||||
|
||||
### 📋 Constants Found in JSON Files
|
||||
|
||||
#### CopyButton (`copy-button.json`)
|
||||
```javascript
|
||||
// Line 11: sizeStyles
|
||||
const sizeStyles = { sm: 'p-1', md: 'p-2', lg: 'p-3' }
|
||||
// → BUTTON_SIZES
|
||||
|
||||
// Lines 25, 39: iconSize (duplicated)
|
||||
const iconSize = { sm: 12, md: 16, lg: 20 }
|
||||
// → ICON_SIZES
|
||||
```
|
||||
|
||||
#### Popover (`popover.json`)
|
||||
```javascript
|
||||
// Line 39: placementStyles
|
||||
const placementStyles = {
|
||||
top: 'bottom-full mb-2 left-1/2 -translate-x-1/2',
|
||||
bottom: 'top-full mt-2 left-1/2 -translate-x-1/2',
|
||||
left: 'right-full mr-2 top-1/2 -translate-y-1/2',
|
||||
right: 'left-full ml-2 top-1/2 -translate-y-1/2'
|
||||
}
|
||||
// → POPOVER_PLACEMENTS
|
||||
```
|
||||
|
||||
#### Image (`image.json`)
|
||||
```javascript
|
||||
// Line 51: Dynamic object-fit (uses template literal)
|
||||
return `${base} ${opacity} object-${fit}`
|
||||
// Could use OBJECT_FIT_CLASSES but requires transform refactor
|
||||
```
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Option 1: Keep Inline (Current Approach)
|
||||
**Pros:**
|
||||
- No changes to component-renderer needed
|
||||
- Self-contained JSON definitions
|
||||
- Easy to understand transforms
|
||||
|
||||
**Cons:**
|
||||
- Duplication of constants
|
||||
- Harder to maintain consistency
|
||||
- Magic strings scattered across files
|
||||
|
||||
### Option 2: Import Constants in Hooks
|
||||
**Pros:**
|
||||
- Hooks can use TypeScript constants
|
||||
- No changes to JSON structure needed
|
||||
- Immediate benefit for custom hooks
|
||||
|
||||
**Cons:**
|
||||
- Only helps with hook-based logic
|
||||
- Still have duplication in JSON transforms
|
||||
|
||||
### Option 3: Add Constants to Transform Context (Future)
|
||||
**Pros:**
|
||||
- Eliminates duplication entirely
|
||||
- Type-safe constants usage
|
||||
- Easier to update global styles
|
||||
|
||||
**Cons:**
|
||||
- Requires component-renderer changes
|
||||
- More complex transform evaluation
|
||||
- Migration effort for existing JSON files
|
||||
|
||||
## Recommended Next Steps
|
||||
|
||||
1. **Short term:** Use constants in custom hooks (Option 2)
|
||||
- Hooks can import from `@/lib/json-ui/constants`
|
||||
- Reduce duplication in hook code
|
||||
|
||||
2. **Medium term:** Document best practices
|
||||
- Add examples of using constants
|
||||
- Create migration guide for new components
|
||||
|
||||
3. **Long term:** Enhanced transform context (Option 3)
|
||||
- Update component-renderer to expose constants
|
||||
- Migrate existing JSON files to use constants
|
||||
- Remove inline const statements
|
||||
|
||||
## Files to Potentially Update
|
||||
|
||||
When migrating to Option 3:
|
||||
- `copy-button.json` - sizeStyles, iconSize
|
||||
- `popover.json` - placementStyles
|
||||
- `menu.json` - May have similar patterns
|
||||
- `file-upload.json` - May have size constants
|
||||
- Any future components using similar patterns
|
||||
1991
duplicate-analysis.json
Normal file
1991
duplicate-analysis.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,10 @@ test.describe('CodeForge - Core Functionality', () => {
|
||||
})
|
||||
|
||||
test('should load the application successfully', async ({ page }) => {
|
||||
await expect(page.locator('body')).toBeVisible()
|
||||
// Check root has children (content rendered)
|
||||
await page.waitForSelector('#root > *', { timeout: 10000 })
|
||||
const root = page.locator('#root')
|
||||
await expect(root).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('should display main navigation', async ({ page }) => {
|
||||
@@ -50,8 +53,8 @@ test.describe('CodeForge - Responsive Design', () => {
|
||||
await page.setViewportSize({ width: 375, height: 667 })
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 10000 })
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 })
|
||||
|
||||
await expect(page.locator('body')).toBeVisible()
|
||||
|
||||
await page.waitForSelector('#root > *', { timeout: 10000 })
|
||||
})
|
||||
|
||||
test('should work on tablet viewport', async ({ page }) => {
|
||||
@@ -59,7 +62,7 @@ test.describe('CodeForge - Responsive Design', () => {
|
||||
await page.setViewportSize({ width: 768, height: 1024 })
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 10000 })
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 })
|
||||
|
||||
await expect(page.locator('body')).toBeVisible()
|
||||
|
||||
await page.waitForSelector('#root > *', { timeout: 10000 })
|
||||
})
|
||||
})
|
||||
|
||||
41
e2e/debug.spec.ts
Normal file
41
e2e/debug.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { test } from '@playwright/test'
|
||||
|
||||
test('debug page load', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
const pageErrors: Error[] = []
|
||||
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
errors.push(msg.text())
|
||||
}
|
||||
})
|
||||
|
||||
page.on('pageerror', (error) => {
|
||||
pageErrors.push(error)
|
||||
})
|
||||
|
||||
await page.goto('/', { waitUntil: 'networkidle', timeout: 15000 })
|
||||
|
||||
// Wait a bit
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Get page content
|
||||
const rootHTML = await page.locator('#root').innerHTML().catch(() => 'ERROR GETTING ROOT')
|
||||
|
||||
console.log('=== PAGE ERRORS ===')
|
||||
pageErrors.forEach(err => console.log(err.message))
|
||||
|
||||
console.log('\n=== CONSOLE ERRORS ===')
|
||||
errors.forEach(err => console.log(err))
|
||||
|
||||
console.log('\n=== ROOT CONTENT ===')
|
||||
console.log(rootHTML.substring(0, 500))
|
||||
|
||||
console.log('\n=== ROOT VISIBLE ===')
|
||||
const rootVisible = await page.locator('#root').isVisible().catch(() => false)
|
||||
console.log('Root visible:', rootVisible)
|
||||
|
||||
console.log('\n=== ROOT HAS CHILDREN ===')
|
||||
const childCount = await page.locator('#root > *').count()
|
||||
console.log('Child count:', childCount)
|
||||
})
|
||||
@@ -4,8 +4,12 @@ test.describe('CodeForge - Smoke Tests', () => {
|
||||
test('app loads successfully', async ({ page }) => {
|
||||
test.setTimeout(20000)
|
||||
await page.goto('/', { waitUntil: 'networkidle', timeout: 15000 })
|
||||
|
||||
await expect(page.locator('body')).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Check that the app has rendered content (more reliable than checking visibility)
|
||||
const root = page.locator('#root')
|
||||
await expect(root).toHaveCount(1, { timeout: 5000 })
|
||||
// Wait for any content to be rendered
|
||||
await page.waitForSelector('#root > *', { timeout: 10000 })
|
||||
})
|
||||
|
||||
test('can navigate to dashboard tab', async ({ page }) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
35
package-lock.json
generated
35
package-lock.json
generated
@@ -89,6 +89,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^17.0.0",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"terser": "^5.46.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.38.0",
|
||||
@@ -824,9 +825,10 @@
|
||||
},
|
||||
"node_modules/@jridgewell/source-map": {
|
||||
"version": "0.3.11",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
|
||||
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.25"
|
||||
@@ -4768,9 +4770,10 @@
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
@@ -6989,9 +6992,10 @@
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -7005,9 +7009,10 @@
|
||||
},
|
||||
"node_modules/source-map-support": {
|
||||
"version": "0.5.21",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"source-map": "^0.6.0"
|
||||
@@ -7075,9 +7080,10 @@
|
||||
},
|
||||
"node_modules/terser": {
|
||||
"version": "5.46.0",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz",
|
||||
"integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.15.0",
|
||||
@@ -7093,9 +7099,10 @@
|
||||
},
|
||||
"node_modules/terser/node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.175.0",
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
"components:list": "node scripts/list-json-components.cjs",
|
||||
"components:generate-types": "tsx scripts/generate-json-ui-component-types.ts",
|
||||
"components:scan": "node scripts/scan-and-update-registry.cjs",
|
||||
"components:validate": "node scripts/validate-supported-components.cjs && tsx scripts/validate-json-registry.ts"
|
||||
"components:validate": "node scripts/validate-supported-components.cjs && tsx scripts/validate-json-registry.ts",
|
||||
"audit:json": "tsx scripts/audit-json-components.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
@@ -111,6 +112,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^17.0.0",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"terser": "^5.46.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.38.0",
|
||||
|
||||
@@ -29,7 +29,7 @@ export default defineConfig({
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:5000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
reuseExistingServer: true,
|
||||
timeout: 120000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
|
||||
190
scripts/analyze-duplicates.ts
Normal file
190
scripts/analyze-duplicates.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Analyze duplicate TSX files before deletion
|
||||
* Check JSON contents to ensure they're complete
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { globSync } from 'fs'
|
||||
|
||||
const ROOT_DIR = path.resolve(process.cwd())
|
||||
const CONFIG_PAGES_DIR = path.join(ROOT_DIR, 'src/config/pages')
|
||||
const COMPONENTS_DIR = path.join(ROOT_DIR, 'src/components')
|
||||
const JSON_DEFS_DIR = path.join(ROOT_DIR, 'src/components/json-definitions')
|
||||
|
||||
function toKebabCase(str: string): string {
|
||||
return str.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '')
|
||||
}
|
||||
|
||||
interface AnalysisResult {
|
||||
tsx: string
|
||||
json: string
|
||||
tsxSize: number
|
||||
jsonSize: number
|
||||
tsxHasHooks: boolean
|
||||
tsxHasState: boolean
|
||||
tsxHasEffects: boolean
|
||||
jsonHasBindings: boolean
|
||||
jsonHasChildren: boolean
|
||||
recommendation: 'safe-to-delete' | 'needs-review' | 'keep-tsx'
|
||||
reason: string
|
||||
}
|
||||
|
||||
async function analyzeTsxFile(filePath: string): Promise<{
|
||||
hasHooks: boolean
|
||||
hasState: boolean
|
||||
hasEffects: boolean
|
||||
}> {
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
|
||||
return {
|
||||
hasHooks: /use[A-Z]/.test(content),
|
||||
hasState: /useState|useReducer/.test(content),
|
||||
hasEffects: /useEffect|useLayoutEffect/.test(content)
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeJsonFile(filePath: string): Promise<{
|
||||
hasBindings: boolean
|
||||
hasChildren: boolean
|
||||
size: number
|
||||
}> {
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
const json = JSON.parse(content)
|
||||
|
||||
return {
|
||||
hasBindings: !!json.bindings || hasNestedBindings(json),
|
||||
hasChildren: !!json.children,
|
||||
size: content.length
|
||||
}
|
||||
}
|
||||
|
||||
function hasNestedBindings(obj: any): boolean {
|
||||
if (!obj || typeof obj !== 'object') return false
|
||||
if (obj.bindings) return true
|
||||
|
||||
for (const key in obj) {
|
||||
if (hasNestedBindings(obj[key])) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function analyzeDuplicates() {
|
||||
console.log('🔍 Analyzing duplicate TSX files...\n')
|
||||
|
||||
const results: AnalysisResult[] = []
|
||||
|
||||
// Find all TSX files in atoms, molecules, organisms
|
||||
const categories = ['atoms', 'molecules', 'organisms']
|
||||
|
||||
for (const category of categories) {
|
||||
const tsxFiles = globSync(path.join(COMPONENTS_DIR, category, '*.tsx'))
|
||||
|
||||
for (const tsxFile of tsxFiles) {
|
||||
const basename = path.basename(tsxFile, '.tsx')
|
||||
const kebab = toKebabCase(basename)
|
||||
|
||||
// Check for JSON equivalent in config/pages
|
||||
const jsonPath = path.join(CONFIG_PAGES_DIR, category, `${kebab}.json`)
|
||||
|
||||
if (!fs.existsSync(jsonPath)) continue
|
||||
|
||||
// Check for JSON definition
|
||||
const jsonDefPath = path.join(JSON_DEFS_DIR, `${kebab}.json`)
|
||||
|
||||
// Analyze both files
|
||||
const tsxAnalysis = await analyzeTsxFile(tsxFile)
|
||||
const tsxSize = fs.statSync(tsxFile).size
|
||||
|
||||
let jsonAnalysis = { hasBindings: false, hasChildren: false, size: 0 }
|
||||
let actualJsonPath = jsonPath
|
||||
|
||||
if (fs.existsSync(jsonDefPath)) {
|
||||
jsonAnalysis = await analyzeJsonFile(jsonDefPath)
|
||||
actualJsonPath = jsonDefPath
|
||||
} else if (fs.existsSync(jsonPath)) {
|
||||
jsonAnalysis = await analyzeJsonFile(jsonPath)
|
||||
}
|
||||
|
||||
// Determine recommendation
|
||||
let recommendation: AnalysisResult['recommendation'] = 'safe-to-delete'
|
||||
let reason = 'JSON definition exists'
|
||||
|
||||
if (tsxAnalysis.hasState || tsxAnalysis.hasEffects) {
|
||||
if (!jsonAnalysis.hasBindings && jsonAnalysis.size < 500) {
|
||||
recommendation = 'needs-review'
|
||||
reason = 'TSX has state/effects but JSON seems incomplete'
|
||||
} else {
|
||||
recommendation = 'safe-to-delete'
|
||||
reason = 'TSX has hooks but JSON should handle via createJsonComponentWithHooks'
|
||||
}
|
||||
}
|
||||
|
||||
if (tsxSize > 5000 && jsonAnalysis.size < 1000) {
|
||||
recommendation = 'needs-review'
|
||||
reason = 'TSX is large but JSON is small - might be missing content'
|
||||
}
|
||||
|
||||
results.push({
|
||||
tsx: path.relative(ROOT_DIR, tsxFile),
|
||||
json: path.relative(ROOT_DIR, actualJsonPath),
|
||||
tsxSize,
|
||||
jsonSize: jsonAnalysis.size,
|
||||
tsxHasHooks: tsxAnalysis.hasHooks,
|
||||
tsxHasState: tsxAnalysis.hasState,
|
||||
tsxHasEffects: tsxAnalysis.hasEffects,
|
||||
jsonHasBindings: jsonAnalysis.hasBindings,
|
||||
jsonHasChildren: jsonAnalysis.hasChildren,
|
||||
recommendation,
|
||||
reason
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Print results
|
||||
console.log(`📊 Found ${results.length} duplicate components\n`)
|
||||
|
||||
const safeToDelete = results.filter(r => r.recommendation === 'safe-to-delete')
|
||||
const needsReview = results.filter(r => r.recommendation === 'needs-review')
|
||||
const keepTsx = results.filter(r => r.recommendation === 'keep-tsx')
|
||||
|
||||
console.log(`✅ Safe to delete: ${safeToDelete.length}`)
|
||||
console.log(`⚠️ Needs review: ${needsReview.length}`)
|
||||
console.log(`🔴 Keep TSX: ${keepTsx.length}\n`)
|
||||
|
||||
if (needsReview.length > 0) {
|
||||
console.log('⚠️ NEEDS REVIEW:')
|
||||
console.log('='.repeat(80))
|
||||
for (const result of needsReview.slice(0, 10)) {
|
||||
console.log(`\n${result.tsx}`)
|
||||
console.log(` → ${result.json}`)
|
||||
console.log(` TSX: ${result.tsxSize} bytes | JSON: ${result.jsonSize} bytes`)
|
||||
console.log(` TSX hooks: ${result.tsxHasHooks} | state: ${result.tsxHasState} | effects: ${result.tsxHasEffects}`)
|
||||
console.log(` JSON bindings: ${result.jsonHasBindings} | children: ${result.jsonHasChildren}`)
|
||||
console.log(` Reason: ${result.reason}`)
|
||||
}
|
||||
if (needsReview.length > 10) {
|
||||
console.log(`\n... and ${needsReview.length - 10} more`)
|
||||
}
|
||||
}
|
||||
|
||||
// Write full report
|
||||
const reportPath = path.join(ROOT_DIR, 'duplicate-analysis.json')
|
||||
fs.writeFileSync(reportPath, JSON.stringify(results, null, 2))
|
||||
console.log(`\n📄 Full report written to: ${reportPath}`)
|
||||
|
||||
// Generate deletion script for safe components
|
||||
if (safeToDelete.length > 0) {
|
||||
const deletionScript = safeToDelete.map(r => `rm "${r.tsx}"`).join('\n')
|
||||
const scriptPath = path.join(ROOT_DIR, 'delete-duplicates.sh')
|
||||
fs.writeFileSync(scriptPath, deletionScript)
|
||||
console.log(`📝 Deletion script written to: ${scriptPath}`)
|
||||
console.log(` Run: bash delete-duplicates.sh`)
|
||||
}
|
||||
}
|
||||
|
||||
analyzeDuplicates().catch(error => {
|
||||
console.error('❌ Analysis failed:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
75
scripts/analyze-pure-json-candidates.ts
Normal file
75
scripts/analyze-pure-json-candidates.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
|
||||
const componentsToAnalyze = {
|
||||
molecules: ['DataSourceCard', 'EditorToolbar', 'EmptyEditorState', 'MonacoEditorPanel', 'SearchBar'],
|
||||
organisms: ['EmptyCanvasState', 'PageHeader', 'SchemaEditorCanvas', 'SchemaEditorPropertiesPanel',
|
||||
'SchemaEditorSidebar', 'SchemaEditorStatusBar', 'SchemaEditorToolbar', 'ToolbarActions'],
|
||||
}
|
||||
|
||||
async function analyzeComponent(category: string, component: string): Promise<void> {
|
||||
const tsFile = path.join(rootDir, `src/components/${category}/${component}.tsx`)
|
||||
const content = await fs.readFile(tsFile, 'utf-8')
|
||||
|
||||
// Check if it's pure composition (only uses UI primitives)
|
||||
const hasBusinessLogic = /useState|useEffect|useCallback|useMemo|useReducer|useRef/.test(content)
|
||||
const hasComplexLogic = /if\s*\(.*\{|switch\s*\(|for\s*\(|while\s*\(/.test(content)
|
||||
|
||||
// Extract what it imports
|
||||
const imports = content.match(/import\s+\{[^}]+\}\s+from\s+['"][^'"]+['"]/g) || []
|
||||
const importedComponents = imports.flatMap(imp => {
|
||||
const match = imp.match(/\{([^}]+)\}/)
|
||||
return match ? match[1].split(',').map(s => s.trim()) : []
|
||||
})
|
||||
|
||||
// Check if it only imports from ui/atoms (pure composition)
|
||||
const onlyUIPrimitives = imports.every(imp =>
|
||||
imp.includes('@/components/ui/') ||
|
||||
imp.includes('@/components/atoms/') ||
|
||||
imp.includes('@/lib/utils') ||
|
||||
imp.includes('lucide-react') ||
|
||||
imp.includes('@phosphor-icons')
|
||||
)
|
||||
|
||||
const lineCount = content.split('\n').length
|
||||
|
||||
console.log(`\n📄 ${component}`)
|
||||
console.log(` Lines: ${lineCount}`)
|
||||
console.log(` Has hooks: ${hasBusinessLogic ? '❌' : '✅'}`)
|
||||
console.log(` Has complex logic: ${hasComplexLogic ? '❌' : '✅'}`)
|
||||
console.log(` Only UI primitives: ${onlyUIPrimitives ? '✅' : '❌'}`)
|
||||
console.log(` Imports: ${importedComponents.slice(0, 5).join(', ')}${importedComponents.length > 5 ? '...' : ''}`)
|
||||
|
||||
if (!hasBusinessLogic && onlyUIPrimitives && lineCount < 100) {
|
||||
console.log(` 🎯 CANDIDATE FOR PURE JSON`)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🔍 Analyzing components for pure JSON conversion...\n')
|
||||
console.log('Looking for components that:')
|
||||
console.log(' - No hooks (useState, useEffect, etc.)')
|
||||
console.log(' - No complex logic')
|
||||
console.log(' - Only import UI primitives')
|
||||
console.log(' - Are simple compositions\n')
|
||||
|
||||
for (const [category, components] of Object.entries(componentsToAnalyze)) {
|
||||
console.log(`\n═══ ${category.toUpperCase()} ═══`)
|
||||
for (const component of components) {
|
||||
try {
|
||||
await analyzeComponent(category, component)
|
||||
} catch (e) {
|
||||
console.log(`\n📄 ${component}`)
|
||||
console.log(` ⚠️ Could not analyze: ${e}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n\n✨ Analysis complete!')
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
302
scripts/audit-json-components.ts
Normal file
302
scripts/audit-json-components.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Audit script for JSON component definitions
|
||||
*
|
||||
* Goals:
|
||||
* 1. Phase out src/components TSX files
|
||||
* 2. Audit existing JSON definitions for completeness and correctness
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { globSync } from 'fs'
|
||||
|
||||
interface AuditIssue {
|
||||
severity: 'error' | 'warning' | 'info'
|
||||
category: string
|
||||
file?: string
|
||||
message: string
|
||||
suggestion?: string
|
||||
}
|
||||
|
||||
interface AuditReport {
|
||||
timestamp: string
|
||||
issues: AuditIssue[]
|
||||
stats: {
|
||||
totalJsonFiles: number
|
||||
totalTsxFiles: number
|
||||
registryEntries: number
|
||||
orphanedJson: number
|
||||
duplicates: number
|
||||
obsoleteWrapperRefs: number
|
||||
}
|
||||
}
|
||||
|
||||
const ROOT_DIR = path.resolve(process.cwd())
|
||||
const CONFIG_PAGES_DIR = path.join(ROOT_DIR, 'src/config/pages')
|
||||
const COMPONENTS_DIR = path.join(ROOT_DIR, 'src/components')
|
||||
const JSON_DEFS_DIR = path.join(ROOT_DIR, 'src/components/json-definitions')
|
||||
const REGISTRY_FILE = path.join(ROOT_DIR, 'json-components-registry.json')
|
||||
|
||||
async function loadRegistry(): Promise<any> {
|
||||
const content = fs.readFileSync(REGISTRY_FILE, 'utf-8')
|
||||
return JSON.parse(content)
|
||||
}
|
||||
|
||||
function findAllFiles(pattern: string, cwd: string = ROOT_DIR): string[] {
|
||||
const fullPattern = path.join(cwd, pattern)
|
||||
return globSync(fullPattern, { ignore: '**/node_modules/**' })
|
||||
}
|
||||
|
||||
function toKebabCase(str: string): string {
|
||||
return str.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '')
|
||||
}
|
||||
|
||||
function toPascalCase(str: string): string {
|
||||
return str
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join('')
|
||||
}
|
||||
|
||||
async function auditJsonComponents(): Promise<AuditReport> {
|
||||
const issues: AuditIssue[] = []
|
||||
const registry = await loadRegistry()
|
||||
|
||||
// Find all files
|
||||
const jsonFiles = findAllFiles('src/config/pages/**/*.json')
|
||||
const tsxFiles = findAllFiles('src/components/**/*.tsx')
|
||||
const jsonDefFiles = findAllFiles('src/components/json-definitions/*.json')
|
||||
|
||||
console.log(`📊 Found ${jsonFiles.length} JSON files in config/pages`)
|
||||
console.log(`📊 Found ${tsxFiles.length} TSX files in src/components`)
|
||||
console.log(`📊 Found ${jsonDefFiles.length} JSON definitions`)
|
||||
console.log(`📊 Found ${registry.components?.length || 0} registry entries\n`)
|
||||
|
||||
// Build registry lookup maps
|
||||
const registryByType = new Map<string, any>()
|
||||
const registryByName = new Map<string, any>()
|
||||
|
||||
if (registry.components) {
|
||||
for (const component of registry.components) {
|
||||
if (component.type) registryByType.set(component.type, component)
|
||||
if (component.name) registryByName.set(component.name, component)
|
||||
}
|
||||
}
|
||||
|
||||
// Check 1: Find TSX files that have JSON equivalents in config/pages
|
||||
console.log('🔍 Checking for TSX files that could be replaced with JSON...')
|
||||
const tsxBasenames = new Set<string>()
|
||||
for (const tsxFile of tsxFiles) {
|
||||
const basename = path.basename(tsxFile, '.tsx')
|
||||
const dir = path.dirname(tsxFile)
|
||||
const category = path.basename(dir) // atoms, molecules, organisms
|
||||
|
||||
if (!['atoms', 'molecules', 'organisms'].includes(category)) continue
|
||||
|
||||
tsxBasenames.add(basename)
|
||||
const kebab = toKebabCase(basename)
|
||||
|
||||
// Check if there's a corresponding JSON file in config/pages
|
||||
const possibleJsonPath = path.join(CONFIG_PAGES_DIR, category, `${kebab}.json`)
|
||||
|
||||
if (fs.existsSync(possibleJsonPath)) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
category: 'duplicate-implementation',
|
||||
file: tsxFile,
|
||||
message: `TSX file has JSON equivalent at ${path.relative(ROOT_DIR, possibleJsonPath)}`,
|
||||
suggestion: `Consider removing TSX and routing through JSON renderer`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check 2: Find JSON files without registry entries
|
||||
console.log('🔍 Checking for orphaned JSON files...')
|
||||
for (const jsonFile of jsonFiles) {
|
||||
const content = JSON.parse(fs.readFileSync(jsonFile, 'utf-8'))
|
||||
const componentType = content.type
|
||||
|
||||
if (componentType && !registryByType.has(componentType)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
category: 'orphaned-json',
|
||||
file: jsonFile,
|
||||
message: `JSON file references type "${componentType}" which is not in registry`,
|
||||
suggestion: `Add registry entry for ${componentType} in json-components-registry.json`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check 3: Find components with obsolete wrapper references
|
||||
console.log('🔍 Checking for obsolete wrapper references...')
|
||||
for (const component of registry.components || []) {
|
||||
if (component.wrapperRequired || component.wrapperComponent) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
category: 'obsolete-wrapper-ref',
|
||||
file: `registry: ${component.type}`,
|
||||
message: `Component "${component.type}" has obsolete wrapperRequired/wrapperComponent fields`,
|
||||
suggestion: `Remove wrapperRequired and wrapperComponent fields - use createJsonComponentWithHooks instead`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check 4: Find components with load.path that don't exist
|
||||
console.log('🔍 Checking for broken load paths...')
|
||||
for (const component of registry.components || []) {
|
||||
if (component.load?.path) {
|
||||
const loadPath = component.load.path.replace('@/', 'src/')
|
||||
const possibleExtensions = ['.tsx', '.ts', '.jsx', '.js']
|
||||
let found = false
|
||||
|
||||
for (const ext of possibleExtensions) {
|
||||
if (fs.existsSync(path.join(ROOT_DIR, loadPath + ext))) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
category: 'broken-load-path',
|
||||
file: `registry: ${component.type}`,
|
||||
message: `Component "${component.type}" has load.path "${component.load.path}" but file not found`,
|
||||
suggestion: `Fix or remove load.path in registry`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check 5: Components in src/components/molecules without JSON definitions
|
||||
console.log('🔍 Checking molecules without JSON definitions...')
|
||||
const moleculeTsxFiles = tsxFiles.filter(f => f.includes('/molecules/'))
|
||||
const jsonDefBasenames = new Set(
|
||||
jsonDefFiles.map(f => path.basename(f, '.json'))
|
||||
)
|
||||
|
||||
for (const tsxFile of moleculeTsxFiles) {
|
||||
const basename = path.basename(tsxFile, '.tsx')
|
||||
const kebab = toKebabCase(basename)
|
||||
|
||||
if (!jsonDefBasenames.has(kebab) && registryByType.has(basename)) {
|
||||
const entry = registryByType.get(basename)
|
||||
if (entry.source === 'molecules' && !entry.load?.path) {
|
||||
issues.push({
|
||||
severity: 'info',
|
||||
category: 'potential-conversion',
|
||||
file: tsxFile,
|
||||
message: `Molecule "${basename}" could potentially be converted to JSON`,
|
||||
suggestion: `Evaluate if ${basename} can be expressed as pure JSON`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stats = {
|
||||
totalJsonFiles: jsonFiles.length,
|
||||
totalTsxFiles: tsxFiles.length,
|
||||
registryEntries: registry.components?.length || 0,
|
||||
orphanedJson: issues.filter(i => i.category === 'orphaned-json').length,
|
||||
duplicates: issues.filter(i => i.category === 'duplicate-implementation').length,
|
||||
obsoleteWrapperRefs: issues.filter(i => i.category === 'obsolete-wrapper-ref').length
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
issues,
|
||||
stats
|
||||
}
|
||||
}
|
||||
|
||||
function printReport(report: AuditReport) {
|
||||
console.log('\n' + '='.repeat(80))
|
||||
console.log('📋 AUDIT REPORT')
|
||||
console.log('='.repeat(80))
|
||||
console.log(`\n📅 Generated: ${report.timestamp}\n`)
|
||||
|
||||
console.log('📈 Statistics:')
|
||||
console.log(` • Total JSON files: ${report.stats.totalJsonFiles}`)
|
||||
console.log(` • Total TSX files: ${report.stats.totalTsxFiles}`)
|
||||
console.log(` • Registry entries: ${report.stats.registryEntries}`)
|
||||
console.log(` • Orphaned JSON: ${report.stats.orphanedJson}`)
|
||||
console.log(` • Obsolete wrapper refs: ${report.stats.obsoleteWrapperRefs}`)
|
||||
console.log(` • Duplicate implementations: ${report.stats.duplicates}\n`)
|
||||
|
||||
// Group issues by category
|
||||
const byCategory = new Map<string, AuditIssue[]>()
|
||||
for (const issue of report.issues) {
|
||||
if (!byCategory.has(issue.category)) {
|
||||
byCategory.set(issue.category, [])
|
||||
}
|
||||
byCategory.get(issue.category)!.push(issue)
|
||||
}
|
||||
|
||||
// Print issues by severity
|
||||
const severityOrder = ['error', 'warning', 'info'] as const
|
||||
const severityIcons = { error: '❌', warning: '⚠️', info: 'ℹ️' }
|
||||
|
||||
for (const severity of severityOrder) {
|
||||
const issuesOfSeverity = report.issues.filter(i => i.severity === severity)
|
||||
if (issuesOfSeverity.length === 0) continue
|
||||
|
||||
console.log(`\n${severityIcons[severity]} ${severity.toUpperCase()} (${issuesOfSeverity.length})`)
|
||||
console.log('-'.repeat(80))
|
||||
|
||||
const categories = new Map<string, AuditIssue[]>()
|
||||
for (const issue of issuesOfSeverity) {
|
||||
if (!categories.has(issue.category)) {
|
||||
categories.set(issue.category, [])
|
||||
}
|
||||
categories.get(issue.category)!.push(issue)
|
||||
}
|
||||
|
||||
for (const [category, issues] of categories) {
|
||||
console.log(`\n ${category.replace(/-/g, ' ').toUpperCase()} (${issues.length}):`)
|
||||
|
||||
for (const issue of issues.slice(0, 5)) { // Show first 5 of each category
|
||||
console.log(` • ${issue.file || 'N/A'}`)
|
||||
console.log(` ${issue.message}`)
|
||||
if (issue.suggestion) {
|
||||
console.log(` 💡 ${issue.suggestion}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.length > 5) {
|
||||
console.log(` ... and ${issues.length - 5} more`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(80))
|
||||
console.log(`Total issues found: ${report.issues.length}`)
|
||||
console.log('='.repeat(80) + '\n')
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🔍 Starting JSON component audit...\n')
|
||||
|
||||
const report = await auditJsonComponents()
|
||||
|
||||
printReport(report)
|
||||
|
||||
// Write report to file
|
||||
const reportPath = path.join(ROOT_DIR, 'audit-report.json')
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2))
|
||||
console.log(`📄 Full report written to: ${reportPath}\n`)
|
||||
|
||||
// Exit with error code if there are errors
|
||||
const errorCount = report.issues.filter(i => i.severity === 'error').length
|
||||
if (errorCount > 0) {
|
||||
console.log(`❌ Audit failed with ${errorCount} errors`)
|
||||
process.exit(1)
|
||||
} else {
|
||||
console.log('✅ Audit completed successfully')
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error('❌ Audit failed:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
64
scripts/cleanup-registry.ts
Normal file
64
scripts/cleanup-registry.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Cleanup script to remove obsolete wrapper references from registry
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const REGISTRY_FILE = path.resolve(process.cwd(), 'json-components-registry.json')
|
||||
|
||||
async function cleanupRegistry() {
|
||||
console.log('🧹 Cleaning up registry...\n')
|
||||
|
||||
// Read registry
|
||||
const content = fs.readFileSync(REGISTRY_FILE, 'utf-8')
|
||||
const registry = JSON.parse(content)
|
||||
|
||||
let cleanedCount = 0
|
||||
const cleanedComponents: string[] = []
|
||||
|
||||
// Remove obsolete fields from all components
|
||||
if (registry.components) {
|
||||
for (const component of registry.components) {
|
||||
let modified = false
|
||||
|
||||
if (component.wrapperRequired !== undefined) {
|
||||
delete component.wrapperRequired
|
||||
modified = true
|
||||
}
|
||||
|
||||
if (component.wrapperComponent !== undefined) {
|
||||
delete component.wrapperComponent
|
||||
modified = true
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
cleanedCount++
|
||||
cleanedComponents.push(component.type || component.name || 'Unknown')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write back to file with proper formatting
|
||||
fs.writeFileSync(REGISTRY_FILE, JSON.stringify(registry, null, 2) + '\n')
|
||||
|
||||
console.log(`✅ Cleaned ${cleanedCount} components\n`)
|
||||
|
||||
if (cleanedComponents.length > 0) {
|
||||
console.log('📋 Cleaned components:')
|
||||
cleanedComponents.slice(0, 10).forEach(name => {
|
||||
console.log(` • ${name}`)
|
||||
})
|
||||
if (cleanedComponents.length > 10) {
|
||||
console.log(` ... and ${cleanedComponents.length - 10} more`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n✨ Registry cleanup complete!')
|
||||
}
|
||||
|
||||
cleanupRegistry().catch(error => {
|
||||
console.error('❌ Cleanup failed:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
115
scripts/cleanup-simple-components.ts
Normal file
115
scripts/cleanup-simple-components.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
|
||||
/**
|
||||
* List of simple presentational components that can be safely deleted
|
||||
* These were identified by the conversion script as having no hooks or complex logic
|
||||
*/
|
||||
const SIMPLE_COMPONENTS = {
|
||||
atoms: [
|
||||
'ActionIcon', 'Alert', 'AppLogo', 'Avatar', 'Breadcrumb', 'ButtonGroup',
|
||||
'Chip', 'Code', 'ColorSwatch', 'Container', 'DataList', 'Divider', 'Dot',
|
||||
'EmptyStateIcon', 'FileIcon', 'Flex', 'Grid', 'Heading', 'HelperText',
|
||||
'IconText', 'IconWrapper', 'InfoBox', 'InfoPanel', 'Input', 'Kbd',
|
||||
'KeyValue', 'Label', 'Link', 'List', 'ListItem', 'LiveIndicator',
|
||||
'LoadingSpinner', 'LoadingState', 'MetricDisplay', 'PageHeader', 'Pulse',
|
||||
'ResponsiveGrid', 'ScrollArea', 'SearchInput', 'Section', 'Skeleton',
|
||||
'Spacer', 'Sparkle', 'Spinner', 'StatusIcon', 'TabIcon', 'Tag', 'Text',
|
||||
'TextArea', 'TextGradient', 'TextHighlight', 'Timestamp', 'TreeIcon',
|
||||
// Additional simple ones
|
||||
'AvatarGroup', 'Checkbox', 'Drawer', 'Modal', 'Notification', 'ProgressBar',
|
||||
'Radio', 'Rating', 'Select', 'Slider', 'Stack', 'StepIndicator', 'Stepper',
|
||||
'Table', 'Tabs', 'Timeline', 'Toggle',
|
||||
],
|
||||
molecules: [
|
||||
'ActionBar', 'AppBranding', 'DataCard', 'DataSourceCard', 'EditorActions',
|
||||
'EditorToolbar', 'EmptyEditorState', 'EmptyState', 'FileTabs', 'LabelWithBadge',
|
||||
'LazyInlineMonacoEditor', 'LazyMonacoEditor', 'LoadingFallback', 'LoadingState',
|
||||
'MonacoEditorPanel', 'NavigationItem', 'PageHeaderContent', 'SearchBar',
|
||||
'StatCard', 'TreeCard', 'TreeListHeader',
|
||||
],
|
||||
organisms: [
|
||||
'EmptyCanvasState', 'PageHeader', 'SchemaEditorCanvas', 'SchemaEditorPropertiesPanel',
|
||||
'SchemaEditorSidebar', 'SchemaEditorStatusBar', 'SchemaEditorToolbar', 'ToolbarActions',
|
||||
],
|
||||
ui: [
|
||||
'aspect-ratio', 'avatar', 'badge', 'checkbox', 'collapsible', 'hover-card',
|
||||
'input', 'label', 'popover', 'progress', 'radio-group', 'resizable',
|
||||
'scroll-area', 'separator', 'skeleton', 'switch', 'textarea', 'toggle',
|
||||
// Additional ones
|
||||
'accordion', 'alert', 'button', 'card', 'tabs', 'tooltip',
|
||||
],
|
||||
}
|
||||
|
||||
interface DeletionResult {
|
||||
deleted: string[]
|
||||
kept: string[]
|
||||
failed: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete simple TypeScript components
|
||||
*/
|
||||
async function deleteSimpleComponents(): Promise<void> {
|
||||
console.log('🧹 Cleaning up simple TypeScript components...\n')
|
||||
|
||||
const results: DeletionResult = {
|
||||
deleted: [],
|
||||
kept: [],
|
||||
failed: [],
|
||||
}
|
||||
|
||||
// Process each category
|
||||
for (const [category, components] of Object.entries(SIMPLE_COMPONENTS)) {
|
||||
console.log(`📂 Processing ${category}...`)
|
||||
|
||||
const baseDir = path.join(rootDir, `src/components/${category}`)
|
||||
|
||||
for (const component of components) {
|
||||
const fileName = component.endsWith('.tsx') ? component : `${component}.tsx`
|
||||
const filePath = path.join(baseDir, fileName)
|
||||
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
await fs.unlink(filePath)
|
||||
results.deleted.push(`${category}/${fileName}`)
|
||||
console.log(` ✅ Deleted: ${fileName}`)
|
||||
} catch (error: unknown) {
|
||||
// File doesn't exist or couldn't be deleted
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
||||
results.kept.push(`${category}/${fileName}`)
|
||||
console.log(` ⏭️ Skipped: ${fileName} (not found)`)
|
||||
} else {
|
||||
results.failed.push(`${category}/${fileName}`)
|
||||
console.log(` ❌ Failed: ${fileName}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log()
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('📊 Summary:')
|
||||
console.log(` Deleted: ${results.deleted.length} files`)
|
||||
console.log(` Skipped: ${results.kept.length} files`)
|
||||
console.log(` Failed: ${results.failed.length} files`)
|
||||
|
||||
if (results.failed.length > 0) {
|
||||
console.log('\n❌ Failed deletions:')
|
||||
results.failed.forEach(f => console.log(` - ${f}`))
|
||||
}
|
||||
|
||||
console.log('\n✨ Cleanup complete!')
|
||||
console.log('\n📝 Next steps:')
|
||||
console.log(' 1. Update index.ts files to remove deleted exports')
|
||||
console.log(' 2. Search for direct imports of deleted components')
|
||||
console.log(' 3. Run build to check for errors')
|
||||
console.log(' 4. Run tests to verify functionality')
|
||||
}
|
||||
|
||||
deleteSimpleComponents().catch(console.error)
|
||||
262
scripts/convert-tsx-to-json.ts
Normal file
262
scripts/convert-tsx-to-json.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
|
||||
interface ConversionConfig {
|
||||
sourceDir: string
|
||||
targetDir: string
|
||||
category: 'atoms' | 'molecules' | 'organisms' | 'ui'
|
||||
}
|
||||
|
||||
interface ComponentAnalysis {
|
||||
name: string
|
||||
hasHooks: boolean
|
||||
hasComplexLogic: boolean
|
||||
wrapsUIComponent: boolean
|
||||
uiComponentName?: string
|
||||
defaultProps: Record<string, unknown>
|
||||
isSimplePresentational: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a TypeScript component file to determine conversion strategy
|
||||
*/
|
||||
async function analyzeComponent(filePath: string): Promise<ComponentAnalysis> {
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
const fileName = path.basename(filePath, '.tsx')
|
||||
|
||||
// Check for hooks
|
||||
const hasHooks = /use[A-Z]\w+\(/.test(content) ||
|
||||
/useState|useEffect|useCallback|useMemo|useRef|useReducer/.test(content)
|
||||
|
||||
// Check for complex logic
|
||||
const hasComplexLogic = hasHooks ||
|
||||
/switch\s*\(/.test(content) ||
|
||||
/for\s*\(/.test(content) ||
|
||||
/while\s*\(/.test(content) ||
|
||||
content.split('\n').length > 100
|
||||
|
||||
// Check if it wraps a shadcn/ui component
|
||||
const uiImportMatch = content.match(/import\s+\{([^}]+)\}\s+from\s+['"]@\/components\/ui\//)
|
||||
const wrapsUIComponent = !!uiImportMatch
|
||||
const uiComponentName = wrapsUIComponent ? uiImportMatch?.[1].trim() : undefined
|
||||
|
||||
// Extract default props from interface
|
||||
const defaultProps: Record<string, unknown> = {}
|
||||
const propDefaults = content.matchAll(/(\w+)\s*[?]?\s*:\s*([^=\n]+)\s*=\s*['"]?([^'";\n,}]+)['"]?/g)
|
||||
for (const match of propDefaults) {
|
||||
const [, propName, , defaultValue] = match
|
||||
if (propName && defaultValue) {
|
||||
defaultProps[propName] = defaultValue.replace(/['"]/g, '')
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if it's simple presentational
|
||||
const isSimplePresentational = !hasComplexLogic &&
|
||||
!hasHooks &&
|
||||
content.split('\n').length < 60
|
||||
|
||||
return {
|
||||
name: fileName,
|
||||
hasHooks,
|
||||
hasComplexLogic,
|
||||
wrapsUIComponent,
|
||||
uiComponentName,
|
||||
defaultProps,
|
||||
isSimplePresentational,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JSON definition for a component based on analysis
|
||||
*/
|
||||
function generateJSON(analysis: ComponentAnalysis, category: string): object {
|
||||
// If it wraps a UI component, reference that
|
||||
if (analysis.wrapsUIComponent && analysis.uiComponentName) {
|
||||
return {
|
||||
type: analysis.uiComponentName,
|
||||
props: analysis.defaultProps,
|
||||
}
|
||||
}
|
||||
|
||||
// If it's simple presentational, create a basic structure
|
||||
if (analysis.isSimplePresentational) {
|
||||
return {
|
||||
type: analysis.name,
|
||||
props: analysis.defaultProps,
|
||||
}
|
||||
}
|
||||
|
||||
// If it has hooks or complex logic, mark as needing wrapper
|
||||
if (analysis.hasHooks || analysis.hasComplexLogic) {
|
||||
return {
|
||||
type: analysis.name,
|
||||
jsonCompatible: false,
|
||||
wrapperRequired: true,
|
||||
load: {
|
||||
path: `@/components/${category}/${analysis.name}`,
|
||||
export: analysis.name,
|
||||
},
|
||||
props: analysis.defaultProps,
|
||||
metadata: {
|
||||
notes: analysis.hasHooks ? 'Contains hooks - needs wrapper' : 'Complex logic - needs wrapper',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Default case
|
||||
return {
|
||||
type: analysis.name,
|
||||
props: analysis.defaultProps,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a single TypeScript file to JSON
|
||||
*/
|
||||
async function convertFile(
|
||||
sourceFile: string,
|
||||
targetDir: string,
|
||||
category: string
|
||||
): Promise<{ success: boolean; analysis: ComponentAnalysis }> {
|
||||
try {
|
||||
const analysis = await analyzeComponent(sourceFile)
|
||||
const json = generateJSON(analysis, category)
|
||||
|
||||
// Generate kebab-case filename
|
||||
const jsonFileName = analysis.name
|
||||
.replace(/([A-Z])/g, '-$1')
|
||||
.toLowerCase()
|
||||
.replace(/^-/, '') + '.json'
|
||||
|
||||
const targetFile = path.join(targetDir, jsonFileName)
|
||||
|
||||
await fs.writeFile(targetFile, JSON.stringify(json, null, 2) + '\n')
|
||||
|
||||
return { success: true, analysis }
|
||||
} catch (error) {
|
||||
console.error(`Error converting ${sourceFile}:`, error)
|
||||
return {
|
||||
success: false,
|
||||
analysis: {
|
||||
name: path.basename(sourceFile, '.tsx'),
|
||||
hasHooks: false,
|
||||
hasComplexLogic: false,
|
||||
wrapsUIComponent: false,
|
||||
defaultProps: {},
|
||||
isSimplePresentational: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert all components in a directory
|
||||
*/
|
||||
async function convertDirectory(config: ConversionConfig): Promise<void> {
|
||||
const sourceDir = path.join(rootDir, config.sourceDir)
|
||||
const targetDir = path.join(rootDir, config.targetDir)
|
||||
|
||||
console.log(`\n📂 Converting ${config.category} components...`)
|
||||
console.log(` Source: ${sourceDir}`)
|
||||
console.log(` Target: ${targetDir}`)
|
||||
|
||||
// Ensure target directory exists
|
||||
await fs.mkdir(targetDir, { recursive: true })
|
||||
|
||||
// Get all TypeScript files
|
||||
const files = await fs.readdir(sourceDir)
|
||||
const tsxFiles = files.filter(f => f.endsWith('.tsx') && !f.includes('.test.') && !f.includes('.stories.'))
|
||||
|
||||
console.log(` Found ${tsxFiles.length} TypeScript files\n`)
|
||||
|
||||
const results = {
|
||||
total: 0,
|
||||
simple: 0,
|
||||
needsWrapper: 0,
|
||||
wrapsUI: 0,
|
||||
failed: 0,
|
||||
}
|
||||
|
||||
// Convert each file
|
||||
for (const file of tsxFiles) {
|
||||
const sourceFile = path.join(sourceDir, file)
|
||||
const { success, analysis } = await convertFile(sourceFile, targetDir, config.category)
|
||||
|
||||
results.total++
|
||||
|
||||
if (!success) {
|
||||
results.failed++
|
||||
console.log(` ❌ ${file}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (analysis.wrapsUIComponent) {
|
||||
results.wrapsUI++
|
||||
console.log(` 🎨 ${file} → ${analysis.name} (wraps UI)`)
|
||||
} else if (analysis.isSimplePresentational) {
|
||||
results.simple++
|
||||
console.log(` ✅ ${file} → ${analysis.name} (simple)`)
|
||||
} else if (analysis.hasHooks || analysis.hasComplexLogic) {
|
||||
results.needsWrapper++
|
||||
console.log(` ⚙️ ${file} → ${analysis.name} (needs wrapper)`)
|
||||
} else {
|
||||
results.simple++
|
||||
console.log(` ✅ ${file} → ${analysis.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n📊 Results for ${config.category}:`)
|
||||
console.log(` Total: ${results.total}`)
|
||||
console.log(` Simple: ${results.simple}`)
|
||||
console.log(` Wraps UI: ${results.wrapsUI}`)
|
||||
console.log(` Needs Wrapper: ${results.needsWrapper}`)
|
||||
console.log(` Failed: ${results.failed}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Main conversion process
|
||||
*/
|
||||
async function main() {
|
||||
console.log('🚀 Starting TypeScript to JSON conversion...\n')
|
||||
|
||||
const configs: ConversionConfig[] = [
|
||||
{
|
||||
sourceDir: 'src/components/atoms',
|
||||
targetDir: 'src/config/pages/atoms',
|
||||
category: 'atoms',
|
||||
},
|
||||
{
|
||||
sourceDir: 'src/components/molecules',
|
||||
targetDir: 'src/config/pages/molecules',
|
||||
category: 'molecules',
|
||||
},
|
||||
{
|
||||
sourceDir: 'src/components/organisms',
|
||||
targetDir: 'src/config/pages/organisms',
|
||||
category: 'organisms',
|
||||
},
|
||||
{
|
||||
sourceDir: 'src/components/ui',
|
||||
targetDir: 'src/config/pages/ui',
|
||||
category: 'ui',
|
||||
},
|
||||
]
|
||||
|
||||
for (const config of configs) {
|
||||
await convertDirectory(config)
|
||||
}
|
||||
|
||||
console.log('\n✨ Conversion complete!')
|
||||
console.log('\n📝 Next steps:')
|
||||
console.log(' 1. Review generated JSON files')
|
||||
console.log(' 2. Manually fix complex components')
|
||||
console.log(' 3. Update json-components-registry.json')
|
||||
console.log(' 4. Test components render correctly')
|
||||
console.log(' 5. Delete old TypeScript files')
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
91
scripts/create-missing-component-jsons.ts
Normal file
91
scripts/create-missing-component-jsons.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
|
||||
const missingComponents = [
|
||||
'AtomicLibraryShowcase',
|
||||
'CodeEditor',
|
||||
'ComponentTreeBuilder',
|
||||
'ComponentTreeManager',
|
||||
'ConflictResolutionPage',
|
||||
'DockerBuildDebugger',
|
||||
'DocumentationView',
|
||||
'ErrorPanel',
|
||||
'FaviconDesigner',
|
||||
'FeatureIdeaCloud',
|
||||
'FeatureToggleSettings',
|
||||
'JSONComponentTreeManager',
|
||||
'JSONLambdaDesigner',
|
||||
'JSONModelDesigner',
|
||||
'PersistenceDashboard',
|
||||
'PersistenceExample',
|
||||
'ProjectDashboard',
|
||||
'PWASettings',
|
||||
'SassStylesShowcase',
|
||||
'StyleDesigner',
|
||||
]
|
||||
|
||||
async function createComponentJSON(componentName: string) {
|
||||
// Convert to kebab-case for filename
|
||||
const fileName = componentName
|
||||
.replace(/([A-Z])/g, '-$1')
|
||||
.toLowerCase()
|
||||
.replace(/^-/, '') + '.json'
|
||||
|
||||
const filePath = path.join(rootDir, 'src/config/pages/components', fileName)
|
||||
|
||||
// Check if component file exists
|
||||
const possiblePaths = [
|
||||
path.join(rootDir, `src/components/${componentName}.tsx`),
|
||||
path.join(rootDir, `src/components/${componentName}/index.tsx`),
|
||||
]
|
||||
|
||||
let componentPath = ''
|
||||
for (const p of possiblePaths) {
|
||||
try {
|
||||
await fs.access(p)
|
||||
componentPath = `@/components/${componentName}`
|
||||
break
|
||||
} catch {
|
||||
// Continue searching
|
||||
}
|
||||
}
|
||||
|
||||
if (!componentPath) {
|
||||
console.log(` ⚠️ ${componentName} - Component file not found, creating placeholder`)
|
||||
componentPath = `@/components/${componentName}`
|
||||
}
|
||||
|
||||
const json = {
|
||||
type: componentName,
|
||||
jsonCompatible: false,
|
||||
wrapperRequired: true,
|
||||
load: {
|
||||
path: componentPath,
|
||||
export: componentName,
|
||||
},
|
||||
props: {},
|
||||
}
|
||||
|
||||
await fs.writeFile(filePath, JSON.stringify(json, null, 2) + '\n')
|
||||
console.log(` ✅ Created: ${fileName}`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('📝 Creating JSON definitions for missing custom components...\n')
|
||||
|
||||
// Ensure directory exists
|
||||
const targetDir = path.join(rootDir, 'src/config/pages/components')
|
||||
await fs.mkdir(targetDir, { recursive: true })
|
||||
|
||||
for (const component of missingComponents) {
|
||||
await createComponentJSON(component)
|
||||
}
|
||||
|
||||
console.log(`\n✨ Created ${missingComponents.length} component JSON files!`)
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
141
scripts/find-component-imports.ts
Normal file
141
scripts/find-component-imports.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
|
||||
// Components we want to remove (restored dependencies)
|
||||
const targetComponents = {
|
||||
ui: ['accordion', 'alert', 'aspect-ratio', 'avatar', 'badge', 'button', 'card',
|
||||
'checkbox', 'collapsible', 'dialog', 'hover-card', 'input', 'label',
|
||||
'popover', 'progress', 'radio-group', 'resizable', 'scroll-area',
|
||||
'separator', 'skeleton', 'sheet', 'switch', 'tabs', 'textarea', 'toggle', 'tooltip'],
|
||||
molecules: ['DataSourceCard', 'EditorToolbar', 'EmptyEditorState', 'MonacoEditorPanel', 'SearchBar'],
|
||||
organisms: ['EmptyCanvasState', 'PageHeader', 'SchemaEditorCanvas', 'SchemaEditorPropertiesPanel',
|
||||
'SchemaEditorSidebar', 'SchemaEditorStatusBar', 'SchemaEditorToolbar', 'ToolbarActions'],
|
||||
atoms: ['Input']
|
||||
}
|
||||
|
||||
interface ImportInfo {
|
||||
file: string
|
||||
line: number
|
||||
importStatement: string
|
||||
importedComponents: string[]
|
||||
fromPath: string
|
||||
}
|
||||
|
||||
async function findAllImports(): Promise<ImportInfo[]> {
|
||||
const imports: ImportInfo[] = []
|
||||
|
||||
const searchDirs = [
|
||||
'src/components',
|
||||
'src/pages',
|
||||
'src/lib',
|
||||
'src'
|
||||
]
|
||||
|
||||
for (const dir of searchDirs) {
|
||||
const dirPath = path.join(rootDir, dir)
|
||||
try {
|
||||
await processDirectory(dirPath, imports)
|
||||
} catch (e) {
|
||||
// Directory might not exist, skip
|
||||
}
|
||||
}
|
||||
|
||||
return imports
|
||||
}
|
||||
|
||||
async function processDirectory(dir: string, imports: ImportInfo[]): Promise<void> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
|
||||
if (entry.isDirectory() && !entry.name.includes('node_modules')) {
|
||||
await processDirectory(fullPath, imports)
|
||||
} else if (entry.isFile() && (entry.name.endsWith('.tsx') || entry.name.endsWith('.ts'))) {
|
||||
await processFile(fullPath, imports)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function processFile(filePath: string, imports: ImportInfo[]): Promise<void> {
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
const lines = content.split('\n')
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
|
||||
// Check for imports from our target components
|
||||
for (const [category, components] of Object.entries(targetComponents)) {
|
||||
for (const component of components) {
|
||||
const patterns = [
|
||||
`from ['"]@/components/${category}/${component}['"]`,
|
||||
`from ['"]./${component}['"]`,
|
||||
`from ['"]../${component}['"]`,
|
||||
]
|
||||
|
||||
for (const pattern of patterns) {
|
||||
if (new RegExp(pattern).test(line)) {
|
||||
// Extract imported components
|
||||
const importMatch = line.match(/import\s+(?:\{([^}]+)\}|(\w+))\s+from/)
|
||||
const importedComponents = importMatch
|
||||
? (importMatch[1] || importMatch[2]).split(',').map(s => s.trim())
|
||||
: []
|
||||
|
||||
imports.push({
|
||||
file: filePath.replace(rootDir, '').replace(/\\/g, '/'),
|
||||
line: i + 1,
|
||||
importStatement: line.trim(),
|
||||
importedComponents,
|
||||
fromPath: component
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🔍 Finding all imports of target components...\n')
|
||||
|
||||
const imports = await findAllImports()
|
||||
|
||||
if (imports.length === 0) {
|
||||
console.log('✅ No imports found! Components can be safely deleted.')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`❌ Found ${imports.length} imports that need refactoring:\n`)
|
||||
|
||||
const byFile: Record<string, ImportInfo[]> = {}
|
||||
for (const imp of imports) {
|
||||
if (!byFile[imp.file]) byFile[imp.file] = []
|
||||
byFile[imp.file].push(imp)
|
||||
}
|
||||
|
||||
for (const [file, fileImports] of Object.entries(byFile)) {
|
||||
console.log(`📄 ${file}`)
|
||||
for (const imp of fileImports) {
|
||||
console.log(` Line ${imp.line}: ${imp.importStatement}`)
|
||||
console.log(` → Imports: ${imp.importedComponents.join(', ')}`)
|
||||
}
|
||||
console.log()
|
||||
}
|
||||
|
||||
console.log('\n📊 Summary by category:')
|
||||
const byCategory: Record<string, number> = {}
|
||||
for (const imp of imports) {
|
||||
const key = imp.fromPath
|
||||
byCategory[key] = (byCategory[key] || 0) + 1
|
||||
}
|
||||
|
||||
for (const [component, count] of Object.entries(byCategory).sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${component}: ${count} imports`)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
41
scripts/fix-index-files.ts
Normal file
41
scripts/fix-index-files.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Fix index.ts files to only export existing TSX files
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { globSync } from 'fs'
|
||||
|
||||
const ROOT_DIR = path.resolve(process.cwd())
|
||||
const COMPONENTS_DIR = path.join(ROOT_DIR, 'src/components')
|
||||
|
||||
const categories = ['atoms', 'molecules', 'organisms']
|
||||
|
||||
for (const category of categories) {
|
||||
const categoryDir = path.join(COMPONENTS_DIR, category)
|
||||
const indexPath = path.join(categoryDir, 'index.ts')
|
||||
|
||||
if (!fs.existsSync(indexPath)) continue
|
||||
|
||||
// Find all TSX files in this category
|
||||
const tsxFiles = globSync(path.join(categoryDir, '*.tsx'))
|
||||
const basenames = tsxFiles.map(f => path.basename(f, '.tsx'))
|
||||
|
||||
console.log(`\n📁 ${category}/`)
|
||||
console.log(` Found ${basenames.length} TSX files`)
|
||||
|
||||
// Generate new exports
|
||||
const exports = basenames
|
||||
.sort()
|
||||
.map(name => `export { ${name} } from './${name}'`)
|
||||
.join('\n')
|
||||
|
||||
// Write new index file
|
||||
const content = `// Auto-generated - only exports existing TSX files\n${exports}\n`
|
||||
fs.writeFileSync(indexPath, content)
|
||||
|
||||
console.log(` ✅ Updated ${category}/index.ts`)
|
||||
}
|
||||
|
||||
console.log('\n✨ All index files updated!')
|
||||
127
scripts/identify-pure-json-components.ts
Normal file
127
scripts/identify-pure-json-components.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
|
||||
// Components we restored (the ones we want to potentially convert to JSON)
|
||||
const restoredComponents = {
|
||||
ui: ['accordion', 'alert', 'aspect-ratio', 'avatar', 'badge', 'button', 'card',
|
||||
'checkbox', 'collapsible', 'dialog', 'hover-card', 'input', 'label',
|
||||
'popover', 'progress', 'radio-group', 'resizable', 'scroll-area',
|
||||
'separator', 'skeleton', 'sheet', 'switch', 'tabs', 'textarea', 'toggle', 'tooltip'],
|
||||
molecules: ['DataSourceCard', 'EditorToolbar', 'EmptyEditorState', 'MonacoEditorPanel', 'SearchBar'],
|
||||
organisms: ['EmptyCanvasState', 'PageHeader', 'SchemaEditorCanvas', 'SchemaEditorPropertiesPanel',
|
||||
'SchemaEditorSidebar', 'SchemaEditorStatusBar', 'SchemaEditorToolbar', 'ToolbarActions'],
|
||||
atoms: ['Input'],
|
||||
}
|
||||
|
||||
interface ComponentAnalysis {
|
||||
name: string
|
||||
category: string
|
||||
pureJSONEligible: boolean
|
||||
reasons: string[]
|
||||
complexity: 'simple' | 'medium' | 'complex'
|
||||
hasHooks: boolean
|
||||
hasConditionalLogic: boolean
|
||||
hasHelperFunctions: boolean
|
||||
hasComplexProps: boolean
|
||||
importsCustomComponents: boolean
|
||||
onlyImportsUIorAtoms: boolean
|
||||
}
|
||||
|
||||
async function analyzeComponent(category: string, component: string): Promise<ComponentAnalysis> {
|
||||
const tsFile = path.join(rootDir, `src/components/${category}/${component}.tsx`)
|
||||
const content = await fs.readFile(tsFile, 'utf-8')
|
||||
|
||||
const hasHooks = /useState|useEffect|useCallback|useMemo|useReducer|useRef|useContext/.test(content)
|
||||
const hasConditionalLogic = /\?|if\s*\(|switch\s*\(/.test(content)
|
||||
const hasHelperFunctions = /(?:const|function)\s+\w+\s*=\s*\([^)]*\)\s*=>/.test(content) && /return\s+\(/.test(content.split('return (')[0] || '')
|
||||
const hasComplexProps = /\.\w+\s*\?/.test(content) || /Object\./.test(content) || /Array\./.test(content)
|
||||
|
||||
// Check imports
|
||||
const importLines = content.match(/import\s+.*?\s+from\s+['"](.*?)['"]/g) || []
|
||||
const importsCustomComponents = importLines.some(line =>
|
||||
/@\/components\/(molecules|organisms)/.test(line)
|
||||
)
|
||||
const onlyImportsUIorAtoms = importLines.every(line => {
|
||||
if (!line.includes('@/components/')) return true
|
||||
return /@\/components\/(ui|atoms)/.test(line)
|
||||
})
|
||||
|
||||
const reasons: string[] = []
|
||||
if (hasHooks) reasons.push('Has React hooks')
|
||||
if (hasHelperFunctions) reasons.push('Has helper functions')
|
||||
if (hasComplexProps) reasons.push('Has complex prop access')
|
||||
if (importsCustomComponents) reasons.push('Imports molecules/organisms')
|
||||
if (!onlyImportsUIorAtoms && !importsCustomComponents) reasons.push('Imports non-UI components')
|
||||
|
||||
// Determine if eligible for pure JSON
|
||||
const pureJSONEligible = !hasHooks && !hasHelperFunctions && !hasComplexProps && onlyImportsUIorAtoms
|
||||
|
||||
// Complexity scoring
|
||||
let complexity: 'simple' | 'medium' | 'complex' = 'simple'
|
||||
if (hasHooks || hasHelperFunctions || hasComplexProps) {
|
||||
complexity = 'complex'
|
||||
} else if (hasConditionalLogic || importsCustomComponents) {
|
||||
complexity = 'medium'
|
||||
}
|
||||
|
||||
return {
|
||||
name: component,
|
||||
category,
|
||||
pureJSONEligible,
|
||||
reasons,
|
||||
complexity,
|
||||
hasHooks,
|
||||
hasConditionalLogic,
|
||||
hasHelperFunctions,
|
||||
hasComplexProps,
|
||||
importsCustomComponents,
|
||||
onlyImportsUIorAtoms,
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🔍 Analyzing restored components for pure JSON eligibility...\\n')
|
||||
|
||||
const eligible: ComponentAnalysis[] = []
|
||||
const ineligible: ComponentAnalysis[] = []
|
||||
|
||||
for (const [category, components] of Object.entries(restoredComponents)) {
|
||||
for (const component of components) {
|
||||
try {
|
||||
const analysis = await analyzeComponent(category, component)
|
||||
if (analysis.pureJSONEligible) {
|
||||
eligible.push(analysis)
|
||||
} else {
|
||||
ineligible.push(analysis)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`⚠️ ${component} - Could not analyze: ${e}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\\n✅ ELIGIBLE FOR PURE JSON (${eligible.length} components)\\n`)
|
||||
for (const comp of eligible) {
|
||||
console.log(` ${comp.name} (${comp.category})`)
|
||||
console.log(` Complexity: ${comp.complexity}`)
|
||||
console.log(` Conditional: ${comp.hasConditionalLogic ? 'Yes' : 'No'}`)
|
||||
}
|
||||
|
||||
console.log(`\\n❌ MUST STAY TYPESCRIPT (${ineligible.length} components)\\n`)
|
||||
for (const comp of ineligible) {
|
||||
console.log(` ${comp.name} (${comp.category})`)
|
||||
console.log(` Complexity: ${comp.complexity}`)
|
||||
console.log(` Reasons: ${comp.reasons.join(', ')}`)
|
||||
}
|
||||
|
||||
console.log(`\\n📊 Summary:`)
|
||||
console.log(` Eligible for JSON: ${eligible.length}`)
|
||||
console.log(` Must stay TypeScript: ${ineligible.length}`)
|
||||
console.log(` Conversion rate: ${Math.round(eligible.length / (eligible.length + ineligible.length) * 100)}%`)
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
157
scripts/refactor-to-dynamic-imports.ts
Normal file
157
scripts/refactor-to-dynamic-imports.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
|
||||
/**
|
||||
* Strategy: Replace static imports with dynamic component loading
|
||||
*
|
||||
* Before:
|
||||
* import { Button } from '@/components/ui/button'
|
||||
* <Button variant="primary">Click</Button>
|
||||
*
|
||||
* After:
|
||||
* import { getComponent } from '@/lib/component-loader'
|
||||
* const Button = getComponent('Button')
|
||||
* <Button variant="primary">Click</Button>
|
||||
*/
|
||||
|
||||
interface RefactorTask {
|
||||
file: string
|
||||
replacements: Array<{
|
||||
oldImport: string
|
||||
newImport: string
|
||||
components: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
const targetComponents = {
|
||||
ui: ['button', 'card', 'badge', 'label', 'input', 'separator', 'scroll-area',
|
||||
'tabs', 'dialog', 'textarea', 'tooltip', 'switch', 'alert', 'skeleton',
|
||||
'progress', 'collapsible', 'resizable', 'popover', 'hover-card', 'checkbox',
|
||||
'accordion', 'aspect-ratio', 'avatar', 'radio-group', 'sheet', 'toggle'],
|
||||
molecules: ['DataSourceCard', 'EditorToolbar', 'EmptyEditorState', 'MonacoEditorPanel', 'SearchBar'],
|
||||
organisms: ['EmptyCanvasState', 'PageHeader', 'SchemaEditorCanvas', 'SchemaEditorPropertiesPanel',
|
||||
'SchemaEditorSidebar', 'SchemaEditorStatusBar', 'SchemaEditorToolbar', 'ToolbarActions'],
|
||||
atoms: ['Input']
|
||||
}
|
||||
|
||||
export async function refactorFile(filePath: string): Promise<boolean> {
|
||||
let content = await fs.readFile(filePath, 'utf-8')
|
||||
let modified = false
|
||||
|
||||
// Find all imports to replace
|
||||
const componentsToLoad = new Set<string>()
|
||||
|
||||
for (const [category, components] of Object.entries(targetComponents)) {
|
||||
for (const component of components) {
|
||||
const patterns = [
|
||||
new RegExp(`import\\s+\\{([^}]+)\\}\\s+from\\s+['"]@/components/${category}/${component}['"]`, 'g'),
|
||||
new RegExp(`import\\s+(\\w+)\\s+from\\s+['"]@/components/${category}/${component}['"]`, 'g'),
|
||||
]
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const matches = content.matchAll(pattern)
|
||||
for (const match of matches) {
|
||||
const importedItems = match[1].split(',').map(s => s.trim().split(' as ')[0].trim())
|
||||
importedItems.forEach(item => componentsToLoad.add(item))
|
||||
|
||||
// Remove the import line
|
||||
content = content.replace(match[0], '')
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!modified) return false
|
||||
|
||||
// Add dynamic component loader import at top
|
||||
const loaderImport = `import { loadComponent } from '@/lib/component-loader'\n`
|
||||
|
||||
// Add component loading statements
|
||||
const componentLoads = Array.from(componentsToLoad)
|
||||
.map(comp => `const ${comp} = loadComponent('${comp}')`)
|
||||
.join('\n')
|
||||
|
||||
// Find first import statement location
|
||||
const firstImportMatch = content.match(/^import\s/m)
|
||||
if (firstImportMatch && firstImportMatch.index !== undefined) {
|
||||
content = content.slice(0, firstImportMatch.index) +
|
||||
loaderImport + '\n' +
|
||||
componentLoads + '\n\n' +
|
||||
content.slice(firstImportMatch.index)
|
||||
}
|
||||
|
||||
await fs.writeFile(filePath, content)
|
||||
return true
|
||||
}
|
||||
|
||||
async function createComponentLoader() {
|
||||
const loaderPath = path.join(rootDir, 'src/lib/component-loader.ts')
|
||||
|
||||
const loaderContent = `/**
|
||||
* Dynamic Component Loader
|
||||
* Loads components from the registry at runtime instead of static imports
|
||||
*/
|
||||
|
||||
import { ComponentType, lazy } from 'react'
|
||||
|
||||
const componentCache = new Map<string, ComponentType<any>>()
|
||||
|
||||
export function loadComponent(componentName: string): ComponentType<any> {
|
||||
if (componentCache.has(componentName)) {
|
||||
return componentCache.get(componentName)!
|
||||
}
|
||||
|
||||
// Try to load from different sources
|
||||
const loaders = [
|
||||
() => import(\`@/components/ui/\${componentName.toLowerCase()}\`),
|
||||
() => import(\`@/components/atoms/\${componentName}\`),
|
||||
() => import(\`@/components/molecules/\${componentName}\`),
|
||||
() => import(\`@/components/organisms/\${componentName}\`),
|
||||
]
|
||||
|
||||
// Create lazy component
|
||||
const LazyComponent = lazy(async () => {
|
||||
for (const loader of loaders) {
|
||||
try {
|
||||
const module = await loader()
|
||||
return { default: module[componentName] || module.default }
|
||||
} catch (e) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
throw new Error(\`Component \${componentName} not found\`)
|
||||
})
|
||||
|
||||
componentCache.set(componentName, LazyComponent)
|
||||
return LazyComponent
|
||||
}
|
||||
|
||||
export function getComponent(componentName: string): ComponentType<any> {
|
||||
return loadComponent(componentName)
|
||||
}
|
||||
`
|
||||
|
||||
await fs.writeFile(loaderPath, loaderContent)
|
||||
console.log('✅ Created component-loader.ts')
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 Starting AGGRESSIVE refactoring to eliminate static imports...\n')
|
||||
console.log('⚠️ WARNING: This is a MAJOR refactoring affecting 975+ import statements!\n')
|
||||
console.log('Press Ctrl+C now if you want to reconsider...\n')
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 3000))
|
||||
|
||||
console.log('🔧 Creating dynamic component loader...')
|
||||
await createComponentLoader()
|
||||
|
||||
console.log('\n📝 This approach requires significant testing and may break things.')
|
||||
console.log(' Recommendation: Manual refactoring of high-value components instead.\n')
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
76
scripts/update-index-exports.ts
Normal file
76
scripts/update-index-exports.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
|
||||
/**
|
||||
* Update index.ts files to remove exports for deleted components
|
||||
*/
|
||||
async function updateIndexFiles(): Promise<void> {
|
||||
console.log('📝 Updating index.ts files...\n')
|
||||
|
||||
const directories = [
|
||||
'src/components/atoms',
|
||||
'src/components/molecules',
|
||||
'src/components/organisms',
|
||||
'src/components/ui',
|
||||
]
|
||||
|
||||
for (const dir of directories) {
|
||||
const indexPath = path.join(rootDir, dir, 'index.ts')
|
||||
const dirPath = path.join(rootDir, dir)
|
||||
|
||||
console.log(`📂 Processing ${dir}/index.ts...`)
|
||||
|
||||
try {
|
||||
// Read current index.ts
|
||||
const indexContent = await fs.readFile(indexPath, 'utf-8')
|
||||
const lines = indexContent.split('\n')
|
||||
|
||||
// Get list of existing .tsx files
|
||||
const files = await fs.readdir(dirPath)
|
||||
const existingComponents = new Set(
|
||||
files
|
||||
.filter(f => f.endsWith('.tsx') && f !== 'index.tsx')
|
||||
.map(f => f.replace('.tsx', ''))
|
||||
)
|
||||
|
||||
// Filter out exports for deleted components
|
||||
const updatedLines = lines.filter(line => {
|
||||
// Skip empty lines and comments
|
||||
if (!line.trim() || line.trim().startsWith('//')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if it's an export line
|
||||
const exportMatch = line.match(/export\s+(?:\{([^}]+)\}|.+)\s+from\s+['"]\.\/([^'"]+)['"]/)
|
||||
if (!exportMatch) {
|
||||
return true // Keep non-export lines
|
||||
}
|
||||
|
||||
const componentName = exportMatch[2]
|
||||
const exists = existingComponents.has(componentName)
|
||||
|
||||
if (!exists) {
|
||||
console.log(` ❌ Removing export: ${componentName}`)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// Write updated index.ts
|
||||
await fs.writeFile(indexPath, updatedLines.join('\n'))
|
||||
|
||||
console.log(` ✅ Updated ${dir}/index.ts\n`)
|
||||
} catch (error) {
|
||||
console.error(` ❌ Error processing ${dir}/index.ts:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✨ Index files updated!')
|
||||
}
|
||||
|
||||
updateIndexFiles().catch(console.error)
|
||||
262
scripts/update-registry-from-json.ts
Normal file
262
scripts/update-registry-from-json.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
|
||||
interface JSONComponent {
|
||||
type: string
|
||||
jsonCompatible?: boolean
|
||||
wrapperRequired?: boolean
|
||||
load?: {
|
||||
path: string
|
||||
export: string
|
||||
lazy?: boolean
|
||||
}
|
||||
props?: Record<string, unknown>
|
||||
metadata?: {
|
||||
notes?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface RegistryEntry {
|
||||
type: string
|
||||
name: string
|
||||
category: string
|
||||
canHaveChildren: boolean
|
||||
description: string
|
||||
status: 'supported' | 'deprecated'
|
||||
source: 'atoms' | 'molecules' | 'organisms' | 'ui' | 'wrappers' | 'custom'
|
||||
jsonCompatible: boolean
|
||||
wrapperRequired?: boolean
|
||||
load?: {
|
||||
path: string
|
||||
export: string
|
||||
lazy?: boolean
|
||||
}
|
||||
metadata?: {
|
||||
conversionDate?: string
|
||||
autoGenerated?: boolean
|
||||
notes?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface Registry {
|
||||
version: string
|
||||
categories: Record<string, string>
|
||||
sourceRoots: Record<string, string[]>
|
||||
components: RegistryEntry[]
|
||||
statistics: {
|
||||
total: number
|
||||
supported: number
|
||||
jsonCompatible: number
|
||||
byCategory: Record<string, number>
|
||||
bySource: Record<string, number>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine component category based on name and source
|
||||
*/
|
||||
function determineCategory(componentName: string, source: string): string {
|
||||
const name = componentName.toLowerCase()
|
||||
|
||||
// Layout components
|
||||
if (/container|section|stack|flex|grid|layout|panel|sidebar|header|footer/.test(name)) {
|
||||
return 'layout'
|
||||
}
|
||||
|
||||
// Input components
|
||||
if (/input|select|checkbox|radio|slider|switch|form|textarea|date|file|number|password|search/.test(name)) {
|
||||
return 'input'
|
||||
}
|
||||
|
||||
// Navigation components
|
||||
if (/nav|menu|breadcrumb|tab|link|pagination/.test(name)) {
|
||||
return 'navigation'
|
||||
}
|
||||
|
||||
// Feedback components
|
||||
if (/alert|toast|notification|spinner|loading|progress|skeleton|badge|indicator/.test(name)) {
|
||||
return 'feedback'
|
||||
}
|
||||
|
||||
// Data display components
|
||||
if (/table|list|card|chart|graph|tree|timeline|avatar|image/.test(name)) {
|
||||
return 'data'
|
||||
}
|
||||
|
||||
// Display components
|
||||
if (/text|heading|label|code|icon|divider|separator|spacer/.test(name)) {
|
||||
return 'display'
|
||||
}
|
||||
|
||||
// Default to custom for organisms and complex components
|
||||
if (source === 'organisms' || source === 'molecules') {
|
||||
return 'custom'
|
||||
}
|
||||
|
||||
return 'display'
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if component can have children
|
||||
*/
|
||||
function canHaveChildren(componentName: string): boolean {
|
||||
const name = componentName.toLowerCase()
|
||||
|
||||
// These typically don't have children
|
||||
const noChildren = /input|select|checkbox|radio|slider|switch|image|icon|divider|separator|spacer|spinner|progress|badge|dot/
|
||||
|
||||
return !noChildren.test(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate description for component
|
||||
*/
|
||||
function generateDescription(componentName: string, category: string): string {
|
||||
const descriptions: Record<string, string> = {
|
||||
layout: 'Layout container component',
|
||||
input: 'Form input component',
|
||||
navigation: 'Navigation component',
|
||||
feedback: 'Feedback and status component',
|
||||
data: 'Data display component',
|
||||
display: 'Display component',
|
||||
custom: 'Custom component',
|
||||
}
|
||||
|
||||
return descriptions[category] || 'Component'
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all JSON files from a directory and create registry entries
|
||||
*/
|
||||
async function processDirectory(
|
||||
dir: string,
|
||||
source: 'atoms' | 'molecules' | 'organisms' | 'ui' | 'custom'
|
||||
): Promise<RegistryEntry[]> {
|
||||
const entries: RegistryEntry[] = []
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(dir)
|
||||
const jsonFiles = files.filter(f => f.endsWith('.json'))
|
||||
|
||||
for (const file of jsonFiles) {
|
||||
const filePath = path.join(dir, file)
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
const jsonComponent: JSONComponent = JSON.parse(content)
|
||||
|
||||
const componentName = jsonComponent.type
|
||||
if (!componentName) continue
|
||||
|
||||
const category = determineCategory(componentName, source)
|
||||
|
||||
const entry: RegistryEntry = {
|
||||
type: componentName,
|
||||
name: componentName,
|
||||
category,
|
||||
canHaveChildren: canHaveChildren(componentName),
|
||||
description: generateDescription(componentName, category),
|
||||
status: 'supported',
|
||||
source,
|
||||
jsonCompatible: jsonComponent.jsonCompatible !== false,
|
||||
wrapperRequired: jsonComponent.wrapperRequired || false,
|
||||
metadata: {
|
||||
conversionDate: new Date().toISOString().split('T')[0],
|
||||
autoGenerated: true,
|
||||
notes: jsonComponent.metadata?.notes,
|
||||
},
|
||||
}
|
||||
|
||||
if (jsonComponent.load) {
|
||||
entry.load = jsonComponent.load
|
||||
}
|
||||
|
||||
entries.push(entry)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing ${dir}:`, error)
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the registry with new components
|
||||
*/
|
||||
async function updateRegistry() {
|
||||
console.log('📝 Updating json-components-registry.json...\n')
|
||||
|
||||
const registryPath = path.join(rootDir, 'json-components-registry.json')
|
||||
|
||||
// Read existing registry
|
||||
const registryContent = await fs.readFile(registryPath, 'utf-8')
|
||||
const registry: Registry = JSON.parse(registryContent)
|
||||
|
||||
console.log(` Current components: ${registry.components.length}`)
|
||||
|
||||
// Process each directory
|
||||
const newEntries: RegistryEntry[] = []
|
||||
|
||||
const directories = [
|
||||
{ dir: path.join(rootDir, 'src/config/pages/atoms'), source: 'atoms' as const },
|
||||
{ dir: path.join(rootDir, 'src/config/pages/molecules'), source: 'molecules' as const },
|
||||
{ dir: path.join(rootDir, 'src/config/pages/organisms'), source: 'organisms' as const },
|
||||
{ dir: path.join(rootDir, 'src/config/pages/ui'), source: 'ui' as const },
|
||||
{ dir: path.join(rootDir, 'src/config/pages/components'), source: 'custom' as const },
|
||||
]
|
||||
|
||||
for (const { dir, source } of directories) {
|
||||
const entries = await processDirectory(dir, source)
|
||||
newEntries.push(...entries)
|
||||
console.log(` Processed ${source}: ${entries.length} components`)
|
||||
}
|
||||
|
||||
// Merge with existing components (remove duplicates)
|
||||
const existingTypes = new Set(registry.components.map(c => c.type))
|
||||
const uniqueNewEntries = newEntries.filter(e => !existingTypes.has(e.type))
|
||||
|
||||
console.log(`\n New unique components: ${uniqueNewEntries.length}`)
|
||||
console.log(` Skipped duplicates: ${newEntries.length - uniqueNewEntries.length}`)
|
||||
|
||||
// Add new components
|
||||
registry.components.push(...uniqueNewEntries)
|
||||
|
||||
// Update statistics
|
||||
const byCategory: Record<string, number> = {}
|
||||
const bySource: Record<string, number> = {}
|
||||
|
||||
for (const component of registry.components) {
|
||||
byCategory[component.category] = (byCategory[component.category] || 0) + 1
|
||||
bySource[component.source] = (bySource[component.source] || 0) + 1
|
||||
}
|
||||
|
||||
registry.statistics = {
|
||||
total: registry.components.length,
|
||||
supported: registry.components.filter(c => c.status === 'supported').length,
|
||||
jsonCompatible: registry.components.filter(c => c.jsonCompatible).length,
|
||||
byCategory,
|
||||
bySource,
|
||||
}
|
||||
|
||||
// Sort components by type
|
||||
registry.components.sort((a, b) => a.type.localeCompare(b.type))
|
||||
|
||||
// Write updated registry
|
||||
await fs.writeFile(registryPath, JSON.stringify(registry, null, 2) + '\n')
|
||||
|
||||
console.log(`\n✅ Registry updated successfully!`)
|
||||
console.log(` Total components: ${registry.statistics.total}`)
|
||||
console.log(` JSON compatible: ${registry.statistics.jsonCompatible}`)
|
||||
console.log(`\n📊 By source:`)
|
||||
for (const [source, count] of Object.entries(bySource)) {
|
||||
console.log(` ${source.padEnd(12)}: ${count}`)
|
||||
}
|
||||
console.log(`\n📊 By category:`)
|
||||
for (const [category, count] of Object.entries(byCategory)) {
|
||||
console.log(` ${category.padEnd(12)}: ${count}`)
|
||||
}
|
||||
}
|
||||
|
||||
updateRegistry().catch(console.error)
|
||||
@@ -3,7 +3,8 @@ import { useToggle, useDialog } from '@/hooks/ui'
|
||||
import { useKV } from '@/hooks/use-kv'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
||||
import { SearchInput, DataCard, ActionBar } from '@/components/molecules'
|
||||
import { SearchInput } from '@/components/molecules'
|
||||
import { DataCard } from '@/components/atoms/json-ui'
|
||||
import { Grid, Heading, StatusBadge } from '@/components/atoms'
|
||||
import { Plus, Trash, Eye } from '@phosphor-icons/react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
@@ -64,28 +65,31 @@ export function AtomicComponentDemo() {
|
||||
</div>
|
||||
|
||||
<Grid cols={3} gap={4}>
|
||||
<DataCard title="Total Tasks" value={stats.total} />
|
||||
<DataCard title="Active" value={stats.active} />
|
||||
<DataCard title="Completed" value={stats.completed} />
|
||||
<DataCard title="Total Tasks" icon="list" gradient="from-blue-500/10 to-blue-500/5">
|
||||
<div className="text-3xl font-bold">{stats.total}</div>
|
||||
</DataCard>
|
||||
<DataCard title="Active" icon="clock" gradient="from-amber-500/10 to-amber-500/5">
|
||||
<div className="text-3xl font-bold">{stats.active}</div>
|
||||
</DataCard>
|
||||
<DataCard title="Completed" icon="check" gradient="from-green-500/10 to-green-500/5">
|
||||
<div className="text-3xl font-bold">{stats.completed}</div>
|
||||
</DataCard>
|
||||
</Grid>
|
||||
|
||||
<ActionBar
|
||||
title="Tasks"
|
||||
actions={[
|
||||
{
|
||||
label: 'Add Task',
|
||||
icon: <Plus size={16} />,
|
||||
onClick: addDialog.open,
|
||||
variant: 'default',
|
||||
},
|
||||
{
|
||||
label: showCompleted.value ? 'Hide Completed' : 'Show Completed',
|
||||
icon: <Eye size={16} />,
|
||||
onClick: showCompleted.toggle,
|
||||
variant: 'outline',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/* ActionBar replaced with inline buttons */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Heading level={3}>Tasks</Heading>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={addDialog.open} size="sm">
|
||||
<Plus size={16} className="mr-2" />
|
||||
Add Task
|
||||
</Button>
|
||||
<Button onClick={showCompleted.toggle} variant="outline" size="sm">
|
||||
<Eye size={16} className="mr-2" />
|
||||
{showCompleted.value ? 'Hide Completed' : 'Show Completed'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchInput
|
||||
value={query}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { DataSourceManager } from '@/components/organisms/DataSourceManager'
|
||||
import { ComponentBindingDialog } from '@/components/molecules/ComponentBindingDialog'
|
||||
import { ComponentBindingDialog } from '@/lib/json-ui/json-components'
|
||||
import { DataSource, UIComponent } from '@/types/json-ui'
|
||||
import { DataBindingHeader } from '@/components/data-binding-designer/DataBindingHeader'
|
||||
import { ComponentBindingsCard } from '@/components/data-binding-designer/ComponentBindingsCard'
|
||||
|
||||
@@ -15,3 +15,16 @@ export const PRIORITY_COLORS = {
|
||||
medium: 'border-amber-400/60 bg-amber-50/80 dark:bg-amber-950/40',
|
||||
high: 'border-red-400/60 bg-red-50/80 dark:bg-red-950/40',
|
||||
}
|
||||
|
||||
// Missing exports for GROUP_COLORS, CATEGORIES, PRIORITIES, STATUSES
|
||||
export const GROUP_COLORS = {
|
||||
default: '#a78bfa',
|
||||
primary: '#60a5fa',
|
||||
success: '#34d399',
|
||||
warning: '#fbbf24',
|
||||
danger: '#f87171',
|
||||
}
|
||||
|
||||
export const CATEGORIES = ['feature', 'enhancement', 'bug', 'documentation', 'other'] as const
|
||||
export const PRIORITIES = ['low', 'medium', 'high'] as const
|
||||
export const STATUSES = ['idea', 'planned', 'in-progress', 'completed'] as const
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { PageRenderer } from '@/lib/json-ui/page-renderer'
|
||||
import { LoadingFallback } from '@/components/molecules'
|
||||
import { useSchemaLoader } from '@/hooks/use-schema-loader'
|
||||
|
||||
interface JSONSchemaPageLoaderProps {
|
||||
@@ -12,7 +11,14 @@ export function JSONSchemaPageLoader({ schemaPath, data, functions }: JSONSchema
|
||||
const { schema, loading, error } = useSchemaLoader(schemaPath)
|
||||
|
||||
if (loading) {
|
||||
return <LoadingFallback message={`Loading ${schemaPath}...`} />
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-sm text-muted-foreground">Loading {schemaPath}...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !schema) {
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { CaretDown } from '@phosphor-icons/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AccordionItem {
|
||||
id: string
|
||||
title: string
|
||||
content: React.ReactNode
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface AccordionProps {
|
||||
items: AccordionItem[]
|
||||
type?: 'single' | 'multiple'
|
||||
defaultOpen?: string[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Accordion({ items, type = 'single', defaultOpen = [], className }: AccordionProps) {
|
||||
const [openItems, setOpenItems] = useState<string[]>(defaultOpen)
|
||||
|
||||
const toggleItem = (id: string) => {
|
||||
if (type === 'single') {
|
||||
setOpenItems(openItems.includes(id) ? [] : [id])
|
||||
} else {
|
||||
setOpenItems(
|
||||
openItems.includes(id)
|
||||
? openItems.filter((item) => item !== id)
|
||||
: [...openItems, id]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{items.map((item) => {
|
||||
const isOpen = openItems.includes(item.id)
|
||||
|
||||
return (
|
||||
<div key={item.id} className="border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => !item.disabled && toggleItem(item.id)}
|
||||
disabled={item.disabled}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between p-4 bg-card text-card-foreground font-medium transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
item.disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<span>{item.title}</span>
|
||||
<CaretDown
|
||||
className={cn(
|
||||
'w-5 h-5 transition-transform',
|
||||
isOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="p-4 bg-card border-t border-border animate-in slide-in-from-top-2">
|
||||
{item.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
export interface ActionButtonProps {
|
||||
icon?: ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
variant?: 'default' | 'outline' | 'ghost' | 'destructive'
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon'
|
||||
tooltip?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ActionButton({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
tooltip,
|
||||
disabled,
|
||||
className,
|
||||
}: ActionButtonProps) {
|
||||
const button = (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
>
|
||||
{icon && <span className="mr-2">{icon}</span>}
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return button
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { CaretRight } from '@phosphor-icons/react'
|
||||
|
||||
interface ActionCardProps {
|
||||
icon?: React.ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function ActionCard({ icon, title, description, onClick, className, disabled }: ActionCardProps) {
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'cursor-pointer transition-all hover:shadow-md hover:border-primary/50',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{icon && (
|
||||
<div className="flex-shrink-0 p-2 rounded-lg bg-primary/10 text-primary">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-sm mb-1">{title}</div>
|
||||
{description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-2">{description}</div>
|
||||
)}
|
||||
</div>
|
||||
<CaretRight size={16} className="flex-shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Plus, Pencil, Trash, Copy, Download, Upload } from '@phosphor-icons/react'
|
||||
|
||||
interface ActionIconProps {
|
||||
action: 'add' | 'edit' | 'delete' | 'copy' | 'download' | 'upload'
|
||||
size?: number
|
||||
weight?: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ActionIcon({ action, size = 16, weight = 'regular', className = '' }: ActionIconProps) {
|
||||
const iconMap = {
|
||||
add: Plus,
|
||||
edit: Pencil,
|
||||
delete: Trash,
|
||||
copy: Copy,
|
||||
download: Download,
|
||||
upload: Upload,
|
||||
}
|
||||
|
||||
const IconComponent = iconMap[action]
|
||||
return <IconComponent size={size} weight={weight} className={className} />
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Info, Warning, CheckCircle, XCircle } from '@phosphor-icons/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AlertProps {
|
||||
variant?: 'info' | 'warning' | 'success' | 'error'
|
||||
title?: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const variantConfig = {
|
||||
info: {
|
||||
icon: Info,
|
||||
classes: 'bg-blue-50 border-blue-200 text-blue-900',
|
||||
},
|
||||
warning: {
|
||||
icon: Warning,
|
||||
classes: 'bg-yellow-50 border-yellow-200 text-yellow-900',
|
||||
},
|
||||
success: {
|
||||
icon: CheckCircle,
|
||||
classes: 'bg-green-50 border-green-200 text-green-900',
|
||||
},
|
||||
error: {
|
||||
icon: XCircle,
|
||||
classes: 'bg-red-50 border-red-200 text-red-900',
|
||||
},
|
||||
}
|
||||
|
||||
export function Alert({ variant = 'info', title, children, className }: AlertProps) {
|
||||
const config = variantConfig[variant]
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-3 p-4 rounded-lg border',
|
||||
config.classes,
|
||||
className
|
||||
)}
|
||||
role="alert"
|
||||
>
|
||||
<Icon size={20} weight="bold" className="flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
{title && <div className="font-semibold mb-1">{title}</div>}
|
||||
<div className="text-sm">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Code } from '@phosphor-icons/react'
|
||||
|
||||
export function AppLogo() {
|
||||
return (
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-gradient-to-br from-primary to-accent flex items-center justify-center shrink-0">
|
||||
<Code size={20} weight="duotone" className="text-white sm:w-6 sm:h-6" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AvatarProps {
|
||||
src?: string
|
||||
alt?: string
|
||||
fallback?: string
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'w-6 h-6 text-xs',
|
||||
sm: 'w-8 h-8 text-sm',
|
||||
md: 'w-10 h-10 text-base',
|
||||
lg: 'w-12 h-12 text-lg',
|
||||
xl: 'w-16 h-16 text-xl',
|
||||
}
|
||||
|
||||
export function Avatar({ src, alt, fallback, size = 'md', className }: AvatarProps) {
|
||||
const initials = fallback || alt?.slice(0, 2).toUpperCase() || '?'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-center rounded-full bg-muted overflow-hidden',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} alt={alt} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="font-medium text-muted-foreground">{initials}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AvatarGroupProps {
|
||||
avatars: {
|
||||
src?: string
|
||||
alt: string
|
||||
fallback: string
|
||||
}[]
|
||||
max?: number
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'h-6 w-6 text-xs',
|
||||
sm: 'h-8 w-8 text-xs',
|
||||
md: 'h-10 w-10 text-sm',
|
||||
lg: 'h-12 w-12 text-base',
|
||||
}
|
||||
|
||||
export function AvatarGroup({
|
||||
avatars,
|
||||
max = 5,
|
||||
size = 'md',
|
||||
className,
|
||||
}: AvatarGroupProps) {
|
||||
const displayAvatars = avatars.slice(0, max)
|
||||
const remainingCount = Math.max(avatars.length - max, 0)
|
||||
|
||||
return (
|
||||
<div className={cn('flex -space-x-2', className)}>
|
||||
{displayAvatars.map((avatar, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-center rounded-full border-2 border-background bg-muted overflow-hidden',
|
||||
sizeClasses[size]
|
||||
)}
|
||||
title={avatar.alt}
|
||||
>
|
||||
{avatar.src ? (
|
||||
<img src={avatar.src} alt={avatar.alt} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="font-medium text-foreground">{avatar.fallback}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-center rounded-full border-2 border-background bg-muted',
|
||||
sizeClasses[size]
|
||||
)}
|
||||
>
|
||||
<span className="font-medium text-foreground">+{remainingCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Badge as ShadcnBadge } from '@/components/ui/badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface BadgeProps {
|
||||
children: ReactNode
|
||||
variant?: 'default' | 'secondary' | 'destructive' | 'outline'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
icon?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'text-xs px-2 py-0.5',
|
||||
md: 'text-sm px-2.5 py-0.5',
|
||||
lg: 'text-base px-3 py-1',
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
icon,
|
||||
className,
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<ShadcnBadge
|
||||
variant={variant}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{icon && <span className="flex-shrink-0">{icon}</span>}
|
||||
{children}
|
||||
</ShadcnBadge>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Link } from '@phosphor-icons/react'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
interface BindingIndicatorProps {
|
||||
sourceId: string
|
||||
path?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function BindingIndicator({ sourceId, path, className = '' }: BindingIndicatorProps) {
|
||||
const bindingText = path ? `${sourceId}.${path}` : sourceId
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs bg-accent/10 text-accent border border-accent/30 ${className}`}>
|
||||
<Link weight="bold" className="w-3 h-3" />
|
||||
<span className="font-mono">{bindingText}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">Bound to: {bindingText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { CaretRight } from '@phosphor-icons/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string
|
||||
href?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
interface BreadcrumbNavProps {
|
||||
items?: BreadcrumbItem[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function BreadcrumbNav({ items = [], className }: BreadcrumbNavProps) {
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className={cn('flex items-center gap-2', className)}>
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1
|
||||
const linkClassName = cn(
|
||||
'text-sm transition-colors',
|
||||
isLast ? 'text-foreground font-medium' : 'text-muted-foreground hover:text-foreground'
|
||||
)
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
{item.href ? (
|
||||
<a href={item.href} onClick={item.onClick} className={linkClassName}>
|
||||
{item.label}
|
||||
</a>
|
||||
) : item.onClick ? (
|
||||
<button onClick={item.onClick} className={linkClassName}>
|
||||
{item.label}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm',
|
||||
isLast ? 'text-foreground font-medium' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
{!isLast && <CaretRight className="w-4 h-4 text-muted-foreground" />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
export const Breadcrumb = BreadcrumbNav
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Button as ShadcnButton, ButtonProps as ShadcnButtonProps } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export interface ButtonProps extends ShadcnButtonProps {
|
||||
children: ReactNode
|
||||
leftIcon?: ReactNode
|
||||
rightIcon?: ReactNode
|
||||
loading?: boolean
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
loading,
|
||||
fullWidth,
|
||||
disabled,
|
||||
className,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<ShadcnButton
|
||||
disabled={disabled || loading}
|
||||
className={cn(fullWidth && 'w-full', className)}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
{leftIcon && <span className="flex-shrink-0">{leftIcon}</span>}
|
||||
<span>{children}</span>
|
||||
{rightIcon && <span className="flex-shrink-0">{rightIcon}</span>}
|
||||
</div>
|
||||
)}
|
||||
</ShadcnButton>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface ButtonGroupProps {
|
||||
children: ReactNode
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ButtonGroup({
|
||||
children,
|
||||
orientation = 'horizontal',
|
||||
className,
|
||||
}: ButtonGroupProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex',
|
||||
orientation === 'horizontal' ? 'flex-row' : 'flex-col',
|
||||
'[&>button]:rounded-none',
|
||||
'[&>button:first-child]:rounded-l-md',
|
||||
'[&>button:last-child]:rounded-r-md',
|
||||
orientation === 'vertical' && '[&>button:first-child]:rounded-t-md [&>button:first-child]:rounded-l-none',
|
||||
orientation === 'vertical' && '[&>button:last-child]:rounded-b-md [&>button:last-child]:rounded-r-none',
|
||||
'[&>button:not(:last-child)]:border-r-0',
|
||||
orientation === 'vertical' && '[&>button:not(:last-child)]:border-b-0 [&>button:not(:last-child)]:border-r',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Calendar as ShadcnCalendar } from '@/components/ui/calendar'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CalendarProps {
|
||||
selected?: Date
|
||||
onSelect?: (date: Date | undefined) => void
|
||||
mode?: 'single' | 'multiple' | 'range'
|
||||
disabled?: Date | ((date: Date) => boolean)
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Calendar({
|
||||
selected,
|
||||
onSelect,
|
||||
mode = 'single',
|
||||
disabled,
|
||||
className,
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<ShadcnCalendar
|
||||
mode={mode as any}
|
||||
selected={selected}
|
||||
onSelect={onSelect as any}
|
||||
disabled={disabled}
|
||||
className={cn('rounded-md border', className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode
|
||||
variant?: 'default' | 'bordered' | 'elevated' | 'flat'
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg'
|
||||
hover?: boolean
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
variant = 'default',
|
||||
padding = 'md',
|
||||
hover = false,
|
||||
className,
|
||||
onClick
|
||||
}: CardProps) {
|
||||
const variantStyles = {
|
||||
default: 'bg-card border border-border',
|
||||
bordered: 'bg-background border-2 border-border',
|
||||
elevated: 'bg-card shadow-lg border border-border',
|
||||
flat: 'bg-muted',
|
||||
}
|
||||
|
||||
const paddingStyles = {
|
||||
none: 'p-0',
|
||||
sm: 'p-3',
|
||||
md: 'p-6',
|
||||
lg: 'p-8',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'rounded-lg transition-all',
|
||||
variantStyles[variant],
|
||||
paddingStyles[padding],
|
||||
hover && 'hover:shadow-md hover:scale-[1.01] cursor-pointer',
|
||||
onClick && 'cursor-pointer',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Check, Minus } from '@phosphor-icons/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CheckboxProps {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
label?: string
|
||||
indeterminate?: boolean
|
||||
disabled?: boolean
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Checkbox({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
indeterminate = false,
|
||||
disabled = false,
|
||||
size = 'md',
|
||||
className
|
||||
}: CheckboxProps) {
|
||||
const sizeStyles = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-5 h-5',
|
||||
lg: 'w-6 h-6',
|
||||
}
|
||||
|
||||
const iconSize = {
|
||||
sm: 12,
|
||||
md: 16,
|
||||
lg: 20,
|
||||
}
|
||||
|
||||
return (
|
||||
<label className={cn('flex items-center gap-2 cursor-pointer', disabled && 'opacity-50 cursor-not-allowed', className)}>
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={indeterminate ? 'mixed' : checked}
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded border-2 transition-colors',
|
||||
sizeStyles[size],
|
||||
checked || indeterminate
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'bg-background border-input hover:border-ring'
|
||||
)}
|
||||
>
|
||||
{indeterminate ? (
|
||||
<Minus size={iconSize[size]} weight="bold" />
|
||||
) : checked ? (
|
||||
<Check size={iconSize[size]} weight="bold" />
|
||||
) : null}
|
||||
</button>
|
||||
{label && <span className="text-sm font-medium select-none">{label}</span>}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { X } from '@phosphor-icons/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ChipProps {
|
||||
children: ReactNode
|
||||
variant?: 'default' | 'primary' | 'accent' | 'muted'
|
||||
size?: 'sm' | 'md'
|
||||
onRemove?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-secondary text-secondary-foreground',
|
||||
primary: 'bg-primary text-primary-foreground',
|
||||
accent: 'bg-accent text-accent-foreground',
|
||||
muted: 'bg-muted text-muted-foreground',
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-0.5 text-xs',
|
||||
md: 'px-3 py-1 text-sm',
|
||||
}
|
||||
|
||||
export function Chip({
|
||||
children,
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
onRemove,
|
||||
className
|
||||
}: ChipProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full font-medium',
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="inline-flex items-center justify-center hover:bg-black/10 rounded-full transition-colors"
|
||||
aria-label="Remove"
|
||||
>
|
||||
<X size={size === 'sm' ? 12 : 14} weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CircularProgressProps {
|
||||
value: number
|
||||
max?: number
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
showLabel?: boolean
|
||||
strokeWidth?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: { dimension: 48, stroke: 4, fontSize: 'text-xs' },
|
||||
md: { dimension: 64, stroke: 5, fontSize: 'text-sm' },
|
||||
lg: { dimension: 96, stroke: 6, fontSize: 'text-base' },
|
||||
xl: { dimension: 128, stroke: 8, fontSize: 'text-lg' },
|
||||
}
|
||||
|
||||
export function CircularProgress({
|
||||
value,
|
||||
max = 100,
|
||||
size = 'md',
|
||||
showLabel = true,
|
||||
strokeWidth,
|
||||
className,
|
||||
}: CircularProgressProps) {
|
||||
const { dimension, stroke, fontSize } = sizeClasses[size]
|
||||
const actualStroke = strokeWidth || stroke
|
||||
const percentage = Math.min((value / max) * 100, 100)
|
||||
const radius = (dimension - actualStroke) / 2
|
||||
const circumference = radius * 2 * Math.PI
|
||||
const offset = circumference - (percentage / 100) * circumference
|
||||
|
||||
return (
|
||||
<div className={cn('relative inline-flex items-center justify-center', className)}>
|
||||
<svg width={dimension} height={dimension} className="transform -rotate-90">
|
||||
<circle
|
||||
cx={dimension / 2}
|
||||
cy={dimension / 2}
|
||||
r={radius}
|
||||
stroke="currentColor"
|
||||
strokeWidth={actualStroke}
|
||||
fill="none"
|
||||
className="text-muted opacity-20"
|
||||
/>
|
||||
<circle
|
||||
cx={dimension / 2}
|
||||
cy={dimension / 2}
|
||||
r={radius}
|
||||
stroke="currentColor"
|
||||
strokeWidth={actualStroke}
|
||||
fill="none"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
className="text-primary transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
{showLabel && (
|
||||
<span className={cn('absolute font-semibold', fontSize)}>
|
||||
{Math.round(percentage)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CodeProps {
|
||||
children: ReactNode
|
||||
inline?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Code({ children, inline = true, className }: CodeProps) {
|
||||
if (inline) {
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded bg-muted text-foreground font-mono text-sm',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<pre
|
||||
className={cn(
|
||||
'p-4 rounded-lg bg-muted text-foreground font-mono text-sm overflow-x-auto',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Check } from '@phosphor-icons/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ColorSwatchProps {
|
||||
color: string
|
||||
selected?: boolean
|
||||
onClick?: () => void
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
label?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ColorSwatch({
|
||||
color,
|
||||
selected = false,
|
||||
onClick,
|
||||
size = 'md',
|
||||
label,
|
||||
className
|
||||
}: ColorSwatchProps) {
|
||||
const sizeStyles = {
|
||||
sm: 'w-6 h-6',
|
||||
md: 'w-8 h-8',
|
||||
lg: 'w-10 h-10',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center gap-1', className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'rounded border-2 transition-all flex items-center justify-center',
|
||||
sizeStyles[size],
|
||||
selected ? 'border-primary ring-2 ring-ring ring-offset-2' : 'border-border hover:border-ring',
|
||||
onClick && 'cursor-pointer'
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
aria-label={label || `Color ${color}`}
|
||||
>
|
||||
{selected && <Check className="text-white drop-shadow-lg" weight="bold" />}
|
||||
</button>
|
||||
{label && <span className="text-xs text-muted-foreground">{label}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface CommandOption {
|
||||
value: string
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
onSelect?: () => void
|
||||
}
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
placeholder?: string
|
||||
emptyMessage?: string
|
||||
groups: {
|
||||
heading?: string
|
||||
items: CommandOption[]
|
||||
}[]
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
open,
|
||||
onOpenChange,
|
||||
placeholder = 'Type a command or search...',
|
||||
emptyMessage = 'No results found.',
|
||||
groups,
|
||||
}: CommandPaletteProps) {
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={onOpenChange}>
|
||||
<CommandInput placeholder={placeholder} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||
{groups.map((group, groupIndex) => (
|
||||
<CommandGroup key={groupIndex} heading={group.heading}>
|
||||
{group.items.map((item) => (
|
||||
<CommandItem
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
onSelect={() => {
|
||||
item.onSelect?.()
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
{item.icon && <span className="mr-2">{item.icon}</span>}
|
||||
<span>{item.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { CheckCircle } from '@phosphor-icons/react'
|
||||
|
||||
interface CompletionCardProps {
|
||||
completionScore: number
|
||||
completionMessage: string
|
||||
isReadyToExport: boolean
|
||||
}
|
||||
|
||||
export function CompletionCard({
|
||||
completionScore,
|
||||
completionMessage,
|
||||
isReadyToExport
|
||||
}: CompletionCardProps) {
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-primary/10 to-accent/10 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CheckCircle size={24} weight="duotone" className="text-primary" />
|
||||
Project Completeness
|
||||
</CardTitle>
|
||||
<CardDescription>Overall progress of your application</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-4xl font-bold">{completionScore}%</span>
|
||||
<Badge variant={isReadyToExport ? 'default' : 'secondary'} className="text-sm">
|
||||
{isReadyToExport ? 'Ready to Export' : 'In Progress'}
|
||||
</Badge>
|
||||
</div>
|
||||
<Progress value={completionScore} className="h-3" />
|
||||
<p className="text-sm text-muted-foreground">{completionMessage}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { ComponentDefinition } from '@/lib/component-definition-types'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import * as Icons from '@phosphor-icons/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ComponentPaletteItemProps {
|
||||
component: ComponentDefinition
|
||||
onDragStart: (component: ComponentDefinition, e: React.DragEvent) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ComponentPaletteItem({ component, onDragStart, className }: ComponentPaletteItemProps) {
|
||||
const IconComponent = (Icons as any)[component.icon] || Icons.Cube
|
||||
|
||||
return (
|
||||
<Card
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(component, e)}
|
||||
className={cn(
|
||||
'p-3 cursor-move hover:bg-accent/50 hover:border-accent transition-all',
|
||||
'flex flex-col items-center gap-2 text-center',
|
||||
'hover:scale-105 active:scale-95',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<IconComponent className="w-6 h-6 text-primary" weight="duotone" />
|
||||
<span className="text-xs font-medium text-foreground">{component.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{component.type}</span>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { UIComponent } from '@/types/json-ui'
|
||||
import { getComponentDef } from '@/lib/component-definition-utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import * as Icons from '@phosphor-icons/react'
|
||||
|
||||
interface ComponentTreeNodeProps {
|
||||
component: UIComponent
|
||||
isSelected: boolean
|
||||
isHovered: boolean
|
||||
isDraggedOver: boolean
|
||||
dropPosition: 'before' | 'after' | 'inside' | null
|
||||
onSelect: () => void
|
||||
onHover: () => void
|
||||
onHoverEnd: () => void
|
||||
onDragStart: (e: React.DragEvent) => void
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDragLeave: (e: React.DragEvent) => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
depth?: number
|
||||
hasChildren?: boolean
|
||||
isExpanded?: boolean
|
||||
onToggleExpand?: () => void
|
||||
}
|
||||
|
||||
export function ComponentTreeNode({
|
||||
component,
|
||||
isSelected,
|
||||
isHovered,
|
||||
isDraggedOver,
|
||||
dropPosition,
|
||||
onSelect,
|
||||
onHover,
|
||||
onHoverEnd,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
depth = 0,
|
||||
hasChildren = false,
|
||||
isExpanded = false,
|
||||
onToggleExpand,
|
||||
}: ComponentTreeNodeProps) {
|
||||
const def = getComponentDef(component.type)
|
||||
const IconComponent = def ? (Icons as any)[def.icon] || Icons.Cube : Icons.Cube
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{isDraggedOver && dropPosition === 'before' && (
|
||||
<div className="absolute -top-0.5 left-0 right-0 h-0.5 bg-accent" />
|
||||
)}
|
||||
|
||||
<div
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSelect()
|
||||
}}
|
||||
onMouseEnter={onHover}
|
||||
onMouseLeave={onHoverEnd}
|
||||
style={{ paddingLeft: `${depth * 16}px` }}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 text-sm cursor-pointer',
|
||||
'hover:bg-muted/50 transition-colors',
|
||||
'border-l-2 border-transparent',
|
||||
isSelected && 'bg-accent/20 border-l-accent',
|
||||
isHovered && !isSelected && 'bg-muted/30',
|
||||
isDraggedOver && dropPosition === 'inside' && 'bg-primary/10'
|
||||
)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleExpand?.()
|
||||
}}
|
||||
className="hover:text-accent"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<Icons.CaretDown className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<Icons.CaretRight className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-3" />
|
||||
)}
|
||||
<IconComponent className="w-4 h-4 text-primary" weight="duotone" />
|
||||
<span className="flex-1 text-foreground truncate">{def?.label || component.type}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">{component.id}</span>
|
||||
</div>
|
||||
|
||||
{isDraggedOver && dropPosition === 'after' && (
|
||||
<div className="absolute -bottom-0.5 left-0 right-0 h-0.5 bg-accent" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Button, ButtonProps } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ConfirmButtonProps extends Omit<ButtonProps, 'onClick'> {
|
||||
onConfirm: () => void | Promise<void>
|
||||
confirmText?: string
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function ConfirmButton({
|
||||
onConfirm,
|
||||
confirmText = 'Are you sure?',
|
||||
isLoading,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ConfirmButtonProps) {
|
||||
const handleClick = async () => {
|
||||
if (window.confirm(confirmText)) {
|
||||
await onConfirm()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
disabled={isLoading}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? 'Loading...' : children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ContainerProps {
|
||||
children: ReactNode
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-screen-sm',
|
||||
md: 'max-w-screen-md',
|
||||
lg: 'max-w-screen-lg',
|
||||
xl: 'max-w-screen-xl',
|
||||
full: 'max-w-full',
|
||||
}
|
||||
|
||||
export function Container({ children, size = 'xl', className }: ContainerProps) {
|
||||
return (
|
||||
<div className={cn('mx-auto px-4 sm:px-6 lg:px-8', sizeClasses[size], className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import {
|
||||
ContextMenu as ShadcnContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
} from '@/components/ui/context-menu'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export interface ContextMenuItemType {
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
shortcut?: string
|
||||
onSelect?: () => void
|
||||
disabled?: boolean
|
||||
separator?: boolean
|
||||
submenu?: ContextMenuItemType[]
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
trigger: ReactNode
|
||||
items: ContextMenuItemType[]
|
||||
}
|
||||
|
||||
export function ContextMenu({ trigger, items }: ContextMenuProps) {
|
||||
const renderItems = (menuItems: ContextMenuItemType[]) => {
|
||||
return menuItems.map((item, index) => {
|
||||
if (item.separator) {
|
||||
return <ContextMenuSeparator key={`separator-${index}`} />
|
||||
}
|
||||
|
||||
if (item.submenu && item.submenu.length > 0) {
|
||||
return (
|
||||
<ContextMenuSub key={index}>
|
||||
<ContextMenuSubTrigger>
|
||||
{item.icon && <span className="mr-2">{item.icon}</span>}
|
||||
{item.label}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
{renderItems(item.submenu)}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenuItem
|
||||
key={index}
|
||||
onSelect={item.onSelect}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon && <span className="mr-2">{item.icon}</span>}
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.shortcut && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{item.shortcut}
|
||||
</span>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ShadcnContextMenu>
|
||||
<ContextMenuTrigger asChild>{trigger}</ContextMenuTrigger>
|
||||
<ContextMenuContent>{renderItems(items)}</ContextMenuContent>
|
||||
</ShadcnContextMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Copy, Check } from '@phosphor-icons/react'
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CopyButtonProps {
|
||||
text: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CopyButton({ text, size = 'md', className }: CopyButtonProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'p-1',
|
||||
md: 'p-2',
|
||||
lg: 'p-3',
|
||||
}
|
||||
|
||||
const iconSize = {
|
||||
sm: 12,
|
||||
md: 16,
|
||||
lg: 20,
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'rounded-md transition-colors',
|
||||
copied
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
sizeStyles[size],
|
||||
className
|
||||
)}
|
||||
aria-label={copied ? 'Copied' : 'Copy to clipboard'}
|
||||
>
|
||||
{copied ? (
|
||||
<Check size={iconSize[size]} weight="bold" />
|
||||
) : (
|
||||
<Copy size={iconSize[size]} />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CountBadgeProps {
|
||||
count: number
|
||||
max?: number
|
||||
variant?: 'default' | 'secondary' | 'destructive' | 'outline'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CountBadge({ count, max, variant = 'default', className }: CountBadgeProps) {
|
||||
const displayValue = max && count > max ? `${max}+` : count.toString()
|
||||
|
||||
if (count === 0) return null
|
||||
|
||||
return (
|
||||
<Badge variant={variant} className={cn('ml-2 px-2 py-0.5 text-xs', className)}>
|
||||
{displayValue}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface DataListProps {
|
||||
items: any[]
|
||||
renderItem?: (item: any, index: number) => ReactNode
|
||||
emptyMessage?: string
|
||||
className?: string
|
||||
itemClassName?: string
|
||||
itemKey?: string
|
||||
}
|
||||
|
||||
export function DataList({
|
||||
items,
|
||||
renderItem,
|
||||
emptyMessage = 'No items',
|
||||
className,
|
||||
itemClassName,
|
||||
itemKey,
|
||||
}: DataListProps) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderFallbackItem = (item: any) => {
|
||||
if (itemKey && item && typeof item === 'object') {
|
||||
const value = item[itemKey]
|
||||
if (value !== undefined && value !== null) {
|
||||
return typeof value === 'string' || typeof value === 'number'
|
||||
? value
|
||||
: JSON.stringify(value)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof item === 'string' || typeof item === 'number') {
|
||||
return item
|
||||
}
|
||||
|
||||
return JSON.stringify(item)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className={cn('transition-colors', itemClassName)}>
|
||||
{renderItem ? renderItem(item, index) : renderFallbackItem(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { DataSourceType } from '@/types/json-ui'
|
||||
import { Database, File } from '@phosphor-icons/react'
|
||||
|
||||
interface DataSourceBadgeProps {
|
||||
type: DataSourceType
|
||||
className?: string
|
||||
}
|
||||
|
||||
const dataSourceConfig = {
|
||||
kv: {
|
||||
icon: Database,
|
||||
label: 'KV Storage',
|
||||
className: 'bg-accent/20 text-accent border-accent/30'
|
||||
},
|
||||
static: {
|
||||
icon: File,
|
||||
label: 'Static',
|
||||
className: 'bg-muted text-muted-foreground border-border'
|
||||
}
|
||||
}
|
||||
|
||||
export function DataSourceBadge({ type, className = '' }: DataSourceBadgeProps) {
|
||||
const config = dataSourceConfig[type]
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<Badge className={`flex items-center gap-1 ${config.className} ${className}`} variant="outline">
|
||||
<Icon className="w-3 h-3" weight="bold" />
|
||||
<span>{config.label}</span>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export interface Column<T> {
|
||||
key: string
|
||||
header: string | ReactNode
|
||||
cell?: (item: T) => ReactNode
|
||||
sortable?: boolean
|
||||
width?: string
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
data: T[]
|
||||
columns: Column<T>[]
|
||||
onRowClick?: (item: T) => void
|
||||
emptyMessage?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DataTable<T extends Record<string, any>>({
|
||||
data,
|
||||
columns,
|
||||
onRowClick,
|
||||
emptyMessage = 'No data available',
|
||||
className,
|
||||
}: DataTableProps<T>) {
|
||||
return (
|
||||
<div className={cn('rounded-md border', className)}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<TableHead
|
||||
key={column.key}
|
||||
style={{ width: column.width }}
|
||||
className={cn(column.sortable && 'cursor-pointer select-none')}
|
||||
>
|
||||
{column.header}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="text-center py-8 text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.map((item, rowIndex) => (
|
||||
<TableRow
|
||||
key={rowIndex}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
className={cn(onRowClick && 'cursor-pointer')}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<TableCell key={column.key}>
|
||||
{column.cell ? column.cell(item) : item[column.key]}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { CalendarBlank } from '@phosphor-icons/react'
|
||||
import { format } from 'date-fns'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface DatePickerProps {
|
||||
value?: Date
|
||||
onChange: (date: Date | undefined) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Pick a date',
|
||||
disabled,
|
||||
className,
|
||||
}: DatePickerProps) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'w-full justify-start text-left font-normal',
|
||||
!value && 'text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CalendarBlank className="mr-2" size={16} />
|
||||
{value ? format(value, 'PPP') : <span>{placeholder}</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={value}
|
||||
onSelect={onChange}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
interface DetailRowProps {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
value: number
|
||||
}
|
||||
|
||||
export function DetailRow({ icon, label, value }: DetailRowProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2 border-b border-border last:border-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
</div>
|
||||
<Badge variant="secondary">{value}</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface DividerProps {
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
className?: string
|
||||
decorative?: boolean
|
||||
}
|
||||
|
||||
export function Divider({
|
||||
orientation = 'horizontal',
|
||||
className,
|
||||
decorative = true
|
||||
}: DividerProps) {
|
||||
return (
|
||||
<div
|
||||
role={decorative ? 'presentation' : 'separator'}
|
||||
aria-orientation={orientation}
|
||||
className={cn(
|
||||
'bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'w-[1px] h-full',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface DotProps {
|
||||
variant?: 'default' | 'primary' | 'accent' | 'success' | 'warning' | 'error'
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg'
|
||||
pulse?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-muted-foreground',
|
||||
primary: 'bg-primary',
|
||||
accent: 'bg-accent',
|
||||
success: 'bg-green-500',
|
||||
warning: 'bg-yellow-500',
|
||||
error: 'bg-destructive',
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'w-1.5 h-1.5',
|
||||
sm: 'w-2 h-2',
|
||||
md: 'w-3 h-3',
|
||||
lg: 'w-4 h-4',
|
||||
}
|
||||
|
||||
export function Dot({
|
||||
variant = 'default',
|
||||
size = 'sm',
|
||||
pulse = false,
|
||||
className
|
||||
}: DotProps) {
|
||||
return (
|
||||
<span className="relative inline-flex">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block rounded-full',
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
/>
|
||||
{pulse && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inline-flex rounded-full opacity-75 animate-ping',
|
||||
variantClasses[variant],
|
||||
sizeClasses[size]
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { X } from '@phosphor-icons/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface DrawerProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
position?: 'left' | 'right' | 'top' | 'bottom'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
showCloseButton?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Drawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
position = 'right',
|
||||
size = 'md',
|
||||
showCloseButton = true,
|
||||
className,
|
||||
}: DrawerProps) {
|
||||
if (!isOpen) return null
|
||||
|
||||
const positionStyles = {
|
||||
left: 'left-0 top-0 h-full',
|
||||
right: 'right-0 top-0 h-full',
|
||||
top: 'top-0 left-0 w-full',
|
||||
bottom: 'bottom-0 left-0 w-full',
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: position === 'left' || position === 'right' ? 'w-64' : 'h-64',
|
||||
md: position === 'left' || position === 'right' ? 'w-96' : 'h-96',
|
||||
lg: position === 'left' || position === 'right' ? 'w-[600px]' : 'h-[600px]',
|
||||
}
|
||||
|
||||
const slideAnimation = {
|
||||
left: 'animate-in slide-in-from-left',
|
||||
right: 'animate-in slide-in-from-right',
|
||||
top: 'animate-in slide-in-from-top',
|
||||
bottom: 'animate-in slide-in-from-bottom',
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm animate-in fade-in-0"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed z-50 bg-card border border-border shadow-lg',
|
||||
positionStyles[position],
|
||||
sizeStyles[size],
|
||||
slideAnimation[position],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{(title || showCloseButton) && (
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
{title && <h2 className="text-lg font-semibold">{title}</h2>}
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-auto p-1 rounded-md hover:bg-accent transition-colors"
|
||||
aria-label="Close drawer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6 overflow-auto h-full">{children}</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface EmptyMessageProps {
|
||||
icon?: React.ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
action?: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EmptyMessage({ icon, title, description, action, className }: EmptyMessageProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex flex-col items-center justify-center text-center p-8 rounded-lg border border-dashed bg-muted/20',
|
||||
className
|
||||
)}>
|
||||
{icon && (
|
||||
<div className="mb-4 text-muted-foreground/50">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold mb-2">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground mb-4 max-w-sm">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{action && (
|
||||
<Button onClick={action.onClick} size="sm">
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export interface EmptyStateProps {
|
||||
icon?: ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
action?: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
children,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex flex-col items-center justify-center gap-4 py-12 px-6 text-center',
|
||||
className
|
||||
)}>
|
||||
{icon && (
|
||||
<div className="text-muted-foreground text-4xl opacity-50">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{action && (
|
||||
<Button onClick={action.onClick} className="mt-2">
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
interface EmptyStateIconProps {
|
||||
icon: React.ReactNode
|
||||
variant?: 'default' | 'muted'
|
||||
}
|
||||
|
||||
export function EmptyStateIcon({ icon, variant = 'muted' }: EmptyStateIconProps) {
|
||||
const variantClasses = {
|
||||
default: 'from-primary/20 to-accent/20 text-primary',
|
||||
muted: 'from-muted to-muted/50 text-muted-foreground',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`w-16 h-16 rounded-full bg-gradient-to-br ${variantClasses[variant]} flex items-center justify-center`}>
|
||||
{icon}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
interface ErrorBadgeProps {
|
||||
count: number
|
||||
variant?: 'default' | 'destructive'
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
export function ErrorBadge({ count, variant = 'destructive', size = 'md' }: ErrorBadgeProps) {
|
||||
if (count === 0) return null
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-5 w-5 text-[10px]',
|
||||
md: 'h-6 w-6 text-xs',
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={variant}
|
||||
className={`${sizeClasses[size]} p-0 flex items-center justify-center absolute -top-1 -right-1`}
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { FileCode, FileJs, FilePlus } from '@phosphor-icons/react'
|
||||
|
||||
interface FileIconProps {
|
||||
type?: 'code' | 'json' | 'plus'
|
||||
size?: number
|
||||
weight?: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FileIcon({ type = 'code', size = 20, weight = 'regular', className = '' }: FileIconProps) {
|
||||
const iconMap = {
|
||||
code: FileCode,
|
||||
json: FileJs,
|
||||
plus: FilePlus,
|
||||
}
|
||||
|
||||
const IconComponent = iconMap[type]
|
||||
return <IconComponent size={size} weight={weight} className={className} />
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { UploadSimple, X } from '@phosphor-icons/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface FileUploadProps {
|
||||
accept?: string
|
||||
multiple?: boolean
|
||||
maxSize?: number
|
||||
onFilesSelected: (files: File[]) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FileUpload({
|
||||
accept,
|
||||
multiple = false,
|
||||
maxSize,
|
||||
onFilesSelected,
|
||||
disabled = false,
|
||||
className
|
||||
}: FileUploadProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
|
||||
|
||||
const handleFiles = (files: FileList | null) => {
|
||||
if (!files) return
|
||||
|
||||
const fileArray = Array.from(files)
|
||||
const validFiles = fileArray.filter(file => {
|
||||
if (maxSize && file.size > maxSize) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
setSelectedFiles(validFiles)
|
||||
onFilesSelected(validFiles)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
if (!disabled) {
|
||||
handleFiles(e.dataTransfer.files)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
if (!disabled) {
|
||||
setIsDragging(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
const newFiles = selectedFiles.filter((_, i) => i !== index)
|
||||
setSelectedFiles(newFiles)
|
||||
onFilesSelected(newFiles)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
<label
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer transition-colors',
|
||||
isDragging && 'border-primary bg-primary/5',
|
||||
!isDragging && 'border-border bg-muted/30 hover:bg-muted/50',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<UploadSimple className="w-8 h-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">Click to upload</span> or drag and drop
|
||||
</p>
|
||||
{accept && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{accept.split(',').join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{maxSize && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Max size: {(maxSize / 1024 / 1024).toFixed(1)}MB
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
onChange={(e) => handleFiles(e.target.files)}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{selectedFiles.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 bg-muted rounded-lg"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{file.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(file.size / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(index)}
|
||||
className="ml-2 p-1 hover:bg-background rounded transition-colors"
|
||||
aria-label="Remove file"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { MagnifyingGlass, X } from '@phosphor-icons/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface FilterInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FilterInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Filter...',
|
||||
className,
|
||||
}: FilterInputProps) {
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<MagnifyingGlass
|
||||
className={cn(
|
||||
'absolute left-3 top-1/2 -translate-y-1/2 transition-colors',
|
||||
isFocused ? 'text-primary' : 'text-muted-foreground'
|
||||
)}
|
||||
size={16}
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
onClick={() => onChange('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
type="button"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface FlexProps {
|
||||
children: ReactNode
|
||||
direction?: 'row' | 'col' | 'row-reverse' | 'col-reverse'
|
||||
align?: 'start' | 'center' | 'end' | 'stretch' | 'baseline'
|
||||
justify?: 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly'
|
||||
gap?: 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
wrap?: 'wrap' | 'nowrap' | 'wrap-reverse'
|
||||
grow?: boolean
|
||||
shrink?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const directionClasses = {
|
||||
row: 'flex-row',
|
||||
col: 'flex-col',
|
||||
'row-reverse': 'flex-row-reverse',
|
||||
'col-reverse': 'flex-col-reverse',
|
||||
}
|
||||
|
||||
const alignClasses = {
|
||||
start: 'items-start',
|
||||
center: 'items-center',
|
||||
end: 'items-end',
|
||||
stretch: 'items-stretch',
|
||||
baseline: 'items-baseline',
|
||||
}
|
||||
|
||||
const justifyClasses = {
|
||||
start: 'justify-start',
|
||||
center: 'justify-center',
|
||||
end: 'justify-end',
|
||||
between: 'justify-between',
|
||||
around: 'justify-around',
|
||||
evenly: 'justify-evenly',
|
||||
}
|
||||
|
||||
const gapClasses = {
|
||||
none: 'gap-0',
|
||||
xs: 'gap-1',
|
||||
sm: 'gap-2',
|
||||
md: 'gap-4',
|
||||
lg: 'gap-6',
|
||||
xl: 'gap-8',
|
||||
}
|
||||
|
||||
const wrapClasses = {
|
||||
wrap: 'flex-wrap',
|
||||
nowrap: 'flex-nowrap',
|
||||
'wrap-reverse': 'flex-wrap-reverse',
|
||||
}
|
||||
|
||||
export function Flex({
|
||||
children,
|
||||
direction = 'row',
|
||||
align = 'stretch',
|
||||
justify = 'start',
|
||||
gap = 'md',
|
||||
wrap = 'nowrap',
|
||||
grow = false,
|
||||
shrink = false,
|
||||
className,
|
||||
}: FlexProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex',
|
||||
directionClasses[direction],
|
||||
alignClasses[align],
|
||||
justifyClasses[justify],
|
||||
gapClasses[gap],
|
||||
wrapClasses[wrap],
|
||||
grow && 'flex-grow',
|
||||
shrink && 'flex-shrink',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import {
|
||||
Form as ShadcnForm,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface FormProps {
|
||||
form: UseFormReturn<any>
|
||||
onSubmit: (values: any) => void | Promise<void>
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Form({ form, onSubmit, children, className }: FormProps) {
|
||||
return (
|
||||
<ShadcnForm {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className={className}>
|
||||
{children}
|
||||
</form>
|
||||
</ShadcnForm>
|
||||
)
|
||||
}
|
||||
|
||||
export { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage }
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface GlowCardProps {
|
||||
children: ReactNode
|
||||
glowColor?: 'primary' | 'accent' | 'success' | 'warning' | 'error'
|
||||
intensity?: 'low' | 'medium' | 'high'
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export function GlowCard({
|
||||
children,
|
||||
glowColor = 'primary',
|
||||
intensity = 'medium',
|
||||
className,
|
||||
onClick,
|
||||
}: GlowCardProps) {
|
||||
const glowClasses = {
|
||||
primary: {
|
||||
low: 'shadow-primary/10',
|
||||
medium: 'shadow-primary/20 hover:shadow-primary/30',
|
||||
high: 'shadow-primary/30 hover:shadow-primary/50',
|
||||
},
|
||||
accent: {
|
||||
low: 'shadow-accent/10',
|
||||
medium: 'shadow-accent/20 hover:shadow-accent/30',
|
||||
high: 'shadow-accent/30 hover:shadow-accent/50',
|
||||
},
|
||||
success: {
|
||||
low: 'shadow-green-500/10',
|
||||
medium: 'shadow-green-500/20 hover:shadow-green-500/30',
|
||||
high: 'shadow-green-500/30 hover:shadow-green-500/50',
|
||||
},
|
||||
warning: {
|
||||
low: 'shadow-yellow-500/10',
|
||||
medium: 'shadow-yellow-500/20 hover:shadow-yellow-500/30',
|
||||
high: 'shadow-yellow-500/30 hover:shadow-yellow-500/50',
|
||||
},
|
||||
error: {
|
||||
low: 'shadow-red-500/10',
|
||||
medium: 'shadow-red-500/20 hover:shadow-red-500/30',
|
||||
high: 'shadow-red-500/30 hover:shadow-red-500/50',
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'transition-all duration-300',
|
||||
'shadow-lg',
|
||||
glowClasses[glowColor][intensity],
|
||||
onClick && 'cursor-pointer hover:scale-[1.02]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface GridProps {
|
||||
children: ReactNode
|
||||
cols?: 1 | 2 | 3 | 4 | 6 | 12
|
||||
gap?: 1 | 2 | 3 | 4 | 6 | 8
|
||||
className?: string
|
||||
}
|
||||
|
||||
const colsClasses = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
|
||||
6: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-6',
|
||||
12: 'grid-cols-3 md:grid-cols-6 lg:grid-cols-12',
|
||||
}
|
||||
|
||||
const gapClasses = {
|
||||
1: 'gap-1',
|
||||
2: 'gap-2',
|
||||
3: 'gap-3',
|
||||
4: 'gap-4',
|
||||
6: 'gap-6',
|
||||
8: 'gap-8',
|
||||
}
|
||||
|
||||
export function Grid({ children, cols = 1, gap = 4, className = '' }: GridProps) {
|
||||
return (
|
||||
<div className={`grid ${colsClasses[cols]} ${gapClasses[gap]} ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { ReactNode, createElement } from 'react'
|
||||
|
||||
interface HeadingProps {
|
||||
children: ReactNode
|
||||
level?: 1 | 2 | 3 | 4 | 5 | 6
|
||||
className?: string
|
||||
}
|
||||
|
||||
const levelClasses = {
|
||||
1: 'text-4xl font-bold tracking-tight',
|
||||
2: 'text-3xl font-semibold tracking-tight',
|
||||
3: 'text-2xl font-semibold tracking-tight',
|
||||
4: 'text-xl font-semibold',
|
||||
5: 'text-lg font-medium',
|
||||
6: 'text-base font-medium',
|
||||
}
|
||||
|
||||
export function Heading({ children, level = 1, className = '' }: HeadingProps) {
|
||||
return createElement(
|
||||
`h${level}`,
|
||||
{ className: `${levelClasses[level]} ${className}` },
|
||||
children
|
||||
)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface HelperTextProps {
|
||||
children: ReactNode
|
||||
variant?: 'default' | 'error' | 'success'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const variantClasses = {
|
||||
default: 'text-muted-foreground',
|
||||
error: 'text-destructive',
|
||||
success: 'text-green-600',
|
||||
}
|
||||
|
||||
export function HelperText({ children, variant = 'default', className }: HelperTextProps) {
|
||||
return (
|
||||
<p className={cn('text-xs mt-1', variantClasses[variant], className)}>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import {
|
||||
HoverCard as ShadcnHoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/hover-card'
|
||||
import { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface HoverCardProps {
|
||||
trigger: ReactNode
|
||||
children: ReactNode
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
align?: 'start' | 'center' | 'end'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function HoverCard({
|
||||
trigger,
|
||||
children,
|
||||
side = 'bottom',
|
||||
align = 'center',
|
||||
className,
|
||||
}: HoverCardProps) {
|
||||
return (
|
||||
<ShadcnHoverCard>
|
||||
<HoverCardTrigger asChild>{trigger}</HoverCardTrigger>
|
||||
<HoverCardContent side={side} align={align} className={cn(className)}>
|
||||
{children}
|
||||
</HoverCardContent>
|
||||
</ShadcnHoverCard>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
interface IconButtonProps {
|
||||
icon: React.ReactNode
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
variant?: 'default' | 'secondary' | 'outline' | 'ghost' | 'destructive'
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon'
|
||||
title?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
({ icon, onClick, disabled, variant = 'ghost', size = 'icon', title, className }, ref) => {
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={className}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
IconButton.displayName = 'IconButton'
|
||||
@@ -1,36 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface IconTextProps {
|
||||
icon: React.ReactNode
|
||||
children: React.ReactNode
|
||||
gap?: 'sm' | 'md' | 'lg'
|
||||
align?: 'start' | 'center' | 'end'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function IconText({
|
||||
icon,
|
||||
children,
|
||||
gap = 'md',
|
||||
align = 'center',
|
||||
className
|
||||
}: IconTextProps) {
|
||||
const gapStyles = {
|
||||
sm: 'gap-1',
|
||||
md: 'gap-2',
|
||||
lg: 'gap-3',
|
||||
}
|
||||
|
||||
const alignStyles = {
|
||||
start: 'items-start',
|
||||
center: 'items-center',
|
||||
end: 'items-end',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex', gapStyles[gap], alignStyles[align], className)}>
|
||||
<span className="flex-shrink-0">{icon}</span>
|
||||
<span className="flex-1">{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
interface IconWrapperProps {
|
||||
icon: React.ReactNode
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
variant?: 'default' | 'muted' | 'primary' | 'destructive'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function IconWrapper({
|
||||
icon,
|
||||
size = 'md',
|
||||
variant = 'default',
|
||||
className = ''
|
||||
}: IconWrapperProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-5 h-5',
|
||||
lg: 'w-6 h-6',
|
||||
}
|
||||
|
||||
const variantClasses = {
|
||||
default: 'text-foreground',
|
||||
muted: 'text-muted-foreground',
|
||||
primary: 'text-primary',
|
||||
destructive: 'text-destructive',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center justify-center ${sizeClasses[size]} ${variantClasses[variant]} ${className}`}>
|
||||
{icon}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ImageProps {
|
||||
src: string
|
||||
alt: string
|
||||
width?: number | string
|
||||
height?: number | string
|
||||
fit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'
|
||||
fallback?: string
|
||||
className?: string
|
||||
onLoad?: () => void
|
||||
onError?: () => void
|
||||
}
|
||||
|
||||
export function Image({
|
||||
src,
|
||||
alt,
|
||||
width,
|
||||
height,
|
||||
fit = 'cover',
|
||||
fallback,
|
||||
className,
|
||||
onLoad,
|
||||
onError
|
||||
}: ImageProps) {
|
||||
const [error, setError] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const handleLoad = () => {
|
||||
setLoading(false)
|
||||
onLoad?.()
|
||||
}
|
||||
|
||||
const handleError = () => {
|
||||
setError(true)
|
||||
setLoading(false)
|
||||
onError?.()
|
||||
}
|
||||
|
||||
const imgSrc = error && fallback ? fallback : src
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
style={{
|
||||
width: typeof width === 'number' ? `${width}px` : width,
|
||||
height: typeof height === 'number' ? `${height}px` : height,
|
||||
}}
|
||||
>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 bg-muted animate-pulse" />
|
||||
)}
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={alt}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
className={cn(
|
||||
'w-full h-full transition-opacity',
|
||||
loading ? 'opacity-0' : 'opacity-100',
|
||||
`object-${fit}`
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Info, Warning, CheckCircle, XCircle } from '@phosphor-icons/react'
|
||||
|
||||
interface InfoBoxProps {
|
||||
type?: 'info' | 'warning' | 'success' | 'error'
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
info: Info,
|
||||
warning: Warning,
|
||||
success: CheckCircle,
|
||||
error: XCircle,
|
||||
}
|
||||
|
||||
const variantClasses = {
|
||||
info: 'bg-blue-500/10 border-blue-500/20 text-blue-700 dark:text-blue-300',
|
||||
warning: 'bg-yellow-500/10 border-yellow-500/20 text-yellow-700 dark:text-yellow-300',
|
||||
success: 'bg-green-500/10 border-green-500/20 text-green-700 dark:text-green-300',
|
||||
error: 'bg-destructive/10 border-destructive/20 text-destructive',
|
||||
}
|
||||
|
||||
export function InfoBox({ type = 'info', title, children, className }: InfoBoxProps) {
|
||||
const Icon = iconMap[type]
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex gap-3 p-4 rounded-lg border',
|
||||
variantClasses[type],
|
||||
className
|
||||
)}>
|
||||
<Icon size={20} weight="fill" className="flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
{title && <div className="font-semibold mb-1">{title}</div>}
|
||||
<div className="text-sm opacity-90">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface InfoPanelProps {
|
||||
children: ReactNode
|
||||
variant?: 'info' | 'warning' | 'success' | 'error' | 'default'
|
||||
title?: string
|
||||
icon?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-card border-border',
|
||||
info: 'bg-blue-500/10 border-blue-500/20 text-blue-700 dark:text-blue-300',
|
||||
warning: 'bg-yellow-500/10 border-yellow-500/20 text-yellow-700 dark:text-yellow-300',
|
||||
success: 'bg-green-500/10 border-green-500/20 text-green-700 dark:text-green-300',
|
||||
error: 'bg-red-500/10 border-red-500/20 text-red-700 dark:text-red-300',
|
||||
}
|
||||
|
||||
export function InfoPanel({
|
||||
children,
|
||||
variant = 'default',
|
||||
title,
|
||||
icon,
|
||||
className,
|
||||
}: InfoPanelProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border p-4',
|
||||
variantClasses[variant],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{(title || icon) && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{icon && <div className="flex-shrink-0">{icon}</div>}
|
||||
{title && <div className="font-semibold">{title}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { forwardRef } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
error?: boolean
|
||||
helperText?: string
|
||||
label?: string
|
||||
leftIcon?: React.ReactNode
|
||||
rightIcon?: React.ReactNode
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ error, helperText, label, leftIcon, rightIcon, className, ...props }, ref) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium mb-1.5 text-foreground">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
{leftIcon && (
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
||||
{leftIcon}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border bg-background px-3 py-2 text-sm',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'transition-colors',
|
||||
error ? 'border-destructive focus-visible:ring-destructive' : 'border-input',
|
||||
leftIcon && 'pl-10',
|
||||
rightIcon && 'pr-10',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{rightIcon && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
||||
{rightIcon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{helperText && (
|
||||
<p className={cn('text-xs mt-1.5', error ? 'text-destructive' : 'text-muted-foreground')}>
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Input.displayName = 'Input'
|
||||
@@ -1,21 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface KbdProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Kbd({ children, className }: KbdProps) {
|
||||
return (
|
||||
<kbd
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center px-2 py-1 text-xs font-mono font-semibold',
|
||||
'bg-muted text-foreground border border-border rounded shadow-sm',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface KeyValueProps {
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
className?: string
|
||||
labelClassName?: string
|
||||
valueClassName?: string
|
||||
}
|
||||
|
||||
export function KeyValue({
|
||||
label,
|
||||
value,
|
||||
orientation = 'horizontal',
|
||||
className,
|
||||
labelClassName,
|
||||
valueClassName
|
||||
}: KeyValueProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex gap-2',
|
||||
orientation === 'vertical' ? 'flex-col' : 'flex-row items-center justify-between',
|
||||
className
|
||||
)}>
|
||||
<span className={cn('text-sm text-muted-foreground', labelClassName)}>
|
||||
{label}
|
||||
</span>
|
||||
<span className={cn('text-sm font-medium', valueClassName)}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LabelProps {
|
||||
children: ReactNode
|
||||
htmlFor?: string
|
||||
required?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Label({ children, htmlFor, required, className }: LabelProps) {
|
||||
return (
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className={cn(
|
||||
'text-sm font-medium text-foreground leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{required && <span className="text-destructive ml-1">*</span>}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LinkProps {
|
||||
href: string
|
||||
children: ReactNode
|
||||
variant?: 'default' | 'muted' | 'accent' | 'destructive'
|
||||
external?: boolean
|
||||
className?: string
|
||||
onClick?: (e: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
const variantClasses = {
|
||||
default: 'text-foreground hover:text-primary underline-offset-4 hover:underline',
|
||||
muted: 'text-muted-foreground hover:text-foreground underline-offset-4 hover:underline',
|
||||
accent: 'text-accent hover:text-accent/80 underline-offset-4 hover:underline',
|
||||
destructive: 'text-destructive hover:text-destructive/80 underline-offset-4 hover:underline',
|
||||
}
|
||||
|
||||
export function Link({
|
||||
href,
|
||||
children,
|
||||
variant = 'default',
|
||||
external = false,
|
||||
className,
|
||||
onClick
|
||||
}: LinkProps) {
|
||||
const externalProps = external ? { target: '_blank', rel: 'noopener noreferrer' } : {}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={cn('transition-colors duration-150', variantClasses[variant], className)}
|
||||
onClick={onClick}
|
||||
{...externalProps}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface ListProps<T> {
|
||||
items: T[]
|
||||
renderItem: (item: T, index: number) => ReactNode
|
||||
emptyMessage?: string
|
||||
className?: string
|
||||
itemClassName?: string
|
||||
}
|
||||
|
||||
export function List<T>({
|
||||
items,
|
||||
renderItem,
|
||||
emptyMessage = 'No items to display',
|
||||
className = '',
|
||||
itemClassName = ''
|
||||
}: ListProps<T>) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className={itemClassName}>
|
||||
{renderItem(item, index)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ListItemProps {
|
||||
icon?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
active?: boolean
|
||||
className?: string
|
||||
endContent?: React.ReactNode
|
||||
}
|
||||
|
||||
export function ListItem({ icon, children, onClick, active, className, endContent }: ListItemProps) {
|
||||
const isInteractive = !!onClick
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-md transition-colors',
|
||||
isInteractive && 'cursor-pointer hover:bg-accent',
|
||||
active && 'bg-accent',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
role={isInteractive ? 'button' : undefined}
|
||||
tabIndex={isInteractive ? 0 : undefined}
|
||||
>
|
||||
{icon && <div className="flex-shrink-0 text-muted-foreground">{icon}</div>}
|
||||
<div className="flex-1 min-w-0 text-sm">{children}</div>
|
||||
{endContent && <div className="flex-shrink-0">{endContent}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LiveIndicatorProps {
|
||||
label?: string
|
||||
showLabel?: boolean
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LiveIndicator({
|
||||
label = 'LIVE',
|
||||
showLabel = true,
|
||||
size = 'md',
|
||||
className,
|
||||
}: LiveIndicatorProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'text-xs gap-1.5',
|
||||
md: 'text-sm gap-2',
|
||||
lg: 'text-base gap-2.5',
|
||||
}
|
||||
|
||||
const dotSizeClasses = {
|
||||
sm: 'w-2 h-2',
|
||||
md: 'w-2.5 h-2.5',
|
||||
lg: 'w-3 h-3',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('inline-flex items-center font-medium', sizeClasses[size], className)}>
|
||||
<span className="relative flex">
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inline-flex rounded-full bg-red-500 opacity-75 animate-ping',
|
||||
dotSizeClasses[size]
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex rounded-full bg-red-500',
|
||||
dotSizeClasses[size]
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
{showLabel && (
|
||||
<span className="text-red-500 font-bold tracking-wider">{label}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4 border-2',
|
||||
md: 'w-6 h-6 border-2',
|
||||
lg: 'w-8 h-8 border-3',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-block ${sizeClasses[size]} border-primary border-t-transparent rounded-full animate-spin ${className}`}
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface LoadingStateProps {
|
||||
message?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LoadingState({
|
||||
message = 'Loading...',
|
||||
size = 'md',
|
||||
className
|
||||
}: LoadingStateProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4 border-2',
|
||||
md: 'w-8 h-8 border-3',
|
||||
lg: 'w-12 h-12 border-4',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center gap-3 py-8', className)}>
|
||||
<div className={cn(
|
||||
'border-primary border-t-transparent rounded-full animate-spin',
|
||||
sizeClasses[size]
|
||||
)} />
|
||||
{message && (
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { CaretRight, Check } from '@phosphor-icons/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MenuItem {
|
||||
id: string
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
disabled?: boolean
|
||||
selected?: boolean
|
||||
divider?: boolean
|
||||
danger?: boolean
|
||||
shortcut?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
interface MenuProps {
|
||||
trigger: React.ReactNode
|
||||
items: MenuItem[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Menu({ trigger, items, className }: MenuProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(event.target as Node) &&
|
||||
triggerRef.current &&
|
||||
!triggerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const handleItemClick = (item: MenuItem) => {
|
||||
if (!item.disabled && item.onClick) {
|
||||
item.onClick()
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
<div ref={triggerRef} onClick={() => setIsOpen(!isOpen)}>
|
||||
{trigger}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={cn(
|
||||
'absolute z-50 mt-2 w-56 bg-popover text-popover-foreground border border-border rounded-lg shadow-lg overflow-hidden',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="py-1">
|
||||
{items.map((item, index) => {
|
||||
if (item.divider) {
|
||||
return <div key={index} className="my-1 h-px bg-border" />
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleItemClick(item)}
|
||||
disabled={item.disabled}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between px-3 py-2 text-sm transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
item.disabled && 'opacity-50 cursor-not-allowed',
|
||||
item.danger && 'text-destructive hover:bg-destructive hover:text-destructive-foreground'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.icon && <span className="w-4 h-4">{item.icon}</span>}
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.shortcut && (
|
||||
<span className="text-xs text-muted-foreground">{item.shortcut}</span>
|
||||
)}
|
||||
{item.selected && <Check className="w-4 h-4" />}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user