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.
- package/config.d.ts +1 -1
- package/dist/cli.js +131 -30
- package/dist/cli.js.map +1 -1
- package/dist/commands/build.d.ts +1 -0
- package/dist/commands/build.d.ts.map +1 -1
- package/dist/commands/build.js +56 -12
- package/dist/commands/build.js.map +1 -1
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +22 -2
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +652 -410
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +3 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +3 -1
- package/dist/commands/migrate.js.map +1 -1
- package/dist/dev-ui/app.js +166 -19
- package/dist/dev-ui/index.html +138 -0
- package/dist/dev-ui-react/App.tsx +164 -0
- package/dist/dev-ui-react/__tests__/previewData.test.ts +193 -0
- package/dist/dev-ui-react/components/BlocksList.tsx +232 -0
- package/dist/dev-ui-react/components/Editor.tsx +469 -0
- package/dist/dev-ui-react/components/Preview.tsx +146 -0
- package/dist/dev-ui-react/hooks/useBlocks.ts +80 -0
- package/dist/dev-ui-react/index.html +13 -0
- package/dist/dev-ui-react/main.tsx +8 -0
- package/dist/dev-ui-react/styles.css +856 -0
- package/dist/dev-ui-react/types.ts +45 -0
- package/dist/types/block-config.d.ts +100 -2
- package/dist/types/block-config.d.ts.map +1 -1
- package/dist/types/block-config.js +6 -1
- package/dist/types/block-config.js.map +1 -1
- package/dist/utils/block-config.js +3 -3
- package/dist/utils/block-config.js.map +1 -1
- package/dist/utils/blocks-meta-cache.d.ts +28 -0
- package/dist/utils/blocks-meta-cache.d.ts.map +1 -0
- package/dist/utils/blocks-meta-cache.js +72 -0
- package/dist/utils/blocks-meta-cache.js.map +1 -0
- package/dist/utils/builder.d.ts +3 -0
- package/dist/utils/builder.d.ts.map +1 -1
- package/dist/utils/builder.js +17 -14
- package/dist/utils/builder.js.map +1 -1
- package/dist/utils/field-schema.d.ts +2 -0
- package/dist/utils/field-schema.d.ts.map +1 -1
- package/dist/utils/field-schema.js +21 -4
- package/dist/utils/field-schema.js.map +1 -1
- package/dist/utils/scanner.d.ts +5 -3
- package/dist/utils/scanner.d.ts.map +1 -1
- package/dist/utils/scanner.js +23 -16
- package/dist/utils/scanner.js.map +1 -1
- package/dist/utils/type-generator.d.ts +7 -1
- package/dist/utils/type-generator.d.ts.map +1 -1
- package/dist/utils/type-generator.js +58 -41
- package/dist/utils/type-generator.js.map +1 -1
- package/package.json +8 -3
- package/dist/commands/deploy.d.ts +0 -9
- package/dist/commands/deploy.d.ts.map +0 -1
- package/dist/commands/deploy.js +0 -226
- package/dist/commands/deploy.js.map +0 -1
- package/dist/commands/push.d.ts +0 -9
- package/dist/commands/push.d.ts.map +0 -1
- package/dist/commands/push.js +0 -199
- package/dist/commands/push.js.map +0 -1
- package/dist/utils/blockforge-config.d.ts +0 -19
- package/dist/utils/blockforge-config.d.ts.map +0 -1
- package/dist/utils/blockforge-config.js +0 -19
- 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
|
+
}
|