@cmssy/cli 0.1.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.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +649 -0
  3. package/config.d.ts +2 -0
  4. package/config.js +2 -0
  5. package/dist/cli.d.ts +3 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +236 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/commands/add-source.d.ts +7 -0
  10. package/dist/commands/add-source.d.ts.map +1 -0
  11. package/dist/commands/add-source.js +238 -0
  12. package/dist/commands/add-source.js.map +1 -0
  13. package/dist/commands/build.d.ts +7 -0
  14. package/dist/commands/build.d.ts.map +1 -0
  15. package/dist/commands/build.js +105 -0
  16. package/dist/commands/build.js.map +1 -0
  17. package/dist/commands/configure.d.ts +6 -0
  18. package/dist/commands/configure.d.ts.map +1 -0
  19. package/dist/commands/configure.js +42 -0
  20. package/dist/commands/configure.js.map +1 -0
  21. package/dist/commands/create.d.ts +18 -0
  22. package/dist/commands/create.d.ts.map +1 -0
  23. package/dist/commands/create.js +444 -0
  24. package/dist/commands/create.js.map +1 -0
  25. package/dist/commands/dev.d.ts +6 -0
  26. package/dist/commands/dev.d.ts.map +1 -0
  27. package/dist/commands/dev.js +962 -0
  28. package/dist/commands/dev.js.map +1 -0
  29. package/dist/commands/init.d.ts +2 -0
  30. package/dist/commands/init.d.ts.map +1 -0
  31. package/dist/commands/init.js +362 -0
  32. package/dist/commands/init.js.map +1 -0
  33. package/dist/commands/migrate.d.ts +2 -0
  34. package/dist/commands/migrate.d.ts.map +1 -0
  35. package/dist/commands/migrate.js +227 -0
  36. package/dist/commands/migrate.js.map +1 -0
  37. package/dist/commands/package.d.ts +7 -0
  38. package/dist/commands/package.d.ts.map +1 -0
  39. package/dist/commands/package.js +136 -0
  40. package/dist/commands/package.js.map +1 -0
  41. package/dist/commands/publish.d.ts +13 -0
  42. package/dist/commands/publish.d.ts.map +1 -0
  43. package/dist/commands/publish.js +910 -0
  44. package/dist/commands/publish.js.map +1 -0
  45. package/dist/commands/sync.d.ts +6 -0
  46. package/dist/commands/sync.d.ts.map +1 -0
  47. package/dist/commands/sync.js +208 -0
  48. package/dist/commands/sync.js.map +1 -0
  49. package/dist/commands/upload.d.ts +7 -0
  50. package/dist/commands/upload.d.ts.map +1 -0
  51. package/dist/commands/upload.js +126 -0
  52. package/dist/commands/upload.js.map +1 -0
  53. package/dist/commands/workspaces.d.ts +2 -0
  54. package/dist/commands/workspaces.d.ts.map +1 -0
  55. package/dist/commands/workspaces.js +67 -0
  56. package/dist/commands/workspaces.js.map +1 -0
  57. package/dist/dev-ui/app.js +1284 -0
  58. package/dist/dev-ui/index.html +1511 -0
  59. package/dist/dev-ui-react/App.tsx +164 -0
  60. package/dist/dev-ui-react/__tests__/previewData.test.ts +193 -0
  61. package/dist/dev-ui-react/components/BlocksList.tsx +232 -0
  62. package/dist/dev-ui-react/components/Editor.tsx +469 -0
  63. package/dist/dev-ui-react/components/Preview.tsx +146 -0
  64. package/dist/dev-ui-react/hooks/useBlocks.ts +80 -0
  65. package/dist/dev-ui-react/index.html +13 -0
  66. package/dist/dev-ui-react/main.tsx +8 -0
  67. package/dist/dev-ui-react/styles.css +856 -0
  68. package/dist/dev-ui-react/types.ts +45 -0
  69. package/dist/types/block-config.d.ts +315 -0
  70. package/dist/types/block-config.d.ts.map +1 -0
  71. package/dist/types/block-config.js +8 -0
  72. package/dist/types/block-config.js.map +1 -0
  73. package/dist/utils/block-config.d.ts +10 -0
  74. package/dist/utils/block-config.d.ts.map +1 -0
  75. package/dist/utils/block-config.js +199 -0
  76. package/dist/utils/block-config.js.map +1 -0
  77. package/dist/utils/blocks-meta-cache.d.ts +28 -0
  78. package/dist/utils/blocks-meta-cache.d.ts.map +1 -0
  79. package/dist/utils/blocks-meta-cache.js +72 -0
  80. package/dist/utils/blocks-meta-cache.js.map +1 -0
  81. package/dist/utils/builder.d.ts +34 -0
  82. package/dist/utils/builder.d.ts.map +1 -0
  83. package/dist/utils/builder.js +140 -0
  84. package/dist/utils/builder.js.map +1 -0
  85. package/dist/utils/cmssy-config.d.ts +16 -0
  86. package/dist/utils/cmssy-config.d.ts.map +1 -0
  87. package/dist/utils/cmssy-config.js +19 -0
  88. package/dist/utils/cmssy-config.js.map +1 -0
  89. package/dist/utils/config.d.ts +9 -0
  90. package/dist/utils/config.d.ts.map +1 -0
  91. package/dist/utils/config.js +46 -0
  92. package/dist/utils/config.js.map +1 -0
  93. package/dist/utils/field-schema.d.ts +12 -0
  94. package/dist/utils/field-schema.d.ts.map +1 -0
  95. package/dist/utils/field-schema.js +202 -0
  96. package/dist/utils/field-schema.js.map +1 -0
  97. package/dist/utils/graphql.d.ts +8 -0
  98. package/dist/utils/graphql.d.ts.map +1 -0
  99. package/dist/utils/graphql.js +118 -0
  100. package/dist/utils/graphql.js.map +1 -0
  101. package/dist/utils/publish-helpers.d.ts +35 -0
  102. package/dist/utils/publish-helpers.d.ts.map +1 -0
  103. package/dist/utils/publish-helpers.js +141 -0
  104. package/dist/utils/publish-helpers.js.map +1 -0
  105. package/dist/utils/scanner.d.ts +36 -0
  106. package/dist/utils/scanner.d.ts.map +1 -0
  107. package/dist/utils/scanner.js +140 -0
  108. package/dist/utils/scanner.js.map +1 -0
  109. package/dist/utils/type-generator.d.ts +9 -0
  110. package/dist/utils/type-generator.d.ts.map +1 -0
  111. package/dist/utils/type-generator.js +85 -0
  112. package/dist/utils/type-generator.js.map +1 -0
  113. package/package.json +88 -0
