cmssy-cli 0.6.0 → 0.7.0

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.
@@ -0,0 +1,496 @@
1
+ // Cmssy Dev Server - Interactive UI
2
+ let currentBlock = null;
3
+ let blocks = [];
4
+ let previewData = {};
5
+ let eventSource = null;
6
+
7
+ // Initialize app
8
+ async function init() {
9
+ await loadBlocks();
10
+ setupSSE();
11
+ }
12
+
13
+ // Load all blocks from API
14
+ async function loadBlocks() {
15
+ try {
16
+ const response = await fetch('/api/blocks');
17
+ blocks = await response.json();
18
+ renderBlocksList();
19
+ } catch (error) {
20
+ console.error('Failed to load blocks:', error);
21
+ document.getElementById('blocks-list').innerHTML = `
22
+ <div style="padding: 20px; color: #e53935;">
23
+ Failed to load blocks. Make sure the dev server is running.
24
+ </div>
25
+ `;
26
+ }
27
+ }
28
+
29
+ // Render blocks list
30
+ function renderBlocksList() {
31
+ const listEl = document.getElementById('blocks-list');
32
+ const countEl = document.getElementById('blocks-count');
33
+
34
+ if (blocks.length === 0) {
35
+ listEl.innerHTML = '<div class="editor-empty">No blocks found</div>';
36
+ countEl.textContent = 'No blocks';
37
+ return;
38
+ }
39
+
40
+ countEl.textContent = `${blocks.length} ${blocks.length === 1 ? 'block' : 'blocks'}`;
41
+
42
+ listEl.innerHTML = blocks.map(block => `
43
+ <div
44
+ class="block-item ${currentBlock?.name === block.name ? 'active' : ''}"
45
+ data-block="${block.name}"
46
+ onclick="selectBlock('${block.name}')"
47
+ >
48
+ <div class="block-item-name">${block.displayName || block.name}</div>
49
+ <div class="block-item-type">${block.type}</div>
50
+ </div>
51
+ `).join('');
52
+ }
53
+
54
+ // Select a block
55
+ async function selectBlock(blockName) {
56
+ const block = blocks.find(b => b.name === blockName);
57
+ if (!block) return;
58
+
59
+ currentBlock = block;
60
+ renderBlocksList(); // Update active state
61
+
62
+ // Load preview data
63
+ try {
64
+ const response = await fetch(`/api/preview/${blockName}`);
65
+ previewData = await response.json();
66
+ } catch (error) {
67
+ console.error('Failed to load preview data:', error);
68
+ previewData = {};
69
+ }
70
+
71
+ // Update UI
72
+ document.getElementById('preview-title').textContent = block.displayName || block.name;
73
+ document.getElementById('editor-subtitle').textContent = block.name;
74
+
75
+ // Render preview
76
+ renderPreview();
77
+
78
+ // Render editor form
79
+ renderEditor();
80
+ }
81
+
82
+ // Render preview iframe
83
+ function renderPreview() {
84
+ if (!currentBlock) return;
85
+
86
+ const previewContent = document.getElementById('preview-content');
87
+ previewContent.innerHTML = `
88
+ <div class="preview-iframe-wrapper">
89
+ <iframe
90
+ class="preview-iframe"
91
+ src="/preview/${currentBlock.name}"
92
+ id="preview-iframe"
93
+ ></iframe>
94
+ </div>
95
+ `;
96
+ }
97
+
98
+ // Render editor form
99
+ function renderEditor() {
100
+ if (!currentBlock || !currentBlock.schema) {
101
+ document.getElementById('editor-content').innerHTML = `
102
+ <div class="editor-empty">No schema defined for this block</div>
103
+ `;
104
+ return;
105
+ }
106
+
107
+ const editorContent = document.getElementById('editor-content');
108
+ const fields = Object.entries(currentBlock.schema);
109
+
110
+ editorContent.innerHTML = fields.map(([key, field]) =>
111
+ renderField(key, field, previewData[key])
112
+ ).join('');
113
+
114
+ // Attach event listeners
115
+ attachFieldListeners();
116
+ }
117
+
118
+ // Render a single field based on type
119
+ function renderField(key, field, value) {
120
+ const required = field.required ? '<span class="field-required">*</span>' : '';
121
+ const helpText = field.helpText ? `<div class="field-help">${field.helpText}</div>` : '';
122
+
123
+ let inputHtml = '';
124
+
125
+ switch (field.type) {
126
+ case 'singleLine':
127
+ case 'text':
128
+ case 'string':
129
+ inputHtml = `
130
+ <input
131
+ type="text"
132
+ class="field-input"
133
+ data-field="${key}"
134
+ value="${escapeHtml(value || field.defaultValue || '')}"
135
+ placeholder="${field.placeholder || ''}"
136
+ ${field.required ? 'required' : ''}
137
+ />
138
+ `;
139
+ break;
140
+
141
+ case 'multiLine':
142
+ inputHtml = `
143
+ <textarea
144
+ class="field-input field-textarea"
145
+ data-field="${key}"
146
+ placeholder="${field.placeholder || ''}"
147
+ ${field.required ? 'required' : ''}
148
+ >${escapeHtml(value || field.defaultValue || '')}</textarea>
149
+ `;
150
+ break;
151
+
152
+ case 'richText':
153
+ inputHtml = `
154
+ <textarea
155
+ class="field-input field-textarea"
156
+ data-field="${key}"
157
+ placeholder="${field.placeholder || 'Enter rich text...'}"
158
+ ${field.required ? 'required' : ''}
159
+ style="min-height: 120px;"
160
+ >${escapeHtml(value || field.defaultValue || '')}</textarea>
161
+ `;
162
+ break;
163
+
164
+ case 'number':
165
+ inputHtml = `
166
+ <input
167
+ type="number"
168
+ class="field-input"
169
+ data-field="${key}"
170
+ value="${value !== undefined ? value : (field.defaultValue || '')}"
171
+ placeholder="${field.placeholder || ''}"
172
+ ${field.required ? 'required' : ''}
173
+ />
174
+ `;
175
+ break;
176
+
177
+ case 'boolean':
178
+ inputHtml = `
179
+ <label style="display: flex; align-items: center; cursor: pointer;">
180
+ <input
181
+ type="checkbox"
182
+ class="field-checkbox"
183
+ data-field="${key}"
184
+ ${value || field.defaultValue ? 'checked' : ''}
185
+ />
186
+ <span>${field.label}</span>
187
+ </label>
188
+ `;
189
+ break;
190
+
191
+ case 'date':
192
+ inputHtml = `
193
+ <input
194
+ type="date"
195
+ class="field-input"
196
+ data-field="${key}"
197
+ value="${value || field.defaultValue || ''}"
198
+ ${field.required ? 'required' : ''}
199
+ />
200
+ `;
201
+ break;
202
+
203
+ case 'link':
204
+ inputHtml = `
205
+ <input
206
+ type="url"
207
+ class="field-input"
208
+ data-field="${key}"
209
+ value="${escapeHtml(value || field.defaultValue || '')}"
210
+ placeholder="${field.placeholder || 'https://...'}"
211
+ ${field.required ? 'required' : ''}
212
+ />
213
+ `;
214
+ break;
215
+
216
+ case 'color':
217
+ const colorValue = value || field.defaultValue || '#000000';
218
+ inputHtml = `
219
+ <div class="color-field">
220
+ <input
221
+ type="color"
222
+ class="color-preview"
223
+ data-field="${key}"
224
+ value="${colorValue}"
225
+ />
226
+ <input
227
+ type="text"
228
+ class="field-input color-input"
229
+ data-field="${key}-text"
230
+ value="${colorValue}"
231
+ placeholder="#000000"
232
+ />
233
+ </div>
234
+ `;
235
+ break;
236
+
237
+ case 'select':
238
+ const currentValue = value || field.defaultValue || '';
239
+ inputHtml = `
240
+ <select class="field-input field-select" data-field="${key}" ${field.required ? 'required' : ''}>
241
+ <option value="">Select an option...</option>
242
+ ${field.options.map(opt => {
243
+ const optValue = typeof opt === 'string' ? opt : opt.value;
244
+ const optLabel = typeof opt === 'string' ? opt : opt.label;
245
+ return `<option value="${escapeHtml(optValue)}" ${currentValue === optValue ? 'selected' : ''}>${escapeHtml(optLabel)}</option>`;
246
+ }).join('')}
247
+ </select>
248
+ `;
249
+ break;
250
+
251
+ case 'media':
252
+ const mediaValue = value || field.defaultValue || {};
253
+ inputHtml = `
254
+ <div class="media-field">
255
+ <div class="media-preview">
256
+ ${mediaValue.url ?
257
+ `<img src="${escapeHtml(mediaValue.url)}" alt="${escapeHtml(mediaValue.alt || '')}"/>` :
258
+ '<div class="media-placeholder">No image</div>'
259
+ }
260
+ </div>
261
+ <div class="media-input-group">
262
+ <input
263
+ type="url"
264
+ class="field-input"
265
+ data-field="${key}.url"
266
+ value="${escapeHtml(mediaValue.url || '')}"
267
+ placeholder="Image URL"
268
+ style="margin-bottom: 8px;"
269
+ />
270
+ <input
271
+ type="text"
272
+ class="field-input"
273
+ data-field="${key}.alt"
274
+ value="${escapeHtml(mediaValue.alt || '')}"
275
+ placeholder="Alt text"
276
+ />
277
+ </div>
278
+ </div>
279
+ `;
280
+ break;
281
+
282
+ case 'repeater':
283
+ inputHtml = renderRepeaterField(key, field, value || field.defaultValue || []);
284
+ break;
285
+
286
+ default:
287
+ inputHtml = `<div style="color: #999;">Unsupported field type: ${field.type}</div>`;
288
+ }
289
+
290
+ // Special case for boolean - don't show separate label
291
+ if (field.type === 'boolean') {
292
+ return `
293
+ <div class="field-group">
294
+ ${inputHtml}
295
+ ${helpText}
296
+ </div>
297
+ `;
298
+ }
299
+
300
+ return `
301
+ <div class="field-group">
302
+ <label class="field-label">
303
+ ${field.label}${required}
304
+ </label>
305
+ ${inputHtml}
306
+ ${helpText}
307
+ </div>
308
+ `;
309
+ }
310
+
311
+ // Render repeater field
312
+ function renderRepeaterField(key, field, items) {
313
+ const minItems = field.minItems || 0;
314
+ const maxItems = field.maxItems || 999;
315
+
316
+ const itemsHtml = items.map((item, index) => {
317
+ const nestedFields = Object.entries(field.schema || {}).map(([nestedKey, nestedField]) => {
318
+ return renderField(`${key}.${index}.${nestedKey}`, nestedField, item[nestedKey]);
319
+ }).join('');
320
+
321
+ return `
322
+ <div class="repeater-item" data-repeater-item="${key}.${index}">
323
+ <div class="repeater-item-header">
324
+ <div class="repeater-item-title">Item ${index + 1}</div>
325
+ ${items.length > minItems ? `
326
+ <button
327
+ type="button"
328
+ class="repeater-item-remove"
329
+ onclick="removeRepeaterItem('${key}', ${index})"
330
+ >Remove</button>
331
+ ` : ''}
332
+ </div>
333
+ ${nestedFields}
334
+ </div>
335
+ `;
336
+ }).join('');
337
+
338
+ return `
339
+ <div class="repeater-items" data-repeater="${key}">
340
+ ${itemsHtml || '<div style="padding: 12px; color: #999; text-align: center;">No items yet</div>'}
341
+ </div>
342
+ ${items.length < maxItems ? `
343
+ <button
344
+ type="button"
345
+ class="repeater-add"
346
+ onclick="addRepeaterItem('${key}')"
347
+ >+ Add Item</button>
348
+ ` : ''}
349
+ `;
350
+ }
351
+
352
+ // Add repeater item
353
+ window.addRepeaterItem = function(key) {
354
+ const field = currentBlock.schema[key];
355
+ if (!field || !field.schema) return;
356
+
357
+ // Get current items
358
+ const currentItems = previewData[key] || [];
359
+
360
+ // Create new empty item
361
+ const newItem = {};
362
+ Object.keys(field.schema).forEach(nestedKey => {
363
+ const nestedField = field.schema[nestedKey];
364
+ newItem[nestedKey] = nestedField.defaultValue || '';
365
+ });
366
+
367
+ // Add to preview data
368
+ previewData[key] = [...currentItems, newItem];
369
+
370
+ // Re-render editor and save
371
+ renderEditor();
372
+ savePreviewData();
373
+ };
374
+
375
+ // Remove repeater item
376
+ window.removeRepeaterItem = function(key, index) {
377
+ const currentItems = previewData[key] || [];
378
+ previewData[key] = currentItems.filter((_, i) => i !== index);
379
+
380
+ // Re-render editor and save
381
+ renderEditor();
382
+ savePreviewData();
383
+ };
384
+
385
+ // Attach event listeners to form fields
386
+ function attachFieldListeners() {
387
+ const inputs = document.querySelectorAll('[data-field]');
388
+ inputs.forEach(input => {
389
+ const eventType = input.type === 'checkbox' ? 'change' : 'input';
390
+ input.addEventListener(eventType, handleFieldChange);
391
+ });
392
+ }
393
+
394
+ // Handle field value change
395
+ function handleFieldChange(event) {
396
+ const input = event.target;
397
+ const fieldPath = input.dataset.field;
398
+ const field = fieldPath.split('.');
399
+
400
+ let value;
401
+ if (input.type === 'checkbox') {
402
+ value = input.checked;
403
+ } else if (input.type === 'number') {
404
+ value = input.value ? parseFloat(input.value) : '';
405
+ } else {
406
+ value = input.value;
407
+ }
408
+
409
+ // Handle nested fields (e.g., "items.0.name" or "image.url")
410
+ if (field.length === 1) {
411
+ previewData[field[0]] = value;
412
+ } else if (field.length === 2) {
413
+ if (!previewData[field[0]]) previewData[field[0]] = {};
414
+ previewData[field[0]][field[1]] = value;
415
+ } else if (field.length === 3) {
416
+ // Repeater item field
417
+ if (!previewData[field[0]]) previewData[field[0]] = [];
418
+ if (!previewData[field[0]][field[1]]) previewData[field[0]][field[1]] = {};
419
+ previewData[field[0]][field[1]][field[2]] = value;
420
+ }
421
+
422
+ // Sync color picker with text input
423
+ if (fieldPath.endsWith('-text')) {
424
+ const colorKey = fieldPath.replace('-text', '');
425
+ const colorInput = document.querySelector(`[data-field="${colorKey}"]`);
426
+ if (colorInput) colorInput.value = value;
427
+ }
428
+
429
+ // Debounce save (quick debounce since we're using postMessage for instant updates)
430
+ clearTimeout(window.saveTimeout);
431
+ window.saveTimeout = setTimeout(() => savePreviewData(), 200);
432
+ }
433
+
434
+ // Save preview data to server
435
+ async function savePreviewData() {
436
+ if (!currentBlock) return;
437
+
438
+ try {
439
+ // Update preview iframe immediately (no reload/blink)
440
+ const iframe = document.getElementById('preview-iframe');
441
+ if (iframe && iframe.contentWindow) {
442
+ iframe.contentWindow.postMessage({
443
+ type: 'UPDATE_PROPS',
444
+ props: previewData
445
+ }, '*');
446
+ }
447
+
448
+ // Save to server in background
449
+ const response = await fetch(`/api/preview/${currentBlock.name}`, {
450
+ method: 'POST',
451
+ headers: { 'Content-Type': 'application/json' },
452
+ body: JSON.stringify(previewData)
453
+ });
454
+
455
+ if (response.ok) {
456
+ document.getElementById('preview-status').textContent = 'Saved';
457
+ setTimeout(() => {
458
+ document.getElementById('preview-status').textContent = 'Ready';
459
+ }, 1000);
460
+ }
461
+ } catch (error) {
462
+ console.error('Failed to save preview data:', error);
463
+ document.getElementById('preview-status').textContent = 'Error';
464
+ }
465
+ }
466
+
467
+ // Setup Server-Sent Events for hot reload
468
+ function setupSSE() {
469
+ eventSource = new EventSource('/events');
470
+
471
+ eventSource.onmessage = (event) => {
472
+ const data = JSON.parse(event.data);
473
+
474
+ if (data.type === 'reload') {
475
+ // Reload preview iframe
476
+ const iframe = document.getElementById('preview-iframe');
477
+ if (iframe && (!data.block || data.block === currentBlock?.name)) {
478
+ iframe.src = iframe.src; // Force reload
479
+ }
480
+ }
481
+ };
482
+
483
+ eventSource.onerror = () => {
484
+ console.error('SSE connection lost. Reconnecting...');
485
+ };
486
+ }
487
+
488
+ // Utility: Escape HTML
489
+ function escapeHtml(text) {
490
+ const div = document.createElement('div');
491
+ div.textContent = text;
492
+ return div.innerHTML;
493
+ }
494
+
495
+ // Start the app
496
+ init();