cmssy-cli 0.6.0 → 0.9.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 (45) hide show
  1. package/README.md +382 -36
  2. package/dist/cli.js +29 -10
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/create.d.ts.map +1 -1
  5. package/dist/commands/create.js +10 -0
  6. package/dist/commands/create.js.map +1 -1
  7. package/dist/commands/dev.d.ts.map +1 -1
  8. package/dist/commands/dev.js +251 -12
  9. package/dist/commands/dev.js.map +1 -1
  10. package/dist/commands/init.d.ts.map +1 -1
  11. package/dist/commands/init.js +32 -22
  12. package/dist/commands/init.js.map +1 -1
  13. package/dist/commands/package.d.ts +7 -0
  14. package/dist/commands/package.d.ts.map +1 -0
  15. package/dist/commands/package.js +161 -0
  16. package/dist/commands/package.js.map +1 -0
  17. package/dist/commands/publish.d.ts +12 -0
  18. package/dist/commands/publish.d.ts.map +1 -0
  19. package/dist/commands/publish.js +395 -0
  20. package/dist/commands/publish.js.map +1 -0
  21. package/dist/commands/push.d.ts +9 -0
  22. package/dist/commands/push.d.ts.map +1 -0
  23. package/dist/commands/push.js +199 -0
  24. package/dist/commands/push.js.map +1 -0
  25. package/dist/commands/upload.d.ts +7 -0
  26. package/dist/commands/upload.d.ts.map +1 -0
  27. package/dist/commands/upload.js +126 -0
  28. package/dist/commands/upload.js.map +1 -0
  29. package/dist/dev-ui/app.js +657 -0
  30. package/dist/dev-ui/index.html +837 -0
  31. package/dist/utils/block-config.d.ts.map +1 -1
  32. package/dist/utils/block-config.js +49 -3
  33. package/dist/utils/block-config.js.map +1 -1
  34. package/dist/utils/cmssy-config.d.ts +0 -3
  35. package/dist/utils/cmssy-config.d.ts.map +1 -1
  36. package/dist/utils/cmssy-config.js.map +1 -1
  37. package/dist/utils/config.d.ts +1 -0
  38. package/dist/utils/config.d.ts.map +1 -1
  39. package/dist/utils/config.js +1 -0
  40. package/dist/utils/config.js.map +1 -1
  41. package/dist/utils/graphql.d.ts +1 -0
  42. package/dist/utils/graphql.d.ts.map +1 -1
  43. package/dist/utils/graphql.js +28 -0
  44. package/dist/utils/graphql.js.map +1 -1
  45. package/package.json +8 -3