@@ -0,0 +1,1284 @@
1
+ // Cmssy Dev Server - Interactive UI
2
+ let currentBlock = null;
3
+ let blocks = [];
4
+ let previewData = {};
5
+ let eventSource = null;
6
+
7
+ // Filters state
8
+ let filters = {
9
+ search: '',
10
+ type: 'all',
11
+ category: '',
12
+ tags: []
13
+ };
14
+
15
+ // Available categories and tags (populated from blocks)
16
+ let availableCategories = [];
17
+ let availableTags = [];
18
+
19
+ // Initialize app
20
+ async function init() {
21
+ await loadBlocks();
22
+ setupSSE();
23
+
24
+ // Check for block parameter in URL
25
+ const urlParams = new URLSearchParams(window.location.search);
26
+ const blockParam = urlParams.get('block');
27
+ if (blockParam) {
28
+ const block = blocks.find(b => b.name === blockParam);
29
+ if (block) {
30
+ await selectBlock(blockParam);
31
+ }
32
+ }
33
+ }
34
+
35
+ // Update URL with current block (without page reload)
36
+ function updateUrlWithBlock(blockName) {
37
+ const url = new URL(window.location);
38
+ if (blockName) {
39
+ url.searchParams.set('block', blockName);
40
+ } else {
41
+ url.searchParams.delete('block');
42
+ }
43
+ window.history.replaceState({}, '', url);
44
+ }
45
+
46
+ // Load all blocks from API
47
+ async function loadBlocks() {
48
+ try {
49
+ const response = await fetch('/api/blocks');
50
+ blocks = await response.json();
51
+ populateFilters();
52
+ renderBlocksList();
53
+ } catch (error) {
54
+ console.error('Failed to load blocks:', error);
55
+ document.getElementById('blocks-list').innerHTML = `
56
+ <div style="padding: 20px; color: #e53935;">
57
+ Failed to load blocks. Make sure the dev server is running.
58
+ </div>
59
+ `;
60
+ }
61
+ }
62
+
63
+ // Populate filter options from loaded blocks
64
+ function populateFilters() {
65
+ // Extract unique categories
66
+ const categoriesSet = new Set();
67
+ const tagsSet = new Set();
68
+
69
+ blocks.forEach(block => {
70
+ if (block.category) {
71
+ categoriesSet.add(block.category);
72
+ }
73
+ if (block.tags && Array.isArray(block.tags)) {
74
+ block.tags.forEach(tag => tagsSet.add(tag));
75
+ }
76
+ });
77
+
78
+ availableCategories = Array.from(categoriesSet).sort();
79
+ availableTags = Array.from(tagsSet).sort();
80
+
81
+ // Populate category dropdown
82
+ const categorySelect = document.getElementById('category-filter');
83
+ if (categorySelect) {
84
+ categorySelect.innerHTML = '<option value="">All Categories</option>';
85
+ availableCategories.forEach(cat => {
86
+ const option = document.createElement('option');
87
+ option.value = cat;
88
+ option.textContent = cat;
89
+ categorySelect.appendChild(option);
90
+ });
91
+ }
92
+
93
+ // Populate tags filter
94
+ const tagsContainer = document.getElementById('tags-filter');
95
+ if (tagsContainer) {
96
+ if (availableTags.length === 0) {
97
+ tagsContainer.style.display = 'none';
98
+ } else {
99
+ tagsContainer.style.display = 'flex';
100
+ tagsContainer.innerHTML = availableTags.map(tag => `
101
+ <button
102
+ type="button"
103
+ class="tag-chip ${filters.tags.includes(tag) ? 'active' : ''}"
104
+ data-tag="${escapeHtml(tag)}"
105
+ onclick="toggleTagFilter('${escapeHtml(tag)}')"
106
+ >${escapeHtml(tag)}</button>
107
+ `).join('');
108
+
109
+ // Add clear button if any filters active
110
+ if (hasActiveFilters()) {
111
+ tagsContainer.innerHTML += `
112
+ <button type="button" class="clear-filters" onclick="clearAllFilters()">
113
+ Clear all
114
+ </button>
115
+ `;
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ // Check if any filters are active
122
+ function hasActiveFilters() {
123
+ return filters.search !== '' ||
124
+ filters.type !== 'all' ||
125
+ filters.category !== '' ||
126
+ filters.tags.length > 0;
127
+ }
128
+
129
+ // Filter handlers
130
+ window.handleSearchInput = function(event) {
131
+ filters.search = event.target.value.toLowerCase();
132
+ renderBlocksList();
133
+ };
134
+
135
+ window.setTypeFilter = function(type) {
136
+ filters.type = type;
137
+
138
+ // Update tab UI
139
+ document.querySelectorAll('.filter-tab').forEach(tab => {
140
+ tab.classList.toggle('active', tab.dataset.type === type);
141
+ });
142
+
143
+ renderBlocksList();
144
+ };
145
+
146
+ window.setCategoryFilter = function(category) {
147
+ filters.category = category;
148
+ renderBlocksList();
149
+ };
150
+
151
+ window.toggleTagFilter = function(tag) {
152
+ const index = filters.tags.indexOf(tag);
153
+ if (index === -1) {
154
+ filters.tags.push(tag);
155
+ } else {
156
+ filters.tags.splice(index, 1);
157
+ }
158
+
159
+ // Update tag chip UI
160
+ document.querySelectorAll('.tag-chip').forEach(chip => {
161
+ if (chip.dataset.tag === tag) {
162
+ chip.classList.toggle('active');
163
+ }
164
+ });
165
+
166
+ // Re-render to update clear button
167
+ populateFilters();
168
+ renderBlocksList();
169
+ };
170
+
171
+ window.clearAllFilters = function() {
172
+ filters = {
173
+ search: '',
174
+ type: 'all',
175
+ category: '',
176
+ tags: []
177
+ };
178
+
179
+ // Reset UI
180
+ document.getElementById('search-input').value = '';
181
+ document.getElementById('category-filter').value = '';
182
+ document.querySelectorAll('.filter-tab').forEach(tab => {
183
+ tab.classList.toggle('active', tab.dataset.type === 'all');
184
+ });
185
+
186
+ populateFilters();
187
+ renderBlocksList();
188
+ };
189
+
190
+ // Get filtered blocks based on current filters
191
+ function getFilteredBlocks() {
192
+ return blocks.filter(block => {
193
+ // Search filter
194
+ if (filters.search) {
195
+ const searchLower = filters.search.toLowerCase();
196
+ const matchesSearch =
197
+ (block.displayName || block.name).toLowerCase().includes(searchLower) ||
198
+ (block.description || '').toLowerCase().includes(searchLower) ||
199
+ (block.name || '').toLowerCase().includes(searchLower);
200
+ if (!matchesSearch) return false;
201
+ }
202
+
203
+ // Type filter
204
+ if (filters.type !== 'all') {
205
+ const blockType = block.type || 'block';
206
+ if (blockType !== filters.type) {
207
+ return false;
208
+ }
209
+ }
210
+
211
+ // Category filter
212
+ if (filters.category && block.category !== filters.category) {
213
+ return false;
214
+ }
215
+
216
+ // Tags filter (match ANY selected tag)
217
+ if (filters.tags.length > 0) {
218
+ const blockTags = block.tags || [];
219
+ const hasAnyTag = filters.tags.some(tag => blockTags.includes(tag));
220
+ if (!hasAnyTag) return false;
221
+ }
222
+
223
+ return true;
224
+ });
225
+ }
226
+
227
+ // Render blocks list
228
+ function renderBlocksList() {
229
+ const listEl = document.getElementById('blocks-list');
230
+ const countEl = document.getElementById('blocks-count');
231
+
232
+ if (blocks.length === 0) {
233
+ listEl.innerHTML = '<div class="editor-empty">No blocks found</div>';
234
+ countEl.textContent = 'No blocks';
235
+ return;
236
+ }
237
+
238
+ // Get filtered blocks
239
+ const filteredBlocks = getFilteredBlocks();
240
+
241
+ // Update count with filter info
242
+ if (hasActiveFilters()) {
243
+ countEl.textContent = `${filteredBlocks.length} of ${blocks.length} items`;
244
+ } else {
245
+ countEl.textContent = `${blocks.length} ${blocks.length === 1 ? 'item' : 'items'}`;
246
+ }
247
+
248
+ // Handle empty filtered results
249
+ if (filteredBlocks.length === 0) {
250
+ listEl.innerHTML = `
251
+ <div class="no-results">
252
+ <div class="no-results-icon">🔍</div>
253
+ <div>No items match your filters</div>
254
+ <button
255
+ type="button"
256
+ style="margin-top: 12px; padding: 6px 16px; background: #667eea; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 13px;"
257
+ onclick="clearAllFilters()"
258
+ >Clear filters</button>
259
+ </div>
260
+ `;
261
+ return;
262
+ }
263
+
264
+ // Group blocks by category
265
+ const grouped = {};
266
+ filteredBlocks.forEach(block => {
267
+ const cat = block.category || 'Uncategorized';
268
+ if (!grouped[cat]) grouped[cat] = [];
269
+ grouped[cat].push(block);
270
+ });
271
+
272
+ // Sort categories alphabetically (but put Uncategorized last)
273
+ const sortedCategories = Object.keys(grouped).sort((a, b) => {
274
+ if (a === 'Uncategorized') return 1;
275
+ if (b === 'Uncategorized') return -1;
276
+ return a.localeCompare(b);
277
+ });
278
+
279
+ // Render grouped blocks
280
+ listEl.innerHTML = sortedCategories.map(category => `
281
+ <div class="block-category">
282
+ <div class="category-header">
283
+ ${escapeHtml(category)}
284
+ <span class="category-count">${grouped[category].length}</span>
285
+ </div>
286
+ ${grouped[category].map(block => renderBlockItem(block)).join('')}
287
+ </div>
288
+ `).join('');
289
+ }
290
+
291
+ // Render a single block item
292
+ function renderBlockItem(block) {
293
+ const isTemplate = block.type === 'template';
294
+ const typeBadge = isTemplate
295
+ ? '<span class="type-badge template">Template</span>'
296
+ : '';
297
+
298
+ return `
299
+ <div
300
+ class="block-item ${currentBlock?.name === block.name ? 'active' : ''}"
301
+ data-block="${escapeHtml(block.name)}"
302
+ onclick="selectBlock('${escapeHtml(block.name)}')"
303
+ >
304
+ <div class="block-item-header">
305
+ <div class="block-item-name">
306
+ ${escapeHtml(block.displayName || block.name)}
307
+ ${typeBadge}
308
+ </div>
309
+ <span class="version-badge">v${block.version || '1.0.0'}</span>
310
+ </div>
311
+ <div class="block-item-footer">
312
+ <span class="block-item-type">${escapeHtml(block.category || 'Block')}</span>
313
+ <span class="status-badge status-local">Local</span>
314
+ </div>
315
+ </div>
316
+ `;
317
+ }
318
+
319
+ // Select a block (lazy loads config)
320
+ async function selectBlock(blockName) {
321
+ const block = blocks.find(b => b.name === blockName);
322
+ if (!block) return;
323
+
324
+ currentBlock = block;
325
+ updateUrlWithBlock(blockName);
326
+ renderBlocksList();
327
+
328
+ // Show loading state in editor
329
+ document.getElementById('preview-title').textContent = block.displayName || block.name;
330
+ document.getElementById('editor-subtitle').textContent = block.name;
331
+ document.getElementById('editor-content').innerHTML = `
332
+ <div class="loading">
333
+ <div class="spinner"></div>
334
+ <span>Loading properties...</span>
335
+ </div>
336
+ `;
337
+
338
+ // Lazy load full config and preview data
339
+ try {
340
+ const response = await fetch(`/api/blocks/${blockName}/config`);
341
+ if (!response.ok) {
342
+ throw new Error('Failed to load block config');
343
+ }
344
+ const config = await response.json();
345
+
346
+ // Update block with loaded config
347
+ block.schema = config.schema;
348
+ block.category = config.category;
349
+ block.tags = config.tags;
350
+ block.displayName = config.displayName;
351
+ block.description = config.description;
352
+ previewData = config.previewData || {};
353
+
354
+ // For templates, also load pages info
355
+ if (block.type === 'template') {
356
+ try {
357
+ const pagesResponse = await fetch(`/api/templates/${blockName}/pages`);
358
+ if (pagesResponse.ok) {
359
+ const templateData = await pagesResponse.json();
360
+ block.pages = templateData.pages;
361
+ block.layoutSlots = templateData.layoutSlots;
362
+ }
363
+ } catch (err) {
364
+ console.warn('Could not load template pages:', err);
365
+ }
366
+ }
367
+
368
+ // Update filters if we got new category/tags
369
+ populateFilters();
370
+ } catch (error) {
371
+ console.error('Failed to load block config:', error);
372
+ document.getElementById('editor-content').innerHTML = `
373
+ <div class="editor-empty" style="color: #e53935;">
374
+ Failed to load block config.<br>
375
+ <small>Check console for details.</small>
376
+ </div>
377
+ `;
378
+ return;
379
+ }
380
+
381
+ // Update UI
382
+ document.getElementById('preview-title').textContent = block.displayName || block.name;
383
+
384
+ // Show publish button
385
+ const publishBtn = document.getElementById('publish-btn');
386
+ if (publishBtn) {
387
+ publishBtn.style.display = 'block';
388
+ }
389
+
390
+ // Render preview
391
+ renderPreview();
392
+
393
+ // Render editor form
394
+ renderEditor();
395
+ }
396
+
397
+ // Render preview iframe
398
+ function renderPreview() {
399
+ if (!currentBlock) return;
400
+
401
+ const previewContent = document.getElementById('preview-content');
402
+ const isTemplate = currentBlock.type === 'template' && currentBlock.pages && currentBlock.pages.length > 0;
403
+
404
+ if (isTemplate) {
405
+ // Template preview - show page selector and full page preview
406
+ const firstPage = currentBlock.pages[0];
407
+ previewContent.innerHTML = `
408
+ <div class="preview-iframe-wrapper template-preview">
409
+ <iframe
410
+ class="preview-iframe"
411
+ src="/preview/template/${currentBlock.name}/${firstPage.slug}"
412
+ id="preview-iframe"
413
+ ></iframe>
414
+ </div>
415
+ `;
416
+ } else {
417
+ // Block preview - single block
418
+ previewContent.innerHTML = `
419
+ <div class="preview-iframe-wrapper">
420
+ <iframe
421
+ class="preview-iframe"
422
+ src="/preview/${currentBlock.name}"
423
+ id="preview-iframe"
424
+ ></iframe>
425
+ </div>
426
+ `;
427
+ }
428
+ }
429
+
430
+ // Render editor form
431
+ function renderEditor() {
432
+ const editorContent = document.getElementById('editor-content');
433
+
434
+ // For templates, show pages overview
435
+ if (currentBlock && currentBlock.type === 'template' && currentBlock.pages) {
436
+ editorContent.innerHTML = renderTemplateEditor();
437
+ return;
438
+ }
439
+
440
+ if (!currentBlock || !currentBlock.schema) {
441
+ editorContent.innerHTML = `
442
+ <div class="editor-empty">No schema defined for this block</div>
443
+ `;
444
+ return;
445
+ }
446
+
447
+ const fields = Object.entries(currentBlock.schema);
448
+
449
+ editorContent.innerHTML = fields.map(([key, field]) =>
450
+ renderField(key, field, previewData[key])
451
+ ).join('');
452
+
453
+ // Attach event listeners
454
+ attachFieldListeners();
455
+ }
456
+
457
+ // Render template editor with pages list
458
+ function renderTemplateEditor() {
459
+ const pages = currentBlock.pages || [];
460
+ const layoutSlots = currentBlock.layoutSlots || [];
461
+
462
+ const pagesHtml = pages.map((page, index) => `
463
+ <div class="template-page-item" onclick="navigateToTemplatePage('${page.slug}')">
464
+ <div class="template-page-header">
465
+ <span class="template-page-name">${escapeHtml(page.name)}</span>
466
+ <span class="template-page-blocks">${page.blocksCount} blocks</span>
467
+ </div>
468
+ <div class="template-page-slug">/${page.slug}</div>
469
+ </div>
470
+ `).join('');
471
+
472
+ const layoutHtml = layoutSlots.length > 0 ? `
473
+ <div class="template-section">
474
+ <h4 class="template-section-title">Layout Slots</h4>
475
+ ${layoutSlots.map(slot => `
476
+ <div class="template-layout-slot">
477
+ <span class="slot-type">${slot.slot}</span>
478
+ <span class="slot-block">${slot.type}</span>
479
+ </div>
480
+ `).join('')}
481
+ </div>
482
+ ` : '';
483
+
484
+ return `
485
+ <div class="template-editor">
486
+ <div class="template-info">
487
+ <div class="template-info-badge">Template</div>
488
+ <p class="template-info-desc">${escapeHtml(currentBlock.description || 'No description')}</p>
489
+ </div>
490
+
491
+ <div class="template-section">
492
+ <h4 class="template-section-title">Pages (${pages.length})</h4>
493
+ <div class="template-pages-list">
494
+ ${pagesHtml || '<div class="editor-empty">No pages defined</div>'}
495
+ </div>
496
+ </div>
497
+
498
+ ${layoutHtml}
499
+
500
+ <div class="template-hint">
501
+ <p>Click on a page to preview it, or use the tabs in the preview header.</p>
502
+ </div>
503
+ </div>
504
+ `;
505
+ }
506
+
507
+ // Navigate to template page in preview
508
+ window.navigateToTemplatePage = function(pageSlug) {
509
+ const iframe = document.getElementById('preview-iframe');
510
+ if (iframe && currentBlock) {
511
+ iframe.src = `/preview/template/${currentBlock.name}/${pageSlug}`;
512
+ }
513
+ };
514
+
515
+ // Render a single field based on type
516
+ function renderField(key, field, value) {
517
+ const required = field.required ? '<span class="field-required">*</span>' : '';
518
+ const helpText = field.helpText ? `<div class="field-help">${field.helpText}</div>` : '';
519
+
520
+ let inputHtml = '';
521
+
522
+ switch (field.type) {
523
+ case 'singleLine':
524
+ case 'text':
525
+ case 'string':
526
+ inputHtml = `
527
+ <input
528
+ type="text"
529
+ class="field-input"
530
+ data-field="${key}"
531
+ value="${escapeHtml(value || field.defaultValue || '')}"
532
+ placeholder="${field.placeholder || ''}"
533
+ ${field.required ? 'required' : ''}
534
+ />
535
+ `;
536
+ break;
537
+
538
+ case 'multiLine':
539
+ inputHtml = `
540
+ <textarea
541
+ class="field-input field-textarea"
542
+ data-field="${key}"
543
+ placeholder="${field.placeholder || ''}"
544
+ ${field.required ? 'required' : ''}
545
+ >${escapeHtml(value || field.defaultValue || '')}</textarea>
546
+ `;
547
+ break;
548
+
549
+ case 'richText':
550
+ inputHtml = `
551
+ <textarea
552
+ class="field-input field-textarea"
553
+ data-field="${key}"
554
+ placeholder="${field.placeholder || 'Enter rich text...'}"
555
+ ${field.required ? 'required' : ''}
556
+ style="min-height: 120px;"
557
+ >${escapeHtml(value || field.defaultValue || '')}</textarea>
558
+ `;
559
+ break;
560
+
561
+ case 'number':
562
+ inputHtml = `
563
+ <input
564
+ type="number"
565
+ class="field-input"
566
+ data-field="${key}"
567
+ value="${value !== undefined ? value : (field.defaultValue || '')}"
568
+ placeholder="${field.placeholder || ''}"
569
+ ${field.required ? 'required' : ''}
570
+ />
571
+ `;
572
+ break;
573
+
574
+ case 'boolean':
575
+ inputHtml = `
576
+ <label style="display: flex; align-items: center; cursor: pointer;">
577
+ <input
578
+ type="checkbox"
579
+ class="field-checkbox"
580
+ data-field="${key}"
581
+ ${value || field.defaultValue ? 'checked' : ''}
582
+ />
583
+ <span>${field.label}</span>
584
+ </label>
585
+ `;
586
+ break;
587
+
588
+ case 'date':
589
+ inputHtml = `
590
+ <input
591
+ type="date"
592
+ class="field-input"
593
+ data-field="${key}"
594
+ value="${value || field.defaultValue || ''}"
595
+ ${field.required ? 'required' : ''}
596
+ />
597
+ `;
598
+ break;
599
+
600
+ case 'link':
601
+ inputHtml = `
602
+ <input
603
+ type="url"
604
+ class="field-input"
605
+ data-field="${key}"
606
+ value="${escapeHtml(value || field.defaultValue || '')}"
607
+ placeholder="${field.placeholder || 'https://...'}"
608
+ ${field.required ? 'required' : ''}
609
+ />
610
+ `;
611
+ break;
612
+
613
+ case 'color':
614
+ const colorValue = value || field.defaultValue || '#000000';
615
+ inputHtml = `
616
+ <div class="color-field">
617
+ <input
618
+ type="color"
619
+ class="color-preview"
620
+ data-field="${key}"
621
+ value="${colorValue}"
622
+ />
623
+ <input
624
+ type="text"
625
+ class="field-input color-input"
626
+ data-field="${key}-text"
627
+ value="${colorValue}"
628
+ placeholder="#000000"
629
+ />
630
+ </div>
631
+ `;
632
+ break;
633
+
634
+ case 'select':
635
+ const currentValue = value || field.defaultValue || '';
636
+ inputHtml = `
637
+ <select class="field-input field-select" data-field="${key}" ${field.required ? 'required' : ''}>
638
+ <option value="">Select an option...</option>
639
+ ${field.options.map(opt => {
640
+ const optValue = typeof opt === 'string' ? opt : opt.value;
641
+ const optLabel = typeof opt === 'string' ? opt : opt.label;
642
+ return `<option value="${escapeHtml(optValue)}" ${currentValue === optValue ? 'selected' : ''}>${escapeHtml(optLabel)}</option>`;
643
+ }).join('')}
644
+ </select>
645
+ `;
646
+ break;
647
+
648
+ case 'media':
649
+ const mediaValue = value || field.defaultValue || {};
650
+ inputHtml = `
651
+ <div class="media-field">
652
+ <div class="media-preview">
653
+ ${mediaValue.url ?
654
+ `<img src="${escapeHtml(mediaValue.url)}" alt="${escapeHtml(mediaValue.alt || '')}"/>` :
655
+ '<div class="media-placeholder">No image</div>'
656
+ }
657
+ </div>
658
+ <div class="media-input-group">
659
+ <input
660
+ type="url"
661
+ class="field-input"
662
+ data-field="${key}.url"
663
+ value="${escapeHtml(mediaValue.url || '')}"
664
+ placeholder="Image URL"
665
+ style="margin-bottom: 8px;"
666
+ />
667
+ <input
668
+ type="text"
669
+ class="field-input"
670
+ data-field="${key}.alt"
671
+ value="${escapeHtml(mediaValue.alt || '')}"
672
+ placeholder="Alt text"
673
+ />
674
+ </div>
675
+ </div>
676
+ `;
677
+ break;
678
+
679
+ case 'repeater':
680
+ inputHtml = renderRepeaterField(key, field, value || field.defaultValue || []);
681
+ break;
682
+
683
+ default:
684
+ inputHtml = `<div style="color: #999;">Unsupported field type: ${field.type}</div>`;
685
+ }
686
+
687
+ // Special case for boolean - don't show separate label
688
+ if (field.type === 'boolean') {
689
+ return `
690
+ <div class="field-group">
691
+ ${inputHtml}
692
+ ${helpText}
693
+ </div>
694
+ `;
695
+ }
696
+
697
+ return `
698
+ <div class="field-group">
699
+ <label class="field-label">
700
+ ${field.label}${required}
701
+ </label>
702
+ ${inputHtml}
703
+ ${helpText}
704
+ </div>
705
+ `;
706
+ }
707
+
708
+ // Render repeater field
709
+ function renderRepeaterField(key, field, items) {
710
+ const minItems = field.minItems || 0;
711
+ const maxItems = field.maxItems || 999;
712
+
713
+ const itemsHtml = items.map((item, index) => {
714
+ const nestedFields = Object.entries(field.schema || {}).map(([nestedKey, nestedField]) => {
715
+ return renderField(`${key}.${index}.${nestedKey}`, nestedField, item[nestedKey]);
716
+ }).join('');
717
+
718
+ return `
719
+ <div class="repeater-item" data-repeater-item="${key}.${index}">
720
+ <div class="repeater-item-header">
721
+ <div class="repeater-item-title">Item ${index + 1}</div>
722
+ ${items.length > minItems ? `
723
+ <button
724
+ type="button"
725
+ class="repeater-item-remove"
726
+ onclick="removeRepeaterItem('${key}', ${index})"
727
+ >Remove</button>
728
+ ` : ''}
729
+ </div>
730
+ ${nestedFields}
731
+ </div>
732
+ `;
733
+ }).join('');
734
+
735
+ return `
736
+ <div class="repeater-items" data-repeater="${key}">
737
+ ${itemsHtml || '<div style="padding: 12px; color: #999; text-align: center;">No items yet</div>'}
738
+ </div>
739
+ ${items.length < maxItems ? `
740
+ <button
741
+ type="button"
742
+ class="repeater-add"
743
+ onclick="addRepeaterItem('${key}')"
744
+ >+ Add Item</button>
745
+ ` : ''}
746
+ `;
747
+ }
748
+
749
+ // Get nested field definition from schema using dot notation path
750
+ function getNestedFieldDefinition(key) {
751
+ const parts = key.split('.');
752
+ let fieldDef = null;
753
+ let schema = currentBlock.schema;
754
+
755
+ for (let i = 0; i < parts.length; i++) {
756
+ const part = parts[i];
757
+
758
+ // Skip numeric indices (they reference array items, not schema keys)
759
+ if (/^\d+$/.test(part)) {
760
+ continue;
761
+ }
762
+
763
+ if (schema && schema[part]) {
764
+ fieldDef = schema[part];
765
+ // If this is a repeater, its nested fields are in .schema
766
+ if (fieldDef.type === 'repeater' && fieldDef.schema) {
767
+ schema = fieldDef.schema;
768
+ }
769
+ } else {
770
+ return null;
771
+ }
772
+ }
773
+
774
+ return fieldDef;
775
+ }
776
+
777
+ // Get nested value from previewData using dot notation path
778
+ function getNestedValue(key) {
779
+ const parts = key.split('.');
780
+ let value = previewData;
781
+
782
+ for (const part of parts) {
783
+ if (value === undefined || value === null) return undefined;
784
+ value = value[part];
785
+ }
786
+
787
+ return value;
788
+ }
789
+
790
+ // Set nested value in previewData using dot notation path
791
+ function setNestedValue(key, newValue) {
792
+ const parts = key.split('.');
793
+ let obj = previewData;
794
+
795
+ for (let i = 0; i < parts.length - 1; i++) {
796
+ const part = parts[i];
797
+ const nextPart = parts[i + 1];
798
+
799
+ if (obj[part] === undefined) {
800
+ // Create array if next part is numeric, otherwise object
801
+ obj[part] = /^\d+$/.test(nextPart) ? [] : {};
802
+ }
803
+ obj = obj[part];
804
+ }
805
+
806
+ obj[parts[parts.length - 1]] = newValue;
807
+ }
808
+
809
+ // Add repeater item
810
+ window.addRepeaterItem = function(key) {
811
+ // Get field definition (supports nested paths like "plans.0.features")
812
+ const field = getNestedFieldDefinition(key);
813
+ if (!field || !field.schema) {
814
+ console.error('Could not find repeater field definition for:', key);
815
+ return;
816
+ }
817
+
818
+ // Get current items at the nested path
819
+ const currentItems = getNestedValue(key) || [];
820
+
821
+ // Create new empty item with defaults
822
+ const newItem = {};
823
+ Object.keys(field.schema).forEach(nestedKey => {
824
+ const nestedField = field.schema[nestedKey];
825
+ if (nestedField.type === 'repeater') {
826
+ newItem[nestedKey] = []; // Nested repeaters start empty
827
+ } else {
828
+ newItem[nestedKey] = nestedField.defaultValue !== undefined ? nestedField.defaultValue : '';
829
+ }
830
+ });
831
+
832
+ // Set the updated array at the nested path
833
+ setNestedValue(key, [...currentItems, newItem]);
834
+
835
+ // Re-render editor and save
836
+ renderEditor();
837
+ savePreviewData();
838
+ };
839
+
840
+ // Remove repeater item
841
+ window.removeRepeaterItem = function(key, index) {
842
+ // Get current items at the nested path
843
+ const currentItems = getNestedValue(key) || [];
844
+
845
+ // Filter out the item at the given index
846
+ const newItems = currentItems.filter((_, i) => i !== index);
847
+
848
+ // Set the updated array at the nested path
849
+ setNestedValue(key, newItems);
850
+
851
+ // Re-render editor and save
852
+ renderEditor();
853
+ savePreviewData();
854
+ };
855
+
856
+ // Attach event listeners to form fields
857
+ function attachFieldListeners() {
858
+ const inputs = document.querySelectorAll('[data-field]');
859
+ inputs.forEach(input => {
860
+ const eventType = input.type === 'checkbox' ? 'change' : 'input';
861
+ input.addEventListener(eventType, handleFieldChange);
862
+ });
863
+ }
864
+
865
+ // Handle field value change
866
+ function handleFieldChange(event) {
867
+ const input = event.target;
868
+ const fieldPath = input.dataset.field;
869
+
870
+ let value;
871
+ if (input.type === 'checkbox') {
872
+ value = input.checked;
873
+ } else if (input.type === 'number') {
874
+ value = input.value ? parseFloat(input.value) : '';
875
+ } else {
876
+ value = input.value;
877
+ }
878
+
879
+ // Use generic nested setter to handle any depth (supports nested repeaters)
880
+ setNestedValue(fieldPath, value);
881
+
882
+ // Sync color picker with text input
883
+ if (fieldPath.endsWith('-text')) {
884
+ const colorKey = fieldPath.replace('-text', '');
885
+ const colorInput = document.querySelector(`[data-field="${colorKey}"]`);
886
+ if (colorInput) colorInput.value = value;
887
+ }
888
+
889
+ // Debounce save (quick debounce since we're using postMessage for instant updates)
890
+ clearTimeout(window.saveTimeout);
891
+ window.saveTimeout = setTimeout(() => savePreviewData(), 200);
892
+ }
893
+
894
+ // Save preview data to server
895
+ async function savePreviewData() {
896
+ if (!currentBlock) return;
897
+
898
+ try {
899
+ // Update preview iframe immediately (no reload/blink)
900
+ const iframe = document.getElementById('preview-iframe');
901
+ if (iframe && iframe.contentWindow) {
902
+ iframe.contentWindow.postMessage({
903
+ type: 'UPDATE_PROPS',
904
+ props: previewData
905
+ }, '*');
906
+ }
907
+
908
+ // Save to server in background
909
+ const response = await fetch(`/api/preview/${currentBlock.name}`, {
910
+ method: 'POST',
911
+ headers: { 'Content-Type': 'application/json' },
912
+ body: JSON.stringify(previewData)
913
+ });
914
+
915
+ if (response.ok) {
916
+ document.getElementById('preview-status').textContent = 'Saved';
917
+ setTimeout(() => {
918
+ document.getElementById('preview-status').textContent = 'Ready';
919
+ }, 1000);
920
+ }
921
+ } catch (error) {
922
+ console.error('Failed to save preview data:', error);
923
+ document.getElementById('preview-status').textContent = 'Error';
924
+ }
925
+ }
926
+
927
+ // Setup Server-Sent Events for hot reload
928
+ function setupSSE() {
929
+ eventSource = new EventSource('/events');
930
+
931
+ eventSource.onmessage = async (event) => {
932
+ const data = JSON.parse(event.data);
933
+
934
+ if (data.type === 'reload') {
935
+ // If config changed, reload blocks list to update schema
936
+ if (data.configChanged && currentBlock && data.block === currentBlock.name) {
937
+ console.log('Config changed, reloading block data...');
938
+ await loadBlocks();
939
+ // Re-select current block to refresh properties sidebar
940
+ await selectBlock(currentBlock.name);
941
+ }
942
+
943
+ // Reload preview iframe
944
+ const iframe = document.getElementById('preview-iframe');
945
+ if (iframe && (!data.block || data.block === currentBlock?.name)) {
946
+ iframe.src = iframe.src; // Force reload
947
+ }
948
+ }
949
+
950
+ if (data.type === 'newBlock') {
951
+ console.log('New block detected:', data.block);
952
+ await loadBlocks();
953
+ }
954
+ };
955
+
956
+ eventSource.onerror = () => {
957
+ console.error('SSE connection lost. Reconnecting...');
958
+ };
959
+ }
960
+
961
+ // Utility: Escape HTML
962
+ function escapeHtml(text) {
963
+ const div = document.createElement('div');
964
+ div.textContent = text;
965
+ return div.innerHTML;
966
+ }
967
+
968
+ // Publish functionality
969
+ let workspacesCache = null;
970
+
971
+ async function loadWorkspaces() {
972
+ const select = document.getElementById('publish-workspace-id');
973
+ const errorDiv = document.getElementById('workspace-error');
974
+
975
+ try {
976
+ // Use cached workspaces if available
977
+ if (workspacesCache) {
978
+ populateWorkspaceSelect(workspacesCache);
979
+ return;
980
+ }
981
+
982
+ const response = await fetch('/api/workspaces');
983
+
984
+ if (!response.ok) {
985
+ const errorData = await response.json();
986
+ throw new Error(errorData.message || 'Failed to load workspaces');
987
+ }
988
+
989
+ const workspaces = await response.json();
990
+ workspacesCache = workspaces;
991
+
992
+ populateWorkspaceSelect(workspaces);
993
+ errorDiv.style.display = 'none';
994
+ } catch (error) {
995
+ console.error('Failed to load workspaces:', error);
996
+ select.innerHTML = '<option value="">Failed to load workspaces</option>';
997
+ errorDiv.textContent = error.message || 'Failed to load workspaces. Check your API token configuration.';
998
+ errorDiv.style.display = 'block';
999
+ }
1000
+ }
1001
+
1002
+ function populateWorkspaceSelect(workspaces) {
1003
+ const select = document.getElementById('publish-workspace-id');
1004
+
1005
+ if (workspaces.length === 0) {
1006
+ select.innerHTML = '<option value="">No workspaces found</option>';
1007
+ return;
1008
+ }
1009
+
1010
+ select.innerHTML = '<option value="">Select a workspace</option>';
1011
+ workspaces.forEach(ws => {
1012
+ const option = document.createElement('option');
1013
+ option.value = ws.id;
1014
+ const roleName = ws.myRole?.name || 'member';
1015
+ option.textContent = `${ws.name} (${roleName})`;
1016
+ select.appendChild(option);
1017
+ });
1018
+ }
1019
+
1020
+ window.openPublishModal = async function() {
1021
+ if (!currentBlock) return;
1022
+
1023
+ const modal = document.getElementById('publish-modal');
1024
+ const blockName = document.getElementById('publish-block-name');
1025
+ const localVersion = document.getElementById('publish-local-version');
1026
+ const publishedVersionRow = document.getElementById('publish-published-version-row');
1027
+
1028
+ blockName.textContent = currentBlock.displayName || currentBlock.name;
1029
+ localVersion.textContent = `v${currentBlock.version || '1.0.0'}`;
1030
+
1031
+ // Hide published version initially
1032
+ publishedVersionRow.style.display = 'none';
1033
+
1034
+ // Reset form
1035
+ document.getElementById('publish-target-marketplace').checked = true;
1036
+ document.getElementById('publish-workspace-id').value = '';
1037
+ document.getElementById('publish-version-bump').value = '';
1038
+
1039
+ // Load workspaces (will be shown when workspace target is selected)
1040
+ await loadWorkspaces();
1041
+
1042
+ // Show/hide workspace input
1043
+ toggleWorkspaceInput();
1044
+
1045
+ modal.classList.add('active');
1046
+ };
1047
+
1048
+ window.closePublishModal = function() {
1049
+ const modal = document.getElementById('publish-modal');
1050
+ modal.classList.remove('active');
1051
+
1052
+ // Reset to form view
1053
+ document.getElementById('publish-form').style.display = 'block';
1054
+ document.getElementById('publish-progress').style.display = 'none';
1055
+ };
1056
+
1057
+ // Simple semver increment helper
1058
+ function incrementVersion(version, type) {
1059
+ const parts = version.split('.').map(Number);
1060
+ if (parts.length !== 3) return version;
1061
+
1062
+ const [major, minor, patch] = parts;
1063
+
1064
+ switch (type) {
1065
+ case 'patch':
1066
+ return `${major}.${minor}.${patch + 1}`;
1067
+ case 'minor':
1068
+ return `${major}.${minor + 1}.0`;
1069
+ case 'major':
1070
+ return `${major + 1}.0.0`;
1071
+ default:
1072
+ return version;
1073
+ }
1074
+ }
1075
+
1076
+ async function fetchPublishedVersion(workspaceId) {
1077
+ if (!currentBlock || !workspaceId) return;
1078
+
1079
+ const publishedVersionRow = document.getElementById('publish-published-version-row');
1080
+ const publishedVersionSpan = document.getElementById('publish-published-version');
1081
+ const versionBumpSelect = document.getElementById('publish-version-bump');
1082
+
1083
+ try {
1084
+ const response = await fetch(`/api/blocks/${currentBlock.name}/published-version?workspaceId=${workspaceId}`);
1085
+ const data = await response.json();
1086
+
1087
+ if (data.published && data.version) {
1088
+ publishedVersionSpan.textContent = `v${data.version}`;
1089
+ publishedVersionRow.style.display = 'block';
1090
+
1091
+ // Update version bump options with calculated versions
1092
+ const currentVer = data.version;
1093
+ const patchVer = incrementVersion(currentVer, 'patch');
1094
+ const minorVer = incrementVersion(currentVer, 'minor');
1095
+ const majorVer = incrementVersion(currentVer, 'major');
1096
+
1097
+ versionBumpSelect.innerHTML = `
1098
+ <option value="">No change</option>
1099
+ <option value="patch">Patch (${currentVer} → ${patchVer})</option>
1100
+ <option value="minor">Minor (${currentVer} → ${minorVer})</option>
1101
+ <option value="major">Major (${currentVer} → ${majorVer})</option>
1102
+ `;
1103
+ } else {
1104
+ publishedVersionSpan.textContent = 'Not published yet';
1105
+ publishedVersionRow.style.display = 'block';
1106
+
1107
+ // Use local version for initial publish
1108
+ const localVer = currentBlock.version || '1.0.0';
1109
+ versionBumpSelect.innerHTML = `
1110
+ <option value="">Use current version (${localVer})</option>
1111
+ <option value="patch">Patch (${localVer} → ${incrementVersion(localVer, 'patch')})</option>
1112
+ <option value="minor">Minor (${localVer} → ${incrementVersion(localVer, 'minor')})</option>
1113
+ <option value="major">Major (${localVer} → ${incrementVersion(localVer, 'major')})</option>
1114
+ `;
1115
+ }
1116
+ } catch (error) {
1117
+ console.error('Failed to fetch published version:', error);
1118
+ publishedVersionRow.style.display = 'none';
1119
+ }
1120
+ }
1121
+
1122
+ window.handleWorkspaceChange = function() {
1123
+ const workspaceId = document.getElementById('publish-workspace-id').value;
1124
+ if (workspaceId) {
1125
+ fetchPublishedVersion(workspaceId);
1126
+ } else {
1127
+ // Hide published version when no workspace selected
1128
+ document.getElementById('publish-published-version-row').style.display = 'none';
1129
+ }
1130
+ };
1131
+
1132
+ window.toggleWorkspaceInput = function() {
1133
+ const target = document.querySelector('input[name="publish-target"]:checked').value;
1134
+ const workspaceGroup = document.getElementById('workspace-id-group');
1135
+ const publishedVersionRow = document.getElementById('publish-published-version-row');
1136
+
1137
+ if (target === 'workspace') {
1138
+ workspaceGroup.style.display = 'block';
1139
+ } else {
1140
+ workspaceGroup.style.display = 'none';
1141
+ publishedVersionRow.style.display = 'none';
1142
+ }
1143
+ };
1144
+
1145
+ window.startPublish = async function() {
1146
+ if (!currentBlock) return;
1147
+
1148
+ const target = document.querySelector('input[name="publish-target"]:checked').value;
1149
+ const workspaceId = document.getElementById('publish-workspace-id').value;
1150
+ const versionBump = document.getElementById('publish-version-bump').value;
1151
+
1152
+ // Validate workspace ID if needed
1153
+ if (target === 'workspace' && !workspaceId) {
1154
+ alert('Please select a workspace from the dropdown');
1155
+ return;
1156
+ }
1157
+
1158
+ // Show loading state
1159
+ document.getElementById('publish-form').style.display = 'none';
1160
+ const progressDiv = document.getElementById('publish-progress');
1161
+ progressDiv.style.display = 'block';
1162
+ progressDiv.innerHTML = `
1163
+ <div style="text-align: center; padding: 40px;">
1164
+ <div class="spinner" style="margin: 0 auto 16px;"></div>
1165
+ <div style="color: #666;">Publishing...</div>
1166
+ </div>
1167
+ `;
1168
+
1169
+ try {
1170
+ const response = await fetch(`/api/blocks/${currentBlock.name}/publish`, {
1171
+ method: 'POST',
1172
+ headers: { 'Content-Type': 'application/json' },
1173
+ body: JSON.stringify({ target, workspaceId, versionBump })
1174
+ });
1175
+
1176
+ const result = await response.json();
1177
+
1178
+ if (result.success) {
1179
+ // Update local block version
1180
+ currentBlock.version = result.version;
1181
+ renderBlocksList();
1182
+
1183
+ progressDiv.innerHTML = `
1184
+ <div style="text-align: center; padding: 40px;">
1185
+ <div style="font-size: 48px; margin-bottom: 16px;">✓</div>
1186
+ <div style="font-size: 18px; font-weight: 600; color: #22c55e; margin-bottom: 8px;">
1187
+ ${escapeHtml(result.message)}
1188
+ </div>
1189
+ ${result.version ? `<div style="color: #666;">Version: ${result.version}</div>` : ''}
1190
+ <button class="btn btn-primary" onclick="closePublishModal()" style="margin-top: 24px;">Done</button>
1191
+ </div>
1192
+ `;
1193
+ } else {
1194
+ throw new Error(result.error || 'Publish failed');
1195
+ }
1196
+ } catch (error) {
1197
+ console.error('Publish failed:', error);
1198
+ progressDiv.innerHTML = `
1199
+ <div style="text-align: center; padding: 40px;">
1200
+ <div style="font-size: 48px; margin-bottom: 16px; color: #ef4444;">✗</div>
1201
+ <div style="font-size: 18px; font-weight: 600; color: #ef4444; margin-bottom: 8px;">Publish Failed</div>
1202
+ <div style="color: #666; margin-bottom: 24px;">${escapeHtml(error.message)}</div>
1203
+ <button class="btn btn-secondary" onclick="closePublishModal()">Close</button>
1204
+ </div>
1205
+ `;
1206
+ }
1207
+ };
1208
+
1209
+ // Panel Toggle Functionality - Collapsed Sidebar
1210
+ let leftPanelCollapsed = false;
1211
+ let rightPanelCollapsed = false;
1212
+
1213
+ window.toggleLeftPanel = function() {
1214
+ const container = document.getElementById('container');
1215
+ const toggleBtn = document.getElementById('toggle-left');
1216
+
1217
+ leftPanelCollapsed = !leftPanelCollapsed;
1218
+
1219
+ if (leftPanelCollapsed) {
1220
+ container.classList.add('left-collapsed');
1221
+ toggleBtn.setAttribute('title', 'Expand panel (Ctrl+B)');
1222
+ } else {
1223
+ container.classList.remove('left-collapsed');
1224
+ toggleBtn.setAttribute('title', 'Collapse panel (Ctrl+B)');
1225
+ }
1226
+
1227
+ // Save preference
1228
+ localStorage.setItem('leftPanelCollapsed', leftPanelCollapsed);
1229
+ };
1230
+
1231
+ window.toggleRightPanel = function() {
1232
+ const container = document.getElementById('container');
1233
+ const toggleBtn = document.getElementById('toggle-right');
1234
+
1235
+ rightPanelCollapsed = !rightPanelCollapsed;
1236
+
1237
+ if (rightPanelCollapsed) {
1238
+ container.classList.add('right-collapsed');
1239
+ toggleBtn.setAttribute('title', 'Expand panel (Ctrl+E)');
1240
+ } else {
1241
+ container.classList.remove('right-collapsed');
1242
+ toggleBtn.setAttribute('title', 'Collapse panel (Ctrl+E)');
1243
+ }
1244
+
1245
+ // Save preference
1246
+ localStorage.setItem('rightPanelCollapsed', rightPanelCollapsed);
1247
+ };
1248
+
1249
+ // Restore panel states from localStorage
1250
+ function restorePanelStates() {
1251
+ const savedLeftState = localStorage.getItem('leftPanelCollapsed');
1252
+ const savedRightState = localStorage.getItem('rightPanelCollapsed');
1253
+
1254
+ if (savedLeftState === 'true') {
1255
+ leftPanelCollapsed = false; // Set to false so toggle will flip it
1256
+ toggleLeftPanel();
1257
+ }
1258
+
1259
+ if (savedRightState === 'true') {
1260
+ rightPanelCollapsed = false; // Set to false so toggle will flip it
1261
+ toggleRightPanel();
1262
+ }
1263
+ }
1264
+
1265
+ // Keyboard shortcuts
1266
+ document.addEventListener('keydown', (event) => {
1267
+ // Ctrl+B (or Cmd+B on Mac) - Toggle left panel
1268
+ if ((event.ctrlKey || event.metaKey) && event.key === 'b') {
1269
+ event.preventDefault();
1270
+ toggleLeftPanel();
1271
+ }
1272
+
1273
+ // Ctrl+E (or Cmd+E on Mac) - Toggle right panel
1274
+ if ((event.ctrlKey || event.metaKey) && event.key === 'e') {
1275
+ event.preventDefault();
1276
+ toggleRightPanel();
1277
+ }
1278
+ });
1279
+
1280
+ // Start the app
1281
+ init();
1282
+
1283
+ // Restore panel states after init
1284
+ setTimeout(restorePanelStates, 100);