lobsterboard 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.
package/src/builder.js ADDED
@@ -0,0 +1,723 @@
1
+ /**
2
+ * LobsterBoard - Dashboard Builder Core
3
+ * Provides utilities for generating dashboard HTML, CSS, and JS
4
+ *
5
+ * @module lobsterboard/builder
6
+ */
7
+
8
+ import { WIDGETS } from './widgets.js';
9
+
10
+ // ─────────────────────────────────────────────
11
+ // SECURITY HELPERS
12
+ // ─────────────────────────────────────────────
13
+
14
+ /**
15
+ * Escape HTML to prevent XSS attacks
16
+ * @param {string} str - String to escape
17
+ * @returns {string} Escaped string
18
+ */
19
+ export function escapeHtml(str) {
20
+ if (!str) return '';
21
+ if (typeof document !== 'undefined') {
22
+ const div = document.createElement('div');
23
+ div.textContent = str;
24
+ return div.innerHTML;
25
+ }
26
+ // Fallback for Node.js
27
+ return str
28
+ .replace(/&/g, '&')
29
+ .replace(/</g, '&lt;')
30
+ .replace(/>/g, '&gt;')
31
+ .replace(/"/g, '&quot;')
32
+ .replace(/'/g, '&#039;');
33
+ }
34
+
35
+ // ─────────────────────────────────────────────
36
+ // HTML PROCESSING
37
+ // ─────────────────────────────────────────────
38
+
39
+ /**
40
+ * Process widget HTML to conditionally remove header
41
+ * @param {string} html - Widget HTML
42
+ * @param {boolean} showHeader - Whether to show the header
43
+ * @returns {string} Processed HTML
44
+ */
45
+ export function processWidgetHtml(html, showHeader) {
46
+ if (showHeader !== false) return html;
47
+ const headerRegex = /<div\s+class="dash-card-head"[^>]*>[\s\S]*?<\/div>/i;
48
+ return html.replace(headerRegex, '');
49
+ }
50
+
51
+ // ─────────────────────────────────────────────
52
+ // CSS GENERATION
53
+ // ─────────────────────────────────────────────
54
+
55
+ /**
56
+ * Generate the base dashboard CSS
57
+ * @returns {string} CSS styles
58
+ */
59
+ export function generateDashboardCss() {
60
+ return `/* LobsterBoard Dashboard - Generated Styles */
61
+
62
+ :root {
63
+ --bg-primary: #0d1117;
64
+ --bg-secondary: #161b22;
65
+ --bg-tertiary: #21262d;
66
+ --bg-hover: #30363d;
67
+ --border: #30363d;
68
+ --text-primary: #e6edf3;
69
+ --text-secondary: #8b949e;
70
+ --text-muted: #6e7681;
71
+ --accent-blue: #58a6ff;
72
+ --accent-green: #3fb950;
73
+ --accent-orange: #d29922;
74
+ --accent-red: #f85149;
75
+ --accent-purple: #a371f7;
76
+ }
77
+
78
+ * {
79
+ box-sizing: border-box;
80
+ margin: 0;
81
+ padding: 0;
82
+ }
83
+
84
+ body {
85
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
86
+ background: var(--bg-primary);
87
+ color: var(--text-primary);
88
+ min-height: 100vh;
89
+ }
90
+
91
+ .dashboard {
92
+ margin: 0 auto;
93
+ overflow: hidden;
94
+ }
95
+
96
+ .widget-container {
97
+ overflow: hidden;
98
+ }
99
+
100
+ /* KPI Cards */
101
+ .kpi-card {
102
+ background: var(--bg-secondary);
103
+ border: 1px solid var(--border);
104
+ border-radius: 8px;
105
+ padding: 16px;
106
+ display: flex;
107
+ align-items: center;
108
+ gap: 12px;
109
+ height: 100%;
110
+ }
111
+
112
+ .kpi-sm {
113
+ padding: 12px;
114
+ }
115
+
116
+ .kpi-icon {
117
+ font-size: 24px;
118
+ }
119
+
120
+ .kpi-data {
121
+ flex: 1;
122
+ }
123
+
124
+ .kpi-value {
125
+ font-size: 20px;
126
+ font-weight: 600;
127
+ }
128
+
129
+ .kpi-value.blue { color: var(--accent-blue); }
130
+ .kpi-value.green { color: var(--accent-green); }
131
+ .kpi-value.orange { color: var(--accent-orange); }
132
+ .kpi-value.red { color: var(--accent-red); }
133
+
134
+ .kpi-label {
135
+ font-size: 12px;
136
+ color: var(--text-secondary);
137
+ margin-top: 2px;
138
+ }
139
+
140
+ .kpi-indicator {
141
+ width: 10px;
142
+ height: 10px;
143
+ border-radius: 50%;
144
+ background: var(--text-muted);
145
+ }
146
+
147
+ .kpi-indicator.green { background: var(--accent-green); }
148
+ .kpi-indicator.yellow { background: var(--accent-orange); }
149
+ .kpi-indicator.red { background: var(--accent-red); }
150
+
151
+ /* Dash Cards */
152
+ .dash-card {
153
+ background: var(--bg-secondary);
154
+ border: 1px solid var(--border);
155
+ border-radius: 8px;
156
+ display: flex;
157
+ flex-direction: column;
158
+ height: 100%;
159
+ overflow: hidden;
160
+ }
161
+
162
+ .dash-card-head {
163
+ display: flex;
164
+ justify-content: space-between;
165
+ align-items: center;
166
+ padding: 12px 16px;
167
+ border-bottom: 1px solid var(--border);
168
+ background: var(--bg-tertiary);
169
+ }
170
+
171
+ .dash-card-title {
172
+ font-size: 13px;
173
+ font-weight: 600;
174
+ }
175
+
176
+ .dash-card-badge {
177
+ font-size: 11px;
178
+ color: var(--text-secondary);
179
+ background: var(--bg-primary);
180
+ padding: 2px 8px;
181
+ border-radius: 10px;
182
+ }
183
+
184
+ .dash-card-body {
185
+ flex: 1;
186
+ padding: 12px 16px;
187
+ overflow-y: auto;
188
+ }
189
+
190
+ .compact-list {
191
+ font-size: 12px;
192
+ }
193
+
194
+ .syslog-scroll {
195
+ font-family: 'SF Mono', Monaco, monospace;
196
+ font-size: 11px;
197
+ }
198
+
199
+ /* Top Bar */
200
+ .topbar {
201
+ display: flex;
202
+ justify-content: space-between;
203
+ align-items: center;
204
+ padding: 8px 20px;
205
+ background: var(--bg-secondary);
206
+ border-bottom: 1px solid var(--border);
207
+ height: 100%;
208
+ }
209
+
210
+ .topbar-left {
211
+ display: flex;
212
+ align-items: center;
213
+ gap: 20px;
214
+ }
215
+
216
+ .topbar-brand {
217
+ font-weight: 600;
218
+ font-size: 14px;
219
+ }
220
+
221
+ .topbar-link {
222
+ color: var(--text-secondary);
223
+ text-decoration: none;
224
+ font-size: 13px;
225
+ }
226
+
227
+ .topbar-link:hover,
228
+ .topbar-link.active {
229
+ color: var(--accent-blue);
230
+ }
231
+
232
+ .topbar-right {
233
+ display: flex;
234
+ align-items: center;
235
+ gap: 12px;
236
+ }
237
+
238
+ .topbar-meta {
239
+ font-size: 12px;
240
+ color: var(--text-muted);
241
+ }
242
+
243
+ .topbar-refresh {
244
+ background: var(--bg-tertiary);
245
+ border: 1px solid var(--border);
246
+ color: var(--text-secondary);
247
+ padding: 4px 8px;
248
+ border-radius: 4px;
249
+ cursor: pointer;
250
+ }
251
+
252
+ /* List Items */
253
+ .list-item {
254
+ padding: 6px 0;
255
+ border-bottom: 1px solid var(--border);
256
+ }
257
+
258
+ .list-item:last-child {
259
+ border-bottom: none;
260
+ }
261
+
262
+ .cron-item {
263
+ display: flex;
264
+ justify-content: space-between;
265
+ padding: 6px 0;
266
+ border-bottom: 1px solid var(--border);
267
+ }
268
+
269
+ .cron-name {
270
+ color: var(--text-primary);
271
+ }
272
+
273
+ .cron-next {
274
+ color: var(--text-muted);
275
+ font-size: 11px;
276
+ }
277
+
278
+ .log-line {
279
+ padding: 2px 0;
280
+ border-bottom: 1px solid rgba(48, 54, 61, 0.5);
281
+ }
282
+
283
+ /* Weather */
284
+ .weather-row {
285
+ display: flex;
286
+ align-items: center;
287
+ gap: 10px;
288
+ padding: 8px 0;
289
+ border-bottom: 1px solid var(--border);
290
+ }
291
+
292
+ .weather-row:last-child {
293
+ border-bottom: none;
294
+ }
295
+
296
+ .weather-icon {
297
+ font-size: 18px;
298
+ }
299
+
300
+ .weather-loc {
301
+ flex: 1;
302
+ color: var(--text-primary);
303
+ }
304
+
305
+ .weather-temp {
306
+ font-weight: 600;
307
+ color: var(--accent-blue);
308
+ }
309
+
310
+ /* Utilities */
311
+ .loading-sm {
312
+ display: flex;
313
+ align-items: center;
314
+ justify-content: center;
315
+ padding: 20px;
316
+ }
317
+
318
+ .spinner-sm {
319
+ width: 20px;
320
+ height: 20px;
321
+ border: 2px solid var(--bg-tertiary);
322
+ border-top-color: var(--accent-blue);
323
+ border-radius: 50%;
324
+ animation: spin 1s linear infinite;
325
+ }
326
+
327
+ @keyframes spin {
328
+ to { transform: rotate(360deg); }
329
+ }
330
+
331
+ .error {
332
+ color: var(--accent-red);
333
+ padding: 10px;
334
+ text-align: center;
335
+ }
336
+
337
+ ::-webkit-scrollbar {
338
+ width: 6px;
339
+ }
340
+
341
+ ::-webkit-scrollbar-track {
342
+ background: var(--bg-primary);
343
+ }
344
+
345
+ ::-webkit-scrollbar-thumb {
346
+ background: var(--bg-tertiary);
347
+ border-radius: 3px;
348
+ }
349
+
350
+ /* Post-Export Edit Mode */
351
+ .edit-mode .widget-container {
352
+ cursor: move;
353
+ outline: 2px dashed #3b82f6;
354
+ outline-offset: -2px;
355
+ }
356
+
357
+ .edit-mode .widget-container:hover {
358
+ outline-color: #60a5fa;
359
+ }
360
+
361
+ .edit-mode .widget-container.dragging {
362
+ opacity: 0.8;
363
+ z-index: 1000;
364
+ }
365
+
366
+ .resize-handle-edit {
367
+ display: none;
368
+ position: absolute;
369
+ bottom: 0;
370
+ right: 0;
371
+ width: 16px;
372
+ height: 16px;
373
+ cursor: se-resize;
374
+ background: #3b82f6;
375
+ border-radius: 2px 0 0 0;
376
+ z-index: 10;
377
+ }
378
+
379
+ .edit-mode .resize-handle-edit {
380
+ display: block;
381
+ }
382
+
383
+ #edit-toggle {
384
+ position: fixed;
385
+ bottom: 20px;
386
+ right: 20px;
387
+ z-index: 9999;
388
+ padding: 8px 16px;
389
+ background: #1e293b;
390
+ color: white;
391
+ border: none;
392
+ border-radius: 6px;
393
+ cursor: pointer;
394
+ font-size: 13px;
395
+ font-weight: 500;
396
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
397
+ }
398
+
399
+ #edit-toggle:hover {
400
+ background: #334155;
401
+ }
402
+
403
+ #edit-toggle.active {
404
+ background: #3b82f6;
405
+ }
406
+ `;
407
+ }
408
+
409
+ // ─────────────────────────────────────────────
410
+ // JS GENERATION
411
+ // ─────────────────────────────────────────────
412
+
413
+ /**
414
+ * Generate the post-export edit mode JS
415
+ * @returns {string} JavaScript code
416
+ */
417
+ export function generateEditJs() {
418
+ return `
419
+ // ─────────────────────────────────────────────
420
+ // POST-EXPORT LAYOUT EDITING
421
+ // ─────────────────────────────────────────────
422
+
423
+ (function() {
424
+ const STORAGE_KEY = 'lobsterboard-layout';
425
+ const GRID_SIZE = 20;
426
+ const MIN_WIDTH = 100;
427
+ const MIN_HEIGHT = 60;
428
+
429
+ let editMode = false;
430
+ let activeWidget = null;
431
+ let startX, startY, origLeft, origTop, origWidth, origHeight;
432
+ let isResizing = false;
433
+
434
+ document.addEventListener('DOMContentLoaded', initEditMode);
435
+
436
+ function initEditMode() {
437
+ const btn = document.createElement('button');
438
+ btn.id = 'edit-toggle';
439
+ btn.textContent = '✏️ Edit Layout';
440
+ btn.onclick = toggleEditMode;
441
+ document.body.appendChild(btn);
442
+ document.querySelectorAll('.widget-container').forEach(initWidget);
443
+ loadPositions();
444
+ }
445
+
446
+ function initWidget(widget) {
447
+ const handle = document.createElement('div');
448
+ handle.className = 'resize-handle-edit';
449
+ widget.appendChild(handle);
450
+ widget.addEventListener('mousedown', onWidgetMouseDown);
451
+ handle.addEventListener('mousedown', onResizeMouseDown);
452
+ }
453
+
454
+ function toggleEditMode() {
455
+ editMode = !editMode;
456
+ document.body.classList.toggle('edit-mode', editMode);
457
+ document.getElementById('edit-toggle').classList.toggle('active', editMode);
458
+ document.getElementById('edit-toggle').textContent = editMode ? '💾 Save Layout' : '✏️ Edit Layout';
459
+ if (!editMode) savePositions();
460
+ }
461
+
462
+ function onWidgetMouseDown(e) {
463
+ if (!editMode) return;
464
+ if (e.target.classList.contains('resize-handle-edit')) return;
465
+ if (e.button !== 0) return;
466
+ e.preventDefault();
467
+ activeWidget = e.currentTarget;
468
+ isResizing = false;
469
+ startX = e.clientX;
470
+ startY = e.clientY;
471
+ origLeft = activeWidget.offsetLeft;
472
+ origTop = activeWidget.offsetTop;
473
+ activeWidget.classList.add('dragging');
474
+ document.addEventListener('mousemove', onMouseMove);
475
+ document.addEventListener('mouseup', onMouseUp);
476
+ }
477
+
478
+ function onResizeMouseDown(e) {
479
+ if (!editMode) return;
480
+ e.preventDefault();
481
+ e.stopPropagation();
482
+ activeWidget = e.target.parentElement;
483
+ isResizing = true;
484
+ startX = e.clientX;
485
+ startY = e.clientY;
486
+ origWidth = activeWidget.offsetWidth;
487
+ origHeight = activeWidget.offsetHeight;
488
+ activeWidget.classList.add('dragging');
489
+ document.addEventListener('mousemove', onMouseMove);
490
+ document.addEventListener('mouseup', onMouseUp);
491
+ }
492
+
493
+ function onMouseMove(e) {
494
+ if (!activeWidget) return;
495
+ const dx = e.clientX - startX;
496
+ const dy = e.clientY - startY;
497
+ if (isResizing) {
498
+ activeWidget.style.width = Math.max(MIN_WIDTH, origWidth + dx) + 'px';
499
+ activeWidget.style.height = Math.max(MIN_HEIGHT, origHeight + dy) + 'px';
500
+ } else {
501
+ activeWidget.style.left = Math.max(0, origLeft + dx) + 'px';
502
+ activeWidget.style.top = Math.max(0, origTop + dy) + 'px';
503
+ }
504
+ }
505
+
506
+ function onMouseUp() {
507
+ if (!activeWidget) return;
508
+ if (isResizing) {
509
+ activeWidget.style.width = snapToGrid(activeWidget.offsetWidth) + 'px';
510
+ activeWidget.style.height = snapToGrid(activeWidget.offsetHeight) + 'px';
511
+ } else {
512
+ activeWidget.style.left = snapToGrid(activeWidget.offsetLeft) + 'px';
513
+ activeWidget.style.top = snapToGrid(activeWidget.offsetTop) + 'px';
514
+ }
515
+ activeWidget.classList.remove('dragging');
516
+ activeWidget = null;
517
+ isResizing = false;
518
+ document.removeEventListener('mousemove', onMouseMove);
519
+ document.removeEventListener('mouseup', onMouseUp);
520
+ }
521
+
522
+ function snapToGrid(value) {
523
+ return Math.round(value / GRID_SIZE) * GRID_SIZE;
524
+ }
525
+
526
+ function savePositions() {
527
+ const positions = {};
528
+ document.querySelectorAll('.widget-container').forEach(widget => {
529
+ const id = widget.dataset.widgetId;
530
+ if (id) {
531
+ positions[id] = {
532
+ left: widget.offsetLeft,
533
+ top: widget.offsetTop,
534
+ width: widget.offsetWidth,
535
+ height: widget.offsetHeight
536
+ };
537
+ }
538
+ });
539
+ try {
540
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(positions));
541
+ } catch (e) {}
542
+ }
543
+
544
+ function loadPositions() {
545
+ try {
546
+ const saved = localStorage.getItem(STORAGE_KEY);
547
+ if (!saved) return;
548
+ const positions = JSON.parse(saved);
549
+ document.querySelectorAll('.widget-container').forEach(widget => {
550
+ const id = widget.dataset.widgetId;
551
+ const pos = positions[id];
552
+ if (pos) {
553
+ widget.style.left = pos.left + 'px';
554
+ widget.style.top = pos.top + 'px';
555
+ widget.style.width = pos.width + 'px';
556
+ widget.style.height = pos.height + 'px';
557
+ }
558
+ });
559
+ } catch (e) {}
560
+ }
561
+ })();
562
+ `;
563
+ }
564
+
565
+ // ─────────────────────────────────────────────
566
+ // DASHBOARD GENERATION
567
+ // ─────────────────────────────────────────────
568
+
569
+ /**
570
+ * Generate widget HTML for a widget configuration
571
+ * @param {Object} widget - Widget configuration
572
+ * @returns {string} Widget HTML
573
+ */
574
+ export function generateWidgetHtml(widget) {
575
+ const template = WIDGETS[widget.type];
576
+ if (!template) return '';
577
+
578
+ const props = { ...widget.properties, id: widget.id };
579
+ let html = processWidgetHtml(template.generateHtml(props), widget.properties.showHeader);
580
+
581
+ return `
582
+ <div class="widget-container" data-widget-id="${widget.id}" style="position:absolute;left:${widget.x}px;top:${widget.y}px;width:${widget.width}px;height:${widget.height}px;">
583
+ ${html}
584
+ </div>`;
585
+ }
586
+
587
+ /**
588
+ * Generate widget JavaScript for a widget configuration
589
+ * @param {Object} widget - Widget configuration
590
+ * @returns {string} Widget JavaScript
591
+ */
592
+ export function generateWidgetJs(widget) {
593
+ const template = WIDGETS[widget.type];
594
+ if (!template || !template.generateJs) return '';
595
+
596
+ const props = { ...widget.properties, id: widget.id };
597
+ return template.generateJs(props);
598
+ }
599
+
600
+ /**
601
+ * Generate complete dashboard HTML
602
+ * @param {Object} config - Dashboard configuration
603
+ * @param {Object} config.canvas - Canvas dimensions { width, height }
604
+ * @param {Array} config.widgets - Array of widget configurations
605
+ * @returns {string} Complete HTML document
606
+ */
607
+ export function generateDashboardHtml(config) {
608
+ const { canvas, widgets } = config;
609
+ const widgetHtml = widgets.map(generateWidgetHtml).join('\n');
610
+
611
+ return `<!DOCTYPE html>
612
+ <html lang="en">
613
+ <head>
614
+ <meta charset="UTF-8">
615
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
616
+ <title>My LobsterBoard Dashboard</title>
617
+ <link rel="stylesheet" href="css/style.css">
618
+ </head>
619
+ <body>
620
+ <main class="dashboard" style="width:${canvas.width}px;height:${canvas.height}px;position:relative;">
621
+ ${widgetHtml}
622
+ </main>
623
+ <script src="js/dashboard.js"></script>
624
+ </body>
625
+ </html>`;
626
+ }
627
+
628
+ /**
629
+ * Generate complete dashboard JavaScript
630
+ * @param {Array} widgets - Array of widget configurations
631
+ * @returns {string} Complete JavaScript
632
+ */
633
+ export function generateDashboardJs(widgets) {
634
+ const widgetJs = widgets.map(generateWidgetJs).filter(Boolean).join('\n\n');
635
+ const editJs = generateEditJs();
636
+
637
+ return `/**
638
+ * LobsterBoard Dashboard - Generated JavaScript
639
+ * Replace YOUR_*_API_KEY placeholders with your actual API keys
640
+ */
641
+
642
+ document.addEventListener('DOMContentLoaded', () => {
643
+ console.log('Dashboard loaded');
644
+ });
645
+
646
+ ${widgetJs}
647
+
648
+ ${editJs}
649
+ `;
650
+ }
651
+
652
+ /**
653
+ * Generate README for exported dashboard
654
+ * @param {Array} widgets - Array of widget configurations
655
+ * @returns {string} README markdown
656
+ */
657
+ export function generateReadme(widgets) {
658
+ const apiKeys = [];
659
+ const needsOpenClaw = widgets.some(w =>
660
+ ['openclaw-release', 'auth-status', 'activity-list', 'cron-jobs', 'system-log', 'session-count', 'token-gauge'].includes(w.type)
661
+ );
662
+
663
+ widgets.forEach(widget => {
664
+ const template = WIDGETS[widget.type];
665
+ if (template?.hasApiKey && template.apiKeyName) {
666
+ if (!apiKeys.includes(template.apiKeyName)) {
667
+ apiKeys.push(template.apiKeyName);
668
+ }
669
+ }
670
+ });
671
+
672
+ return `# LobsterBoard Dashboard
673
+
674
+ This dashboard was generated with LobsterBoard Dashboard Builder.
675
+
676
+ ## Quick Start
677
+
678
+ ${needsOpenClaw ? `### Running with OpenClaw widgets
679
+
680
+ Your dashboard includes widgets that connect to OpenClaw. Run the included server:
681
+
682
+ \`\`\`bash
683
+ node server.js
684
+ \`\`\`
685
+
686
+ Open http://localhost:8080 in your browser.
687
+ ` : ''}
688
+ ### Static mode
689
+
690
+ Open \`index.html\` directly in a browser.
691
+
692
+ ## Files
693
+
694
+ | File | Description |
695
+ |------|-------------|
696
+ | \`index.html\` | Dashboard page |
697
+ | \`css/style.css\` | Styles |
698
+ | \`js/dashboard.js\` | Widget logic |
699
+ | \`server.js\` | Server with OpenClaw API proxy |
700
+
701
+ ${apiKeys.length > 0 ? `## API Keys
702
+
703
+ Edit \`js/dashboard.js\` and replace these placeholders:
704
+ ${apiKeys.map(key => `- \`YOUR_${key}\``).join('\n')}
705
+ ` : ''}
706
+
707
+ ---
708
+
709
+ Generated with LobsterBoard - https://github.com/curbob/LobsterBoard
710
+ `;
711
+ }
712
+
713
+ export default {
714
+ escapeHtml,
715
+ processWidgetHtml,
716
+ generateDashboardCss,
717
+ generateEditJs,
718
+ generateWidgetHtml,
719
+ generateWidgetJs,
720
+ generateDashboardHtml,
721
+ generateDashboardJs,
722
+ generateReadme
723
+ };