@@ -0,0 +1,657 @@
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-header">
49
+ <div class="block-item-name">${block.displayName || block.name}</div>
50
+ <span class="version-badge">v${block.version || '1.0.0'}</span>
51
+ </div>
52
+ <div class="block-item-footer">
53
+ <span class="block-item-type">${block.type}</span>
54
+ <span class="status-badge status-local">Local</span>
55
+ </div>
56
+ </div>
57
+ `).join('');
58
+ }
59
+
60
+ // Select a block
61
+ async function selectBlock(blockName) {
62
+ const block = blocks.find(b => b.name === blockName);
63
+ if (!block) return;
64
+
65
+ currentBlock = block;
66
+ renderBlocksList(); // Update active state
67
+
68
+ // Load preview data
69
+ try {
70
+ const response = await fetch(`/api/preview/${blockName}`);
71
+ previewData = await response.json();
72
+ } catch (error) {
73
+ console.error('Failed to load preview data:', error);
74
+ previewData = {};
75
+ }
76
+
77
+ // Update UI
78
+ document.getElementById('preview-title').textContent = block.displayName || block.name;
79
+ document.getElementById('editor-subtitle').textContent = block.name;
80
+
81
+ // Show publish button
82
+ const publishBtn = document.getElementById('publish-btn');
83
+ if (publishBtn) {
84
+ publishBtn.style.display = 'block';
85
+ }
86
+
87
+ // Render preview
88
+ renderPreview();
89
+
90
+ // Render editor form
91
+ renderEditor();
92
+ }
93
+
94
+ // Render preview iframe
95
+ function renderPreview() {
96
+ if (!currentBlock) return;
97
+
98
+ const previewContent = document.getElementById('preview-content');
99
+ previewContent.innerHTML = `
100
+ <div class="preview-iframe-wrapper">
101
+ <iframe
102
+ class="preview-iframe"
103
+ src="/preview/${currentBlock.name}"
104
+ id="preview-iframe"
105
+ ></iframe>
106
+ </div>
107
+ `;
108
+ }
109
+
110
+ // Render editor form
111
+ function renderEditor() {
112
+ if (!currentBlock || !currentBlock.schema) {
113
+ document.getElementById('editor-content').innerHTML = `
114
+ <div class="editor-empty">No schema defined for this block</div>
115
+ `;
116
+ return;
117
+ }
118
+
119
+ const editorContent = document.getElementById('editor-content');
120
+ const fields = Object.entries(currentBlock.schema);
121
+
122
+ editorContent.innerHTML = fields.map(([key, field]) =>
123
+ renderField(key, field, previewData[key])
124
+ ).join('');
125
+
126
+ // Attach event listeners
127
+ attachFieldListeners();
128
+ }
129
+
130
+ // Render a single field based on type
131
+ function renderField(key, field, value) {
132
+ const required = field.required ? '<span class="field-required">*</span>' : '';
133
+ const helpText = field.helpText ? `<div class="field-help">${field.helpText}</div>` : '';
134
+
135
+ let inputHtml = '';
136
+
137
+ switch (field.type) {
138
+ case 'singleLine':
139
+ case 'text':
140
+ case 'string':
141
+ inputHtml = `
142
+ <input
143
+ type="text"
144
+ class="field-input"
145
+ data-field="${key}"
146
+ value="${escapeHtml(value || field.defaultValue || '')}"
147
+ placeholder="${field.placeholder || ''}"
148
+ ${field.required ? 'required' : ''}
149
+ />
150
+ `;
151
+ break;
152
+
153
+ case 'multiLine':
154
+ inputHtml = `
155
+ <textarea
156
+ class="field-input field-textarea"
157
+ data-field="${key}"
158
+ placeholder="${field.placeholder || ''}"
159
+ ${field.required ? 'required' : ''}
160
+ >${escapeHtml(value || field.defaultValue || '')}</textarea>
161
+ `;
162
+ break;
163
+
164
+ case 'richText':
165
+ inputHtml = `
166
+ <textarea
167
+ class="field-input field-textarea"
168
+ data-field="${key}"
169
+ placeholder="${field.placeholder || 'Enter rich text...'}"
170
+ ${field.required ? 'required' : ''}
171
+ style="min-height: 120px;"
172
+ >${escapeHtml(value || field.defaultValue || '')}</textarea>
173
+ `;
174
+ break;
175
+
176
+ case 'number':
177
+ inputHtml = `
178
+ <input
179
+ type="number"
180
+ class="field-input"
181
+ data-field="${key}"
182
+ value="${value !== undefined ? value : (field.defaultValue || '')}"
183
+ placeholder="${field.placeholder || ''}"
184
+ ${field.required ? 'required' : ''}
185
+ />
186
+ `;
187
+ break;
188
+
189
+ case 'boolean':
190
+ inputHtml = `
191
+ <label style="display: flex; align-items: center; cursor: pointer;">
192
+ <input
193
+ type="checkbox"
194
+ class="field-checkbox"
195
+ data-field="${key}"
196
+ ${value || field.defaultValue ? 'checked' : ''}
197
+ />
198
+ <span>${field.label}</span>
199
+ </label>
200
+ `;
201
+ break;
202
+
203
+ case 'date':
204
+ inputHtml = `
205
+ <input
206
+ type="date"
207
+ class="field-input"
208
+ data-field="${key}"
209
+ value="${value || field.defaultValue || ''}"
210
+ ${field.required ? 'required' : ''}
211
+ />
212
+ `;
213
+ break;
214
+
215
+ case 'link':
216
+ inputHtml = `
217
+ <input
218
+ type="url"
219
+ class="field-input"
220
+ data-field="${key}"
221
+ value="${escapeHtml(value || field.defaultValue || '')}"
222
+ placeholder="${field.placeholder || 'https://...'}"
223
+ ${field.required ? 'required' : ''}
224
+ />
225
+ `;
226
+ break;
227
+
228
+ case 'color':
229
+ const colorValue = value || field.defaultValue || '#000000';
230
+ inputHtml = `
231
+ <div class="color-field">
232
+ <input
233
+ type="color"
234
+ class="color-preview"
235
+ data-field="${key}"
236
+ value="${colorValue}"
237
+ />
238
+ <input
239
+ type="text"
240
+ class="field-input color-input"
241
+ data-field="${key}-text"
242
+ value="${colorValue}"
243
+ placeholder="#000000"
244
+ />
245
+ </div>
246
+ `;
247
+ break;
248
+
249
+ case 'select':
250
+ const currentValue = value || field.defaultValue || '';
251
+ inputHtml = `
252
+ <select class="field-input field-select" data-field="${key}" ${field.required ? 'required' : ''}>
253
+ <option value="">Select an option...</option>
254
+ ${field.options.map(opt => {
255
+ const optValue = typeof opt === 'string' ? opt : opt.value;
256
+ const optLabel = typeof opt === 'string' ? opt : opt.label;
257
+ return `<option value="${escapeHtml(optValue)}" ${currentValue === optValue ? 'selected' : ''}>${escapeHtml(optLabel)}</option>`;
258
+ }).join('')}
259
+ </select>
260
+ `;
261
+ break;
262
+
263
+ case 'media':
264
+ const mediaValue = value || field.defaultValue || {};
265
+ inputHtml = `
266
+ <div class="media-field">
267
+ <div class="media-preview">
268
+ ${mediaValue.url ?
269
+ `<img src="${escapeHtml(mediaValue.url)}" alt="${escapeHtml(mediaValue.alt || '')}"/>` :
270
+ '<div class="media-placeholder">No image</div>'
271
+ }
272
+ </div>
273
+ <div class="media-input-group">
274
+ <input
275
+ type="url"
276
+ class="field-input"
277
+ data-field="${key}.url"
278
+ value="${escapeHtml(mediaValue.url || '')}"
279
+ placeholder="Image URL"
280
+ style="margin-bottom: 8px;"
281
+ />
282
+ <input
283
+ type="text"
284
+ class="field-input"
285
+ data-field="${key}.alt"
286
+ value="${escapeHtml(mediaValue.alt || '')}"
287
+ placeholder="Alt text"
288
+ />
289
+ </div>
290
+ </div>
291
+ `;
292
+ break;
293
+
294
+ case 'repeater':
295
+ inputHtml = renderRepeaterField(key, field, value || field.defaultValue || []);
296
+ break;
297
+
298
+ default:
299
+ inputHtml = `<div style="color: #999;">Unsupported field type: ${field.type}</div>`;
300
+ }
301
+
302
+ // Special case for boolean - don't show separate label
303
+ if (field.type === 'boolean') {
304
+ return `
305
+ <div class="field-group">
306
+ ${inputHtml}
307
+ ${helpText}
308
+ </div>
309
+ `;
310
+ }
311
+
312
+ return `
313
+ <div class="field-group">
314
+ <label class="field-label">
315
+ ${field.label}${required}
316
+ </label>
317
+ ${inputHtml}
318
+ ${helpText}
319
+ </div>
320
+ `;
321
+ }
322
+
323
+ // Render repeater field
324
+ function renderRepeaterField(key, field, items) {
325
+ const minItems = field.minItems || 0;
326
+ const maxItems = field.maxItems || 999;
327
+
328
+ const itemsHtml = items.map((item, index) => {
329
+ const nestedFields = Object.entries(field.schema || {}).map(([nestedKey, nestedField]) => {
330
+ return renderField(`${key}.${index}.${nestedKey}`, nestedField, item[nestedKey]);
331
+ }).join('');
332
+
333
+ return `
334
+ <div class="repeater-item" data-repeater-item="${key}.${index}">
335
+ <div class="repeater-item-header">
336
+ <div class="repeater-item-title">Item ${index + 1}</div>
337
+ ${items.length > minItems ? `
338
+ <button
339
+ type="button"
340
+ class="repeater-item-remove"
341
+ onclick="removeRepeaterItem('${key}', ${index})"
342
+ >Remove</button>
343
+ ` : ''}
344
+ </div>
345
+ ${nestedFields}
346
+ </div>
347
+ `;
348
+ }).join('');
349
+
350
+ return `
351
+ <div class="repeater-items" data-repeater="${key}">
352
+ ${itemsHtml || '<div style="padding: 12px; color: #999; text-align: center;">No items yet</div>'}
353
+ </div>
354
+ ${items.length < maxItems ? `
355
+ <button
356
+ type="button"
357
+ class="repeater-add"
358
+ onclick="addRepeaterItem('${key}')"
359
+ >+ Add Item</button>
360
+ ` : ''}
361
+ `;
362
+ }
363
+
364
+ // Add repeater item
365
+ window.addRepeaterItem = function(key) {
366
+ const field = currentBlock.schema[key];
367
+ if (!field || !field.schema) return;
368
+
369
+ // Get current items
370
+ const currentItems = previewData[key] || [];
371
+
372
+ // Create new empty item
373
+ const newItem = {};
374
+ Object.keys(field.schema).forEach(nestedKey => {
375
+ const nestedField = field.schema[nestedKey];
376
+ newItem[nestedKey] = nestedField.defaultValue || '';
377
+ });
378
+
379
+ // Add to preview data
380
+ previewData[key] = [...currentItems, newItem];
381
+
382
+ // Re-render editor and save
383
+ renderEditor();
384
+ savePreviewData();
385
+ };
386
+
387
+ // Remove repeater item
388
+ window.removeRepeaterItem = function(key, index) {
389
+ const currentItems = previewData[key] || [];
390
+ previewData[key] = currentItems.filter((_, i) => i !== index);
391
+
392
+ // Re-render editor and save
393
+ renderEditor();
394
+ savePreviewData();
395
+ };
396
+
397
+ // Attach event listeners to form fields
398
+ function attachFieldListeners() {
399
+ const inputs = document.querySelectorAll('[data-field]');
400
+ inputs.forEach(input => {
401
+ const eventType = input.type === 'checkbox' ? 'change' : 'input';
402
+ input.addEventListener(eventType, handleFieldChange);
403
+ });
404
+ }
405
+
406
+ // Handle field value change
407
+ function handleFieldChange(event) {
408
+ const input = event.target;
409
+ const fieldPath = input.dataset.field;
410
+ const field = fieldPath.split('.');
411
+
412
+ let value;
413
+ if (input.type === 'checkbox') {
414
+ value = input.checked;
415
+ } else if (input.type === 'number') {
416
+ value = input.value ? parseFloat(input.value) : '';
417
+ } else {
418
+ value = input.value;
419
+ }
420
+
421
+ // Handle nested fields (e.g., "items.0.name" or "image.url")
422
+ if (field.length === 1) {
423
+ previewData[field[0]] = value;
424
+ } else if (field.length === 2) {
425
+ if (!previewData[field[0]]) previewData[field[0]] = {};
426
+ previewData[field[0]][field[1]] = value;
427
+ } else if (field.length === 3) {
428
+ // Repeater item field
429
+ if (!previewData[field[0]]) previewData[field[0]] = [];
430
+ if (!previewData[field[0]][field[1]]) previewData[field[0]][field[1]] = {};
431
+ previewData[field[0]][field[1]][field[2]] = value;
432
+ }
433
+
434
+ // Sync color picker with text input
435
+ if (fieldPath.endsWith('-text')) {
436
+ const colorKey = fieldPath.replace('-text', '');
437
+ const colorInput = document.querySelector(`[data-field="${colorKey}"]`);
438
+ if (colorInput) colorInput.value = value;
439
+ }
440
+
441
+ // Debounce save (quick debounce since we're using postMessage for instant updates)
442
+ clearTimeout(window.saveTimeout);
443
+ window.saveTimeout = setTimeout(() => savePreviewData(), 200);
444
+ }
445
+
446
+ // Save preview data to server
447
+ async function savePreviewData() {
448
+ if (!currentBlock) return;
449
+
450
+ try {
451
+ // Update preview iframe immediately (no reload/blink)
452
+ const iframe = document.getElementById('preview-iframe');
453
+ if (iframe && iframe.contentWindow) {
454
+ iframe.contentWindow.postMessage({
455
+ type: 'UPDATE_PROPS',
456
+ props: previewData
457
+ }, '*');
458
+ }
459
+
460
+ // Save to server in background
461
+ const response = await fetch(`/api/preview/${currentBlock.name}`, {
462
+ method: 'POST',
463
+ headers: { 'Content-Type': 'application/json' },
464
+ body: JSON.stringify(previewData)
465
+ });
466
+
467
+ if (response.ok) {
468
+ document.getElementById('preview-status').textContent = 'Saved';
469
+ setTimeout(() => {
470
+ document.getElementById('preview-status').textContent = 'Ready';
471
+ }, 1000);
472
+ }
473
+ } catch (error) {
474
+ console.error('Failed to save preview data:', error);
475
+ document.getElementById('preview-status').textContent = 'Error';
476
+ }
477
+ }
478
+
479
+ // Setup Server-Sent Events for hot reload
480
+ function setupSSE() {
481
+ eventSource = new EventSource('/events');
482
+
483
+ eventSource.onmessage = (event) => {
484
+ const data = JSON.parse(event.data);
485
+
486
+ if (data.type === 'reload') {
487
+ // Reload preview iframe
488
+ const iframe = document.getElementById('preview-iframe');
489
+ if (iframe && (!data.block || data.block === currentBlock?.name)) {
490
+ iframe.src = iframe.src; // Force reload
491
+ }
492
+ }
493
+ };
494
+
495
+ eventSource.onerror = () => {
496
+ console.error('SSE connection lost. Reconnecting...');
497
+ };
498
+ }
499
+
500
+ // Utility: Escape HTML
501
+ function escapeHtml(text) {
502
+ const div = document.createElement('div');
503
+ div.textContent = text;
504
+ return div.innerHTML;
505
+ }
506
+
507
+ // Publish functionality
508
+ let publishTaskId = null;
509
+ let publishEventSource = null;
510
+
511
+ window.openPublishModal = function() {
512
+ if (!currentBlock) return;
513
+
514
+ const modal = document.getElementById('publish-modal');
515
+ const blockName = document.getElementById('publish-block-name');
516
+ const version = document.getElementById('publish-version');
517
+
518
+ blockName.textContent = currentBlock.displayName || currentBlock.name;
519
+ version.textContent = `v${currentBlock.version || '1.0.0'}`;
520
+
521
+ // Reset form
522
+ document.getElementById('publish-target-marketplace').checked = true;
523
+ document.getElementById('publish-workspace-id').value = '';
524
+ document.getElementById('publish-version-bump').value = '';
525
+
526
+ // Show/hide workspace input
527
+ toggleWorkspaceInput();
528
+
529
+ modal.classList.add('active');
530
+ };
531
+
532
+ window.closePublishModal = function() {
533
+ const modal = document.getElementById('publish-modal');
534
+ modal.classList.remove('active');
535
+ };
536
+
537
+ window.toggleWorkspaceInput = function() {
538
+ const target = document.querySelector('input[name="publish-target"]:checked').value;
539
+ const workspaceGroup = document.getElementById('workspace-id-group');
540
+
541
+ if (target === 'workspace') {
542
+ workspaceGroup.style.display = 'block';
543
+ } else {
544
+ workspaceGroup.style.display = 'none';
545
+ }
546
+ };
547
+
548
+ window.startPublish = async function() {
549
+ if (!currentBlock) return;
550
+
551
+ const target = document.querySelector('input[name="publish-target"]:checked').value;
552
+ const workspaceId = document.getElementById('publish-workspace-id').value;
553
+ const versionBump = document.getElementById('publish-version-bump').value;
554
+
555
+ // Validate workspace ID if needed
556
+ if (target === 'workspace' && !workspaceId) {
557
+ alert('Workspace ID is required for workspace publish');
558
+ return;
559
+ }
560
+
561
+ // Show progress UI, hide form
562
+ document.getElementById('publish-form').style.display = 'none';
563
+ document.getElementById('publish-progress').style.display = 'block';
564
+
565
+ try {
566
+ // Start publish
567
+ const response = await fetch(`/api/blocks/${currentBlock.name}/publish`, {
568
+ method: 'POST',
569
+ headers: { 'Content-Type': 'application/json' },
570
+ body: JSON.stringify({ target, workspaceId, versionBump })
571
+ });
572
+
573
+ if (!response.ok) {
574
+ const error = await response.json();
575
+ throw new Error(error.error || 'Failed to start publish');
576
+ }
577
+
578
+ const { taskId } = await response.json();
579
+ publishTaskId = taskId;
580
+
581
+ // Stream progress
582
+ streamPublishProgress(taskId);
583
+
584
+ } catch (error) {
585
+ console.error('Publish failed:', error);
586
+ showPublishError(error.message);
587
+ }
588
+ };
589
+
590
+ function streamPublishProgress(taskId) {
591
+ // Close existing connection
592
+ if (publishEventSource) {
593
+ publishEventSource.close();
594
+ }
595
+
596
+ publishEventSource = new EventSource(`/api/publish/progress/${taskId}`);
597
+
598
+ publishEventSource.onmessage = (event) => {
599
+ const task = JSON.parse(event.data);
600
+ updatePublishProgress(task);
601
+
602
+ // Close connection when done
603
+ if (task.status === 'completed' || task.status === 'failed') {
604
+ publishEventSource.close();
605
+ publishEventSource = null;
606
+ }
607
+ };
608
+
609
+ publishEventSource.onerror = () => {
610
+ console.error('SSE connection lost');
611
+ publishEventSource.close();
612
+ publishEventSource = null;
613
+ };
614
+ }
615
+
616
+ function updatePublishProgress(task) {
617
+ const progressBar = document.getElementById('publish-progress-bar');
618
+ const progressText = document.getElementById('publish-progress-text');
619
+ const stepsContainer = document.getElementById('publish-steps');
620
+
621
+ // Update progress bar
622
+ progressBar.style.width = `${task.progress}%`;
623
+ progressText.textContent = `${task.progress}%`;
624
+
625
+ // Update steps
626
+ stepsContainer.innerHTML = task.steps.map(step => `
627
+ <div class="progress-step ${step.status}">
628
+ <span class="step-icon">
629
+ ${step.status === 'completed' ? '✓' : step.status === 'failed' ? '✗' : '⏳'}
630
+ </span>
631
+ <span class="step-message">${step.message}</span>
632
+ </div>
633
+ `).join('');
634
+
635
+ // Handle completion
636
+ if (task.status === 'completed') {
637
+ document.getElementById('publish-close-btn').style.display = 'block';
638
+ document.getElementById('publish-close-btn').textContent = 'Done';
639
+ } else if (task.status === 'failed') {
640
+ document.getElementById('publish-close-btn').style.display = 'block';
641
+ document.getElementById('publish-close-btn').textContent = 'Close';
642
+ }
643
+ }
644
+
645
+ function showPublishError(message) {
646
+ const progressDiv = document.getElementById('publish-progress');
647
+ progressDiv.innerHTML = `
648
+ <div class="publish-error">
649
+ <div class="error-icon">✗</div>
650
+ <div class="error-message">${escapeHtml(message)}</div>
651
+ <button class="btn btn-secondary" onclick="closePublishModal()">Close</button>
652
+ </div>
653
+ `;
654
+ }
655
+
656
+ // Start the app
657
+ init();