cmssy-cli 0.21.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/config.d.ts +1 -1
  2. package/dist/cli.js +131 -30
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/build.d.ts +1 -0
  5. package/dist/commands/build.d.ts.map +1 -1
  6. package/dist/commands/build.js +56 -12
  7. package/dist/commands/build.js.map +1 -1
  8. package/dist/commands/create.d.ts.map +1 -1
  9. package/dist/commands/create.js +22 -2
  10. package/dist/commands/create.js.map +1 -1
  11. package/dist/commands/dev.d.ts.map +1 -1
  12. package/dist/commands/dev.js +652 -410
  13. package/dist/commands/dev.js.map +1 -1
  14. package/dist/commands/init.d.ts.map +1 -1
  15. package/dist/commands/init.js +3 -1
  16. package/dist/commands/init.js.map +1 -1
  17. package/dist/commands/migrate.d.ts.map +1 -1
  18. package/dist/commands/migrate.js +3 -1
  19. package/dist/commands/migrate.js.map +1 -1
  20. package/dist/dev-ui/app.js +166 -19
  21. package/dist/dev-ui/index.html +138 -0
  22. package/dist/dev-ui-react/App.tsx +164 -0
  23. package/dist/dev-ui-react/__tests__/previewData.test.ts +193 -0
  24. package/dist/dev-ui-react/components/BlocksList.tsx +232 -0
  25. package/dist/dev-ui-react/components/Editor.tsx +469 -0
  26. package/dist/dev-ui-react/components/Preview.tsx +146 -0
  27. package/dist/dev-ui-react/hooks/useBlocks.ts +80 -0
  28. package/dist/dev-ui-react/index.html +13 -0
  29. package/dist/dev-ui-react/main.tsx +8 -0
  30. package/dist/dev-ui-react/styles.css +856 -0
  31. package/dist/dev-ui-react/types.ts +45 -0
  32. package/dist/types/block-config.d.ts +100 -2
  33. package/dist/types/block-config.d.ts.map +1 -1
  34. package/dist/types/block-config.js +6 -1
  35. package/dist/types/block-config.js.map +1 -1
  36. package/dist/utils/block-config.js +3 -3
  37. package/dist/utils/block-config.js.map +1 -1
  38. package/dist/utils/blocks-meta-cache.d.ts +28 -0
  39. package/dist/utils/blocks-meta-cache.d.ts.map +1 -0
  40. package/dist/utils/blocks-meta-cache.js +72 -0
  41. package/dist/utils/blocks-meta-cache.js.map +1 -0
  42. package/dist/utils/builder.d.ts +3 -0
  43. package/dist/utils/builder.d.ts.map +1 -1
  44. package/dist/utils/builder.js +17 -14
  45. package/dist/utils/builder.js.map +1 -1
  46. package/dist/utils/field-schema.d.ts +2 -0
  47. package/dist/utils/field-schema.d.ts.map +1 -1
  48. package/dist/utils/field-schema.js +21 -4
  49. package/dist/utils/field-schema.js.map +1 -1
  50. package/dist/utils/scanner.d.ts +5 -3
  51. package/dist/utils/scanner.d.ts.map +1 -1
  52. package/dist/utils/scanner.js +23 -16
  53. package/dist/utils/scanner.js.map +1 -1
  54. package/dist/utils/type-generator.d.ts +7 -1
  55. package/dist/utils/type-generator.d.ts.map +1 -1
  56. package/dist/utils/type-generator.js +58 -41
  57. package/dist/utils/type-generator.js.map +1 -1
  58. package/package.json +8 -3
  59. package/dist/commands/deploy.d.ts +0 -9
  60. package/dist/commands/deploy.d.ts.map +0 -1
  61. package/dist/commands/deploy.js +0 -226
  62. package/dist/commands/deploy.js.map +0 -1
  63. package/dist/commands/push.d.ts +0 -9
  64. package/dist/commands/push.d.ts.map +0 -1
  65. package/dist/commands/push.js +0 -199
  66. package/dist/commands/push.js.map +0 -1
  67. package/dist/utils/blockforge-config.d.ts +0 -19
  68. package/dist/utils/blockforge-config.d.ts.map +0 -1
  69. package/dist/utils/blockforge-config.js +0 -19
  70. package/dist/utils/blockforge-config.js.map +0 -1
@@ -0,0 +1,164 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
+ import { BlocksList } from './components/BlocksList';
3
+ import { Preview } from './components/Preview';
4
+ import { Editor } from './components/Editor';
5
+ import { useBlocks, useBlockConfig } from './hooks/useBlocks';
6
+ import { Block } from './types';
7
+ import './styles.css';
8
+
9
+ export function App() {
10
+ const { blocks, loading: blocksLoading } = useBlocks();
11
+ const [selectedBlock, setSelectedBlock] = useState<Block | null>(null);
12
+ const [previewData, setPreviewData] = useState<Record<string, unknown>>({});
13
+ const [currentPage, setCurrentPage] = useState<string | undefined>();
14
+ const [isDirty, setIsDirty] = useState(false);
15
+ // Keep reference to complete config data for merging during save
16
+ const configDataRef = useRef<Record<string, unknown>>({});
17
+
18
+ const { config, loading: configLoading } = useBlockConfig(
19
+ selectedBlock?.name || null,
20
+ selectedBlock?.type || null
21
+ );
22
+
23
+ // Update selected block with loaded config
24
+ useEffect(() => {
25
+ if (selectedBlock && config) {
26
+ const updatedBlock: Block = {
27
+ ...selectedBlock,
28
+ schema: config.schema as Block['schema'],
29
+ pages: config.pages,
30
+ layoutSlots: config.layoutSlots,
31
+ };
32
+ setSelectedBlock(updatedBlock);
33
+
34
+ // Store complete config data in ref for merging during save
35
+ const configData = config.previewData || {};
36
+ configDataRef.current = configData;
37
+
38
+ // Set previewData from config (complete data)
39
+ setPreviewData(configData);
40
+
41
+ // Set first page for templates
42
+ if (config.pages && config.pages.length > 0) {
43
+ setCurrentPage(config.pages[0].slug);
44
+ }
45
+ }
46
+ }, [config, selectedBlock?.name]);
47
+
48
+ // Handle block selection
49
+ const handleSelectBlock = useCallback((block: Block) => {
50
+ setSelectedBlock(block);
51
+ setPreviewData({});
52
+ setCurrentPage(undefined);
53
+ setIsDirty(false);
54
+ configDataRef.current = {};
55
+
56
+ // Update URL based on type
57
+ const url = new URL(window.location.href);
58
+ url.searchParams.delete('block');
59
+ url.searchParams.delete('template');
60
+ if (block.type === 'template') {
61
+ url.searchParams.set('template', block.name);
62
+ } else {
63
+ url.searchParams.set('block', block.name);
64
+ }
65
+ window.history.replaceState({}, '', url.toString());
66
+ }, []);
67
+
68
+ // Handle preview data change - just update state, effect handles saving
69
+ const handlePreviewDataChange = useCallback(
70
+ (newData: Record<string, unknown>) => {
71
+ setPreviewData(newData);
72
+ setIsDirty(true);
73
+ },
74
+ []
75
+ );
76
+
77
+ // Debounced save effect - saves when data changes and is dirty
78
+ useEffect(() => {
79
+ // Don't save if loading, not dirty, no block selected, or config not loaded yet
80
+ // configDataRef must have data to prevent losing fields
81
+ if (configLoading || !isDirty || !selectedBlock || Object.keys(configDataRef.current).length === 0) {
82
+ return;
83
+ }
84
+
85
+ const controller = new AbortController();
86
+ const timeoutId = setTimeout(async () => {
87
+ try {
88
+ // Merge user edits with complete config data to never lose fields
89
+ const dataToSave = { ...configDataRef.current, ...previewData };
90
+
91
+ await fetch(`/api/preview/${selectedBlock.name}`, {
92
+ method: 'POST',
93
+ headers: { 'Content-Type': 'application/json' },
94
+ body: JSON.stringify(dataToSave),
95
+ signal: controller.signal,
96
+ });
97
+ setIsDirty(false);
98
+ } catch (error) {
99
+ if (error instanceof Error && error.name !== 'AbortError') {
100
+ console.error('Failed to save preview data:', error);
101
+ }
102
+ }
103
+ }, 500);
104
+
105
+ // Cleanup: cancel timeout and abort fetch on unmount or dependency change
106
+ return () => {
107
+ clearTimeout(timeoutId);
108
+ controller.abort();
109
+ };
110
+ }, [previewData, selectedBlock, configLoading, isDirty]);
111
+
112
+ // Handle template page navigation
113
+ const handleNavigateToPage = useCallback((pageSlug: string) => {
114
+ setCurrentPage(pageSlug);
115
+ }, []);
116
+
117
+ // Load block/template from URL on mount (runs once when blocks are loaded)
118
+ const urlLoadedRef = useRef(false);
119
+ useEffect(() => {
120
+ if (blocks.length === 0 || urlLoadedRef.current) return;
121
+
122
+ const params = new URLSearchParams(window.location.search);
123
+ const blockName = params.get('block');
124
+ const templateName = params.get('template');
125
+
126
+ if (templateName) {
127
+ const template = blocks.find((b) => b.name === templateName && b.type === 'template');
128
+ if (template) {
129
+ urlLoadedRef.current = true;
130
+ handleSelectBlock(template);
131
+ }
132
+ } else if (blockName) {
133
+ const block = blocks.find((b) => b.name === blockName);
134
+ if (block) {
135
+ urlLoadedRef.current = true;
136
+ handleSelectBlock(block);
137
+ }
138
+ }
139
+ }, [blocks, handleSelectBlock]);
140
+
141
+ return (
142
+ <div className="container">
143
+ <BlocksList
144
+ blocks={blocks}
145
+ selectedBlock={selectedBlock}
146
+ onSelectBlock={handleSelectBlock}
147
+ loading={blocksLoading}
148
+ />
149
+ <Preview
150
+ block={selectedBlock}
151
+ previewData={previewData}
152
+ currentPage={currentPage}
153
+ loading={configLoading}
154
+ />
155
+ <Editor
156
+ block={selectedBlock}
157
+ loading={configLoading}
158
+ previewData={previewData}
159
+ onPreviewDataChange={handlePreviewDataChange}
160
+ onNavigateToPage={handleNavigateToPage}
161
+ />
162
+ </div>
163
+ );
164
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Test for preview data merge behavior
3
+ * Run with: npx vitest run src/dev-ui-react/__tests__/previewData.test.ts
4
+ */
5
+
6
+ import { describe, it, expect } from 'vitest';
7
+
8
+ // Simulating the merge logic from App.tsx
9
+ function mergeForSave(
10
+ configDataRef: Record<string, unknown>,
11
+ previewData: Record<string, unknown>
12
+ ): Record<string, unknown> {
13
+ return { ...configDataRef, ...previewData };
14
+ }
15
+
16
+ // Simulating mergeDefaultsWithPreview from dev.ts
17
+ function mergeDefaultsWithPreview(
18
+ schema: Record<string, any>,
19
+ previewData: Record<string, unknown>
20
+ ): Record<string, unknown> {
21
+ const merged: Record<string, unknown> = { ...previewData };
22
+
23
+ for (const [key, field] of Object.entries(schema)) {
24
+ if (merged[key] === undefined || merged[key] === null) {
25
+ if (field.defaultValue !== undefined) {
26
+ merged[key] = field.defaultValue;
27
+ } else if (field.type === 'repeater') {
28
+ merged[key] = [];
29
+ }
30
+ }
31
+
32
+ if (field.type === 'repeater' && field.schema && Array.isArray(merged[key])) {
33
+ merged[key] = (merged[key] as any[]).map((item: any) => {
34
+ const mergedItem: Record<string, unknown> = { ...item };
35
+ for (const [nestedKey, nestedField] of Object.entries(field.schema as Record<string, any>)) {
36
+ if (mergedItem[nestedKey] === undefined && nestedField.defaultValue !== undefined) {
37
+ mergedItem[nestedKey] = nestedField.defaultValue;
38
+ }
39
+ }
40
+ return mergedItem;
41
+ });
42
+ }
43
+
44
+ if (field.type === 'media' && typeof merged[key] === 'string') {
45
+ merged[key] = { url: merged[key], alt: '' };
46
+ }
47
+ }
48
+
49
+ return merged;
50
+ }
51
+
52
+ describe('Preview Data Management', () => {
53
+ const mockSchema = {
54
+ badge: { type: 'singleLine', label: 'Badge', defaultValue: 'About Us' },
55
+ heading: { type: 'singleLine', label: 'Heading', defaultValue: 'Default Heading' },
56
+ values: {
57
+ type: 'repeater',
58
+ label: 'Values',
59
+ schema: {
60
+ title: { type: 'singleLine', defaultValue: 'Title' },
61
+ description: { type: 'multiLine', defaultValue: 'Description' },
62
+ },
63
+ defaultValue: [
64
+ { title: 'Value 1', description: 'Desc 1' },
65
+ { title: 'Value 2', description: 'Desc 2' },
66
+ ],
67
+ },
68
+ imageUrl: { type: 'media', label: 'Image', defaultValue: 'https://example.com/img.jpg' },
69
+ };
70
+
71
+ const completePreviewData = {
72
+ badge: 'About Us',
73
+ heading: 'Building the future',
74
+ values: [
75
+ { title: 'Innovation', description: 'We innovate' },
76
+ { title: 'Quality', description: 'We deliver quality' },
77
+ ],
78
+ imageUrl: { url: 'https://example.com/team.jpg', alt: 'Team' },
79
+ };
80
+
81
+ describe('mergeDefaultsWithPreview', () => {
82
+ it('should merge defaultValues into empty previewData', () => {
83
+ const result = mergeDefaultsWithPreview(mockSchema, {});
84
+
85
+ expect(result.badge).toBe('About Us');
86
+ expect(result.heading).toBe('Default Heading');
87
+ expect(result.values).toEqual([
88
+ { title: 'Value 1', description: 'Desc 1' },
89
+ { title: 'Value 2', description: 'Desc 2' },
90
+ ]);
91
+ expect(result.imageUrl).toEqual({ url: 'https://example.com/img.jpg', alt: '' });
92
+ });
93
+
94
+ it('should preserve existing previewData values over defaults', () => {
95
+ const result = mergeDefaultsWithPreview(mockSchema, { badge: 'Custom Badge' });
96
+
97
+ expect(result.badge).toBe('Custom Badge');
98
+ expect(result.heading).toBe('Default Heading'); // From default
99
+ });
100
+
101
+ it('should handle repeater arrays correctly', () => {
102
+ const result = mergeDefaultsWithPreview(mockSchema, {
103
+ values: [{ title: 'Custom' }], // Missing description
104
+ });
105
+
106
+ expect(result.values).toEqual([
107
+ { title: 'Custom', description: 'Description' }, // description from default
108
+ ]);
109
+ });
110
+
111
+ it('should convert string media to object', () => {
112
+ const result = mergeDefaultsWithPreview(mockSchema, {
113
+ imageUrl: 'https://custom.com/img.png',
114
+ });
115
+
116
+ expect(result.imageUrl).toEqual({ url: 'https://custom.com/img.png', alt: '' });
117
+ });
118
+ });
119
+
120
+ describe('mergeForSave', () => {
121
+ it('should merge configDataRef with previewData, previewData wins', () => {
122
+ const configDataRef = { ...completePreviewData };
123
+ const previewData = { badge: 'New Badge' };
124
+
125
+ const result = mergeForSave(configDataRef, previewData);
126
+
127
+ expect(result.badge).toBe('New Badge'); // User edit
128
+ expect(result.heading).toBe('Building the future'); // From config
129
+ expect(result.values).toEqual(completePreviewData.values); // From config
130
+ expect(result.imageUrl).toEqual(completePreviewData.imageUrl); // From config
131
+ });
132
+
133
+ it('should preserve all fields when user edits one field', () => {
134
+ const configDataRef = { ...completePreviewData };
135
+ const previewData = { ...completePreviewData, badge: 'Edited Badge' };
136
+
137
+ const result = mergeForSave(configDataRef, previewData);
138
+
139
+ expect(Object.keys(result)).toEqual(Object.keys(completePreviewData));
140
+ expect(result.badge).toBe('Edited Badge');
141
+ expect(result.values).toEqual(completePreviewData.values);
142
+ });
143
+
144
+ it('should NOT lose data when configDataRef is empty (BUG SCENARIO)', () => {
145
+ // This is the bug: if configDataRef is empty, we lose all data
146
+ const configDataRef = {}; // Bug: ref was not set
147
+ const previewData = { badge: 'New Badge' };
148
+
149
+ const result = mergeForSave(configDataRef, previewData);
150
+
151
+ // This will fail - demonstrating the bug
152
+ // If configDataRef is empty, we only get previewData
153
+ expect(Object.keys(result)).toEqual(['badge']);
154
+ });
155
+
156
+ it('should work correctly when configDataRef has complete data', () => {
157
+ const configDataRef = { ...completePreviewData };
158
+ const previewData = { badge: 'New Badge' };
159
+
160
+ const result = mergeForSave(configDataRef, previewData);
161
+
162
+ // With proper configDataRef, all data is preserved
163
+ expect(Object.keys(result).sort()).toEqual(Object.keys(completePreviewData).sort());
164
+ });
165
+ });
166
+
167
+ describe('Full flow simulation', () => {
168
+ it('should simulate complete user edit flow', () => {
169
+ // 1. Server loads preview.json and merges with schema defaults
170
+ const previewJsonContent = { heading: 'Only heading' };
171
+ const serverMerged = mergeDefaultsWithPreview(mockSchema, previewJsonContent);
172
+
173
+ expect(serverMerged.badge).toBe('About Us'); // From default
174
+ expect(serverMerged.heading).toBe('Only heading'); // From preview.json
175
+ expect(serverMerged.values).toBeDefined(); // From default
176
+
177
+ // 2. Frontend receives merged data and stores in configDataRef
178
+ const configDataRef = { ...serverMerged };
179
+
180
+ // 3. User edits badge
181
+ const previewData = { ...serverMerged, badge: 'Edited Badge' };
182
+
183
+ // 4. Save merges configDataRef with previewData
184
+ const dataToSave = mergeForSave(configDataRef, previewData);
185
+
186
+ // 5. All fields should be preserved
187
+ expect(dataToSave.badge).toBe('Edited Badge');
188
+ expect(dataToSave.heading).toBe('Only heading');
189
+ expect(dataToSave.values).toEqual(serverMerged.values);
190
+ expect(Object.keys(dataToSave).length).toBeGreaterThanOrEqual(4);
191
+ });
192
+ });
193
+ });
@@ -0,0 +1,232 @@
1
+ import { useState, useMemo } from 'react';
2
+ import { Block, Filters } from '../types';
3
+
4
+ interface BlocksListProps {
5
+ blocks: Block[];
6
+ selectedBlock: Block | null;
7
+ onSelectBlock: (block: Block) => void;
8
+ loading: boolean;
9
+ }
10
+
11
+ export function BlocksList({ blocks, selectedBlock, onSelectBlock, loading }: BlocksListProps) {
12
+ const [filters, setFilters] = useState<Filters>({
13
+ search: '',
14
+ type: 'all',
15
+ category: '',
16
+ tags: [],
17
+ });
18
+ const [collapsed, setCollapsed] = useState(false);
19
+
20
+ // Extract unique categories and tags
21
+ const { categories, tags } = useMemo(() => {
22
+ const cats = new Set<string>();
23
+ const tgs = new Set<string>();
24
+
25
+ blocks.forEach((block) => {
26
+ if (block.category) cats.add(block.category);
27
+ block.tags?.forEach((tag) => tgs.add(tag));
28
+ });
29
+
30
+ return {
31
+ categories: Array.from(cats).sort(),
32
+ tags: Array.from(tgs).sort(),
33
+ };
34
+ }, [blocks]);
35
+
36
+ // Filter blocks
37
+ const filteredBlocks = useMemo(() => {
38
+ return blocks.filter((block) => {
39
+ // Search filter
40
+ if (filters.search) {
41
+ const search = filters.search.toLowerCase();
42
+ const matches =
43
+ block.name.toLowerCase().includes(search) ||
44
+ block.displayName.toLowerCase().includes(search) ||
45
+ block.description?.toLowerCase().includes(search);
46
+ if (!matches) return false;
47
+ }
48
+
49
+ // Type filter
50
+ if (filters.type !== 'all' && block.type !== filters.type) {
51
+ return false;
52
+ }
53
+
54
+ // Category filter
55
+ if (filters.category && block.category !== filters.category) {
56
+ return false;
57
+ }
58
+
59
+ // Tags filter
60
+ if (filters.tags.length > 0) {
61
+ const hasTag = filters.tags.some((tag) => block.tags?.includes(tag));
62
+ if (!hasTag) return false;
63
+ }
64
+
65
+ return true;
66
+ });
67
+ }, [blocks, filters]);
68
+
69
+ // Group by category
70
+ const groupedBlocks = useMemo(() => {
71
+ const grouped: Record<string, Block[]> = {};
72
+
73
+ filteredBlocks.forEach((block) => {
74
+ const cat = block.category || 'Uncategorized';
75
+ if (!grouped[cat]) grouped[cat] = [];
76
+ grouped[cat].push(block);
77
+ });
78
+
79
+ return Object.entries(grouped).sort(([a], [b]) => {
80
+ if (a === 'Uncategorized') return 1;
81
+ if (b === 'Uncategorized') return -1;
82
+ return a.localeCompare(b);
83
+ });
84
+ }, [filteredBlocks]);
85
+
86
+ const hasActiveFilters =
87
+ filters.search || filters.type !== 'all' || filters.category || filters.tags.length > 0;
88
+
89
+ const clearFilters = () => {
90
+ setFilters({ search: '', type: 'all', category: '', tags: [] });
91
+ };
92
+
93
+ if (collapsed) {
94
+ return (
95
+ <div className="blocks-panel collapsed">
96
+ <div className="blocks-header">
97
+ <button className="panel-toggle" onClick={() => setCollapsed(false)} title="Expand">
98
+ <span className="toggle-icon">☰</span>
99
+ </button>
100
+ </div>
101
+ </div>
102
+ );
103
+ }
104
+
105
+ return (
106
+ <div className="blocks-panel">
107
+ <div className="blocks-header">
108
+ <div className="blocks-header-content">
109
+ <h1>Blocks</h1>
110
+ <p>
111
+ {hasActiveFilters
112
+ ? `${filteredBlocks.length} of ${blocks.length} items`
113
+ : `${blocks.length} items`}
114
+ </p>
115
+ </div>
116
+ <button className="panel-toggle" onClick={() => setCollapsed(true)} title="Collapse">
117
+ <span className="toggle-icon">☰</span>
118
+ </button>
119
+ </div>
120
+
121
+ <div className="blocks-filters">
122
+ <input
123
+ type="search"
124
+ className="search-input"
125
+ placeholder="Search blocks..."
126
+ value={filters.search}
127
+ onChange={(e) => setFilters((f) => ({ ...f, search: e.target.value }))}
128
+ />
129
+
130
+ <div className="filter-tabs">
131
+ {(['all', 'block', 'template'] as const).map((type) => (
132
+ <button
133
+ key={type}
134
+ className={`filter-tab ${filters.type === type ? 'active' : ''}`}
135
+ onClick={() => setFilters((f) => ({ ...f, type }))}
136
+ >
137
+ {type === 'all' ? 'All' : type === 'block' ? 'Blocks' : 'Templates'}
138
+ </button>
139
+ ))}
140
+ </div>
141
+
142
+ <select
143
+ className="filter-select"
144
+ value={filters.category}
145
+ onChange={(e) => setFilters((f) => ({ ...f, category: e.target.value }))}
146
+ >
147
+ <option value="">All Categories</option>
148
+ {categories.map((cat) => (
149
+ <option key={cat} value={cat}>
150
+ {cat}
151
+ </option>
152
+ ))}
153
+ </select>
154
+
155
+ {tags.length > 0 && (
156
+ <div className="tags-filter">
157
+ {tags.map((tag) => (
158
+ <button
159
+ key={tag}
160
+ className={`tag-chip ${filters.tags.includes(tag) ? 'active' : ''}`}
161
+ onClick={() =>
162
+ setFilters((f) => ({
163
+ ...f,
164
+ tags: f.tags.includes(tag)
165
+ ? f.tags.filter((t) => t !== tag)
166
+ : [...f.tags, tag],
167
+ }))
168
+ }
169
+ >
170
+ {tag}
171
+ </button>
172
+ ))}
173
+ {hasActiveFilters && (
174
+ <button className="clear-filters" onClick={clearFilters}>
175
+ Clear all
176
+ </button>
177
+ )}
178
+ </div>
179
+ )}
180
+ </div>
181
+
182
+ <div className="blocks-list">
183
+ {loading ? (
184
+ <div className="loading">
185
+ <div className="spinner" />
186
+ <span>Loading blocks...</span>
187
+ </div>
188
+ ) : filteredBlocks.length === 0 ? (
189
+ <div className="no-results">
190
+ <div className="no-results-icon">🔍</div>
191
+ <div>No items match your filters</div>
192
+ {hasActiveFilters && (
193
+ <button className="btn-clear" onClick={clearFilters}>
194
+ Clear filters
195
+ </button>
196
+ )}
197
+ </div>
198
+ ) : (
199
+ groupedBlocks.map(([category, categoryBlocks]) => (
200
+ <div key={category} className="block-category">
201
+ <div className="category-header">
202
+ {category}
203
+ <span className="category-count">{categoryBlocks.length}</span>
204
+ </div>
205
+ {categoryBlocks.map((block) => (
206
+ <div
207
+ key={block.name}
208
+ className={`block-item ${selectedBlock?.name === block.name ? 'active' : ''}`}
209
+ onClick={() => onSelectBlock(block)}
210
+ >
211
+ <div className="block-item-header">
212
+ <div className="block-item-name">
213
+ {block.displayName}
214
+ {block.type === 'template' && (
215
+ <span className="type-badge template">Template</span>
216
+ )}
217
+ </div>
218
+ <span className="version-badge">v{block.version}</span>
219
+ </div>
220
+ <div className="block-item-footer">
221
+ <span className="block-item-type">{block.category || 'Block'}</span>
222
+ <span className="status-badge status-local">Local</span>
223
+ </div>
224
+ </div>
225
+ ))}
226
+ </div>
227
+ ))
228
+ )}
229
+ </div>
230
+ </div>
231
+ );
232
+ }