agentgui 1.0.274 → 1.0.275
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/CLAUDE.md +280 -280
- package/IPFS_DOWNLOADER.md +277 -277
- package/TASK_2C_COMPLETION.md +334 -334
- package/bin/gmgui.cjs +54 -54
- package/build-portable.js +3 -42
- package/database.js +1422 -1406
- package/lib/claude-runner.js +1130 -1130
- package/lib/ipfs-downloader.js +459 -459
- package/lib/speech.js +152 -152
- package/package.json +1 -1
- package/readme.md +76 -76
- package/server.js +3787 -3794
- package/setup-npm-token.sh +68 -68
- package/static/app.js +773 -773
- package/static/event-rendering-showcase.html +708 -708
- package/static/index.html +3178 -3180
- package/static/js/agent-auth.js +298 -298
- package/static/js/audio-recorder-processor.js +18 -18
- package/static/js/client.js +2656 -2656
- package/static/js/conversations.js +583 -583
- package/static/js/dialogs.js +267 -267
- package/static/js/event-consolidator.js +101 -101
- package/static/js/event-filter.js +311 -311
- package/static/js/event-processor.js +452 -452
- package/static/js/features.js +413 -413
- package/static/js/kalman-filter.js +67 -67
- package/static/js/progress-dialog.js +130 -130
- package/static/js/script-runner.js +219 -219
- package/static/js/streaming-renderer.js +2123 -2120
- package/static/js/syntax-highlighter.js +269 -269
- package/static/js/tts-websocket-handler.js +152 -152
- package/static/js/ui-components.js +431 -431
- package/static/js/voice.js +849 -849
- package/static/js/websocket-manager.js +596 -596
- package/static/templates/INDEX.html +465 -465
- package/static/templates/README.md +190 -190
- package/static/templates/agent-capabilities.html +56 -56
- package/static/templates/agent-metadata-panel.html +44 -44
- package/static/templates/agent-status-badge.html +30 -30
- package/static/templates/code-annotation-panel.html +155 -155
- package/static/templates/code-suggestion-panel.html +184 -184
- package/static/templates/command-header.html +77 -77
- package/static/templates/command-output-scrollable.html +118 -118
- package/static/templates/elapsed-time.html +54 -54
- package/static/templates/error-alert.html +106 -106
- package/static/templates/error-history-timeline.html +160 -160
- package/static/templates/error-recovery-options.html +109 -109
- package/static/templates/error-stack-trace.html +95 -95
- package/static/templates/error-summary.html +80 -80
- package/static/templates/event-counter.html +48 -48
- package/static/templates/execution-actions.html +97 -97
- package/static/templates/execution-progress-bar.html +80 -80
- package/static/templates/execution-stepper.html +120 -120
- package/static/templates/file-breadcrumb.html +118 -118
- package/static/templates/file-diff-viewer.html +121 -121
- package/static/templates/file-metadata.html +133 -133
- package/static/templates/file-read-panel.html +66 -66
- package/static/templates/file-write-panel.html +120 -120
- package/static/templates/git-branch-remote.html +107 -107
- package/static/templates/git-diff-list.html +101 -101
- package/static/templates/git-log-visualization.html +153 -153
- package/static/templates/git-status-panel.html +115 -115
- package/static/templates/quality-metrics-display.html +170 -170
- package/static/templates/terminal-output-panel.html +87 -87
- package/static/templates/test-results-display.html +144 -144
- package/static/theme.js +72 -72
- package/test-download-progress.js +223 -223
- package/test-websocket-broadcast.js +147 -147
- package/tests/ipfs-downloader.test.js +370 -370
|
@@ -1,431 +1,431 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* UI Components
|
|
3
|
-
* Reusable UI building blocks for modals, tabs, buttons, and more
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
class UIComponents {
|
|
7
|
-
/**
|
|
8
|
-
* Create a modal dialog
|
|
9
|
-
*/
|
|
10
|
-
static createModal(config = {}) {
|
|
11
|
-
const {
|
|
12
|
-
title = 'Dialog',
|
|
13
|
-
content = '',
|
|
14
|
-
buttons = [],
|
|
15
|
-
onClose = null,
|
|
16
|
-
size = 'medium' // small, medium, large
|
|
17
|
-
} = config;
|
|
18
|
-
|
|
19
|
-
const modal = document.createElement('div');
|
|
20
|
-
modal.className = 'modal-overlay';
|
|
21
|
-
modal.dataset.modal = 'true';
|
|
22
|
-
|
|
23
|
-
const sizeClasses = {
|
|
24
|
-
'small': 'max-w-sm',
|
|
25
|
-
'medium': 'max-w-md',
|
|
26
|
-
'large': 'max-w-2xl'
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
modal.innerHTML = `
|
|
30
|
-
<div class="modal-content ${sizeClasses[size] || sizeClasses['medium']} bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6">
|
|
31
|
-
<div class="modal-header flex justify-between items-center mb-4 pb-4 border-b">
|
|
32
|
-
<h2 class="text-xl font-bold">${UIComponents.escapeHtml(title)}</h2>
|
|
33
|
-
<button class="modal-close text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 text-2xl leading-none">×</button>
|
|
34
|
-
</div>
|
|
35
|
-
<div class="modal-body mb-4">
|
|
36
|
-
${typeof content === 'string' ? UIComponents.escapeHtml(content) : ''}
|
|
37
|
-
</div>
|
|
38
|
-
<div class="modal-footer flex gap-2 justify-end">
|
|
39
|
-
${buttons.map(btn => `
|
|
40
|
-
<button class="btn btn-${btn.variant || 'secondary'}" data-action="${btn.action || 'close'}">
|
|
41
|
-
${UIComponents.escapeHtml(btn.label)}
|
|
42
|
-
</button>
|
|
43
|
-
`).join('')}
|
|
44
|
-
</div>
|
|
45
|
-
</div>
|
|
46
|
-
`;
|
|
47
|
-
|
|
48
|
-
// Add close handler
|
|
49
|
-
const closeBtn = modal.querySelector('.modal-close');
|
|
50
|
-
closeBtn.addEventListener('click', () => {
|
|
51
|
-
modal.remove();
|
|
52
|
-
if (onClose) onClose();
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
// Add button handlers
|
|
56
|
-
modal.querySelectorAll('[data-action]').forEach(btn => {
|
|
57
|
-
btn.addEventListener('click', (e) => {
|
|
58
|
-
const action = e.target.dataset.action;
|
|
59
|
-
if (action === 'close') {
|
|
60
|
-
modal.remove();
|
|
61
|
-
if (onClose) onClose();
|
|
62
|
-
}
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
// Close on background click
|
|
67
|
-
modal.addEventListener('click', (e) => {
|
|
68
|
-
if (e.target === modal) {
|
|
69
|
-
modal.remove();
|
|
70
|
-
if (onClose) onClose();
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
return modal;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Create a tabbed interface
|
|
79
|
-
*/
|
|
80
|
-
static createTabs(config = {}) {
|
|
81
|
-
const {
|
|
82
|
-
tabs = [],
|
|
83
|
-
activeTab = 0,
|
|
84
|
-
onChange = null
|
|
85
|
-
} = config;
|
|
86
|
-
|
|
87
|
-
const container = document.createElement('div');
|
|
88
|
-
container.className = 'tabs';
|
|
89
|
-
|
|
90
|
-
// Tab buttons
|
|
91
|
-
const tabButtons = document.createElement('div');
|
|
92
|
-
tabButtons.className = 'tab-buttons flex border-b';
|
|
93
|
-
|
|
94
|
-
tabs.forEach((tab, index) => {
|
|
95
|
-
const btn = document.createElement('button');
|
|
96
|
-
btn.className = `tab-button px-4 py-2 font-medium transition-colors ${
|
|
97
|
-
index === activeTab
|
|
98
|
-
? 'border-b-2 border-blue-500 text-blue-600 dark:text-blue-400'
|
|
99
|
-
: 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
|
|
100
|
-
}`;
|
|
101
|
-
btn.textContent = tab.label;
|
|
102
|
-
btn.dataset.tabIndex = index;
|
|
103
|
-
|
|
104
|
-
btn.addEventListener('click', () => {
|
|
105
|
-
// Update active button
|
|
106
|
-
tabButtons.querySelectorAll('.tab-button').forEach((b, i) => {
|
|
107
|
-
if (i === index) {
|
|
108
|
-
b.classList.add('border-b-2', 'border-blue-500', 'text-blue-600', 'dark:text-blue-400');
|
|
109
|
-
b.classList.remove('text-gray-600', 'dark:text-gray-400');
|
|
110
|
-
} else {
|
|
111
|
-
b.classList.remove('border-b-2', 'border-blue-500', 'text-blue-600', 'dark:text-blue-400');
|
|
112
|
-
b.classList.add('text-gray-600', 'dark:text-gray-400');
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
// Update tab content
|
|
117
|
-
tabContent.querySelectorAll('.tab-pane').forEach((pane, i) => {
|
|
118
|
-
pane.style.display = i === index ? 'block' : 'none';
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
if (onChange) onChange(index);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
tabButtons.appendChild(btn);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
container.appendChild(tabButtons);
|
|
128
|
-
|
|
129
|
-
// Tab content
|
|
130
|
-
const tabContent = document.createElement('div');
|
|
131
|
-
tabContent.className = 'tab-content mt-4';
|
|
132
|
-
|
|
133
|
-
tabs.forEach((tab, index) => {
|
|
134
|
-
const pane = document.createElement('div');
|
|
135
|
-
pane.className = 'tab-pane';
|
|
136
|
-
pane.style.display = index === activeTab ? 'block' : 'none';
|
|
137
|
-
pane.innerHTML = typeof tab.content === 'string' ? tab.content : '';
|
|
138
|
-
tabContent.appendChild(pane);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
container.appendChild(tabContent);
|
|
142
|
-
return container;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Create an alert/notification
|
|
147
|
-
*/
|
|
148
|
-
static createAlert(config = {}) {
|
|
149
|
-
const {
|
|
150
|
-
message = '',
|
|
151
|
-
type = 'info', // info, success, warning, error
|
|
152
|
-
duration = 5000,
|
|
153
|
-
dismissible = true
|
|
154
|
-
} = config;
|
|
155
|
-
|
|
156
|
-
const alert = document.createElement('div');
|
|
157
|
-
const typeClasses = {
|
|
158
|
-
'info': 'bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-900 dark:border-blue-700 dark:text-blue-200',
|
|
159
|
-
'success': 'bg-green-50 border-green-200 text-green-800 dark:bg-green-900 dark:border-green-700 dark:text-green-200',
|
|
160
|
-
'warning': 'bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900 dark:border-yellow-700 dark:text-yellow-200',
|
|
161
|
-
'error': 'bg-red-50 border-red-200 text-red-800 dark:bg-red-900 dark:border-red-700 dark:text-red-200'
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
alert.className = `alert border-l-4 p-4 mb-4 rounded ${typeClasses[type] || typeClasses['info']}`;
|
|
165
|
-
alert.innerHTML = `
|
|
166
|
-
<div class="flex justify-between items-center">
|
|
167
|
-
<span>${UIComponents.escapeHtml(message)}</span>
|
|
168
|
-
${dismissible ? '<button class="text-current hover:opacity-75">×</button>' : ''}
|
|
169
|
-
</div>
|
|
170
|
-
`;
|
|
171
|
-
|
|
172
|
-
if (dismissible) {
|
|
173
|
-
const closeBtn = alert.querySelector('button');
|
|
174
|
-
closeBtn.addEventListener('click', () => alert.remove());
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
if (duration > 0) {
|
|
178
|
-
setTimeout(() => alert.remove(), duration);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return alert;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Create a loading spinner
|
|
186
|
-
*/
|
|
187
|
-
static createSpinner(config = {}) {
|
|
188
|
-
const {
|
|
189
|
-
size = 'medium', // small, medium, large
|
|
190
|
-
text = 'Loading...'
|
|
191
|
-
} = config;
|
|
192
|
-
|
|
193
|
-
const sizeClasses = {
|
|
194
|
-
'small': 'w-4 h-4',
|
|
195
|
-
'medium': 'w-8 h-8',
|
|
196
|
-
'large': 'w-12 h-12'
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
const container = document.createElement('div');
|
|
200
|
-
container.className = 'flex items-center gap-3 justify-center p-4';
|
|
201
|
-
container.innerHTML = `
|
|
202
|
-
<svg class="animate-spin ${sizeClasses[size] || sizeClasses['medium']} text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24">
|
|
203
|
-
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" opacity="0.25"></circle>
|
|
204
|
-
<path d="M4 12a8 8 0 018-8" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
|
|
205
|
-
</svg>
|
|
206
|
-
<span class="text-gray-700 dark:text-gray-300">${UIComponents.escapeHtml(text)}</span>
|
|
207
|
-
`;
|
|
208
|
-
return container;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Create a progress bar
|
|
213
|
-
*/
|
|
214
|
-
static createProgressBar(config = {}) {
|
|
215
|
-
const {
|
|
216
|
-
percentage = 0,
|
|
217
|
-
label = '',
|
|
218
|
-
showLabel = true
|
|
219
|
-
} = config;
|
|
220
|
-
|
|
221
|
-
const container = document.createElement('div');
|
|
222
|
-
container.className = 'progress-container';
|
|
223
|
-
|
|
224
|
-
let html = '';
|
|
225
|
-
if (label && showLabel) {
|
|
226
|
-
html += `<div class="flex justify-between mb-2 text-sm"><span>${UIComponents.escapeHtml(label)}</span><span>${Math.round(percentage)}%</span></div>`;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
html += `
|
|
230
|
-
<div class="progress-bar bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
|
|
231
|
-
<div class="progress-fill bg-blue-500 h-full transition-all" style="width: ${Math.min(100, Math.max(0, percentage))}%"></div>
|
|
232
|
-
</div>
|
|
233
|
-
`;
|
|
234
|
-
|
|
235
|
-
container.innerHTML = html;
|
|
236
|
-
return container;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Create a collapsible section
|
|
241
|
-
*/
|
|
242
|
-
static createCollapsible(config = {}) {
|
|
243
|
-
const {
|
|
244
|
-
title = 'Details',
|
|
245
|
-
content = '',
|
|
246
|
-
isOpen = false
|
|
247
|
-
} = config;
|
|
248
|
-
|
|
249
|
-
const container = document.createElement('div');
|
|
250
|
-
container.className = 'collapsible';
|
|
251
|
-
|
|
252
|
-
container.innerHTML = `
|
|
253
|
-
<details ${isOpen ? 'open' : ''}>
|
|
254
|
-
<summary class="cursor-pointer font-semibold hover:bg-gray-100 dark:hover:bg-gray-800 px-2 py-1 rounded transition-colors">
|
|
255
|
-
${UIComponents.escapeHtml(title)}
|
|
256
|
-
</summary>
|
|
257
|
-
<div class="content mt-2 ml-4">
|
|
258
|
-
${typeof content === 'string' ? content : ''}
|
|
259
|
-
</div>
|
|
260
|
-
</details>
|
|
261
|
-
`;
|
|
262
|
-
|
|
263
|
-
return container;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Create a form input
|
|
268
|
-
*/
|
|
269
|
-
static createInput(config = {}) {
|
|
270
|
-
const {
|
|
271
|
-
type = 'text',
|
|
272
|
-
name = '',
|
|
273
|
-
label = '',
|
|
274
|
-
placeholder = '',
|
|
275
|
-
value = '',
|
|
276
|
-
required = false
|
|
277
|
-
} = config;
|
|
278
|
-
|
|
279
|
-
const container = document.createElement('div');
|
|
280
|
-
container.className = 'form-group mb-4';
|
|
281
|
-
|
|
282
|
-
let html = '';
|
|
283
|
-
if (label) {
|
|
284
|
-
html += `<label class="block text-sm font-medium mb-2">${UIComponents.escapeHtml(label)}</label>`;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
html += `
|
|
288
|
-
<input
|
|
289
|
-
type="${type}"
|
|
290
|
-
name="${name}"
|
|
291
|
-
placeholder="${UIComponents.escapeHtml(placeholder)}"
|
|
292
|
-
value="${UIComponents.escapeHtml(value)}"
|
|
293
|
-
${required ? 'required' : ''}
|
|
294
|
-
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
295
|
-
/>
|
|
296
|
-
`;
|
|
297
|
-
|
|
298
|
-
container.innerHTML = html;
|
|
299
|
-
return container;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* Create a select dropdown
|
|
304
|
-
*/
|
|
305
|
-
static createSelect(config = {}) {
|
|
306
|
-
const {
|
|
307
|
-
name = '',
|
|
308
|
-
label = '',
|
|
309
|
-
options = [],
|
|
310
|
-
value = '',
|
|
311
|
-
required = false
|
|
312
|
-
} = config;
|
|
313
|
-
|
|
314
|
-
const container = document.createElement('div');
|
|
315
|
-
container.className = 'form-group mb-4';
|
|
316
|
-
|
|
317
|
-
let html = '';
|
|
318
|
-
if (label) {
|
|
319
|
-
html += `<label class="block text-sm font-medium mb-2">${UIComponents.escapeHtml(label)}</label>`;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
html += `
|
|
323
|
-
<select
|
|
324
|
-
name="${name}"
|
|
325
|
-
${required ? 'required' : ''}
|
|
326
|
-
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
327
|
-
>
|
|
328
|
-
${options.map(opt => `
|
|
329
|
-
<option value="${opt.value}" ${opt.value === value ? 'selected' : ''}>
|
|
330
|
-
${UIComponents.escapeHtml(opt.label)}
|
|
331
|
-
</option>
|
|
332
|
-
`).join('')}
|
|
333
|
-
</select>
|
|
334
|
-
`;
|
|
335
|
-
|
|
336
|
-
container.innerHTML = html;
|
|
337
|
-
return container;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* Create a button group
|
|
342
|
-
*/
|
|
343
|
-
static createButtonGroup(config = {}) {
|
|
344
|
-
const {
|
|
345
|
-
buttons = [],
|
|
346
|
-
vertical = false
|
|
347
|
-
} = config;
|
|
348
|
-
|
|
349
|
-
const container = document.createElement('div');
|
|
350
|
-
container.className = `button-group flex gap-2 ${vertical ? 'flex-col' : 'flex-row'}`;
|
|
351
|
-
|
|
352
|
-
buttons.forEach(btn => {
|
|
353
|
-
const button = document.createElement('button');
|
|
354
|
-
button.className = `btn btn-${btn.variant || 'secondary'} flex-${vertical ? '1' : 'none'}`;
|
|
355
|
-
button.textContent = btn.label;
|
|
356
|
-
if (btn.onClick) {
|
|
357
|
-
button.addEventListener('click', btn.onClick);
|
|
358
|
-
}
|
|
359
|
-
container.appendChild(button);
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
return container;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* Create a badge/tag
|
|
367
|
-
*/
|
|
368
|
-
static createBadge(config = {}) {
|
|
369
|
-
const {
|
|
370
|
-
label = '',
|
|
371
|
-
variant = 'default', // default, primary, success, warning, error
|
|
372
|
-
size = 'medium' // small, medium, large
|
|
373
|
-
} = config;
|
|
374
|
-
|
|
375
|
-
const sizeClasses = {
|
|
376
|
-
'small': 'text-xs px-2 py-1',
|
|
377
|
-
'medium': 'text-sm px-3 py-1',
|
|
378
|
-
'large': 'text-base px-4 py-2'
|
|
379
|
-
};
|
|
380
|
-
|
|
381
|
-
const variantClasses = {
|
|
382
|
-
'default': 'bg-gray-200 text-gray-800 dark:bg-gray-700 dark:text-gray-200',
|
|
383
|
-
'primary': 'bg-blue-200 text-blue-800 dark:bg-blue-700 dark:text-blue-200',
|
|
384
|
-
'success': 'bg-green-200 text-green-800 dark:bg-green-700 dark:text-green-200',
|
|
385
|
-
'warning': 'bg-yellow-200 text-yellow-800 dark:bg-yellow-700 dark:text-yellow-200',
|
|
386
|
-
'error': 'bg-red-200 text-red-800 dark:bg-red-700 dark:text-red-200'
|
|
387
|
-
};
|
|
388
|
-
|
|
389
|
-
const badge = document.createElement('span');
|
|
390
|
-
badge.className = `badge rounded-full font-medium ${sizeClasses[size] || sizeClasses['medium']} ${variantClasses[variant] || variantClasses['default']}`;
|
|
391
|
-
badge.textContent = label;
|
|
392
|
-
return badge;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/**
|
|
396
|
-
* HTML escape utility
|
|
397
|
-
*/
|
|
398
|
-
static escapeHtml(text) {
|
|
399
|
-
return window._escHtml(text);
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/**
|
|
403
|
-
* Copy text to clipboard
|
|
404
|
-
*/
|
|
405
|
-
static copyToClipboard(text) {
|
|
406
|
-
return navigator.clipboard.writeText(text).catch(err => {
|
|
407
|
-
console.error('Failed to copy:', err);
|
|
408
|
-
return false;
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* Download data as file
|
|
414
|
-
*/
|
|
415
|
-
static downloadFile(data, filename, mimeType = 'text/plain') {
|
|
416
|
-
const blob = new Blob([data], { type: mimeType });
|
|
417
|
-
const url = URL.createObjectURL(blob);
|
|
418
|
-
const link = document.createElement('a');
|
|
419
|
-
link.href = url;
|
|
420
|
-
link.download = filename;
|
|
421
|
-
document.body.appendChild(link);
|
|
422
|
-
link.click();
|
|
423
|
-
document.body.removeChild(link);
|
|
424
|
-
URL.revokeObjectURL(url);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
// Export for use in browser
|
|
429
|
-
if (typeof module !== 'undefined' && module.exports) {
|
|
430
|
-
module.exports = UIComponents;
|
|
431
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* UI Components
|
|
3
|
+
* Reusable UI building blocks for modals, tabs, buttons, and more
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
class UIComponents {
|
|
7
|
+
/**
|
|
8
|
+
* Create a modal dialog
|
|
9
|
+
*/
|
|
10
|
+
static createModal(config = {}) {
|
|
11
|
+
const {
|
|
12
|
+
title = 'Dialog',
|
|
13
|
+
content = '',
|
|
14
|
+
buttons = [],
|
|
15
|
+
onClose = null,
|
|
16
|
+
size = 'medium' // small, medium, large
|
|
17
|
+
} = config;
|
|
18
|
+
|
|
19
|
+
const modal = document.createElement('div');
|
|
20
|
+
modal.className = 'modal-overlay';
|
|
21
|
+
modal.dataset.modal = 'true';
|
|
22
|
+
|
|
23
|
+
const sizeClasses = {
|
|
24
|
+
'small': 'max-w-sm',
|
|
25
|
+
'medium': 'max-w-md',
|
|
26
|
+
'large': 'max-w-2xl'
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
modal.innerHTML = `
|
|
30
|
+
<div class="modal-content ${sizeClasses[size] || sizeClasses['medium']} bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6">
|
|
31
|
+
<div class="modal-header flex justify-between items-center mb-4 pb-4 border-b">
|
|
32
|
+
<h2 class="text-xl font-bold">${UIComponents.escapeHtml(title)}</h2>
|
|
33
|
+
<button class="modal-close text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 text-2xl leading-none">×</button>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="modal-body mb-4">
|
|
36
|
+
${typeof content === 'string' ? UIComponents.escapeHtml(content) : ''}
|
|
37
|
+
</div>
|
|
38
|
+
<div class="modal-footer flex gap-2 justify-end">
|
|
39
|
+
${buttons.map(btn => `
|
|
40
|
+
<button class="btn btn-${btn.variant || 'secondary'}" data-action="${btn.action || 'close'}">
|
|
41
|
+
${UIComponents.escapeHtml(btn.label)}
|
|
42
|
+
</button>
|
|
43
|
+
`).join('')}
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
// Add close handler
|
|
49
|
+
const closeBtn = modal.querySelector('.modal-close');
|
|
50
|
+
closeBtn.addEventListener('click', () => {
|
|
51
|
+
modal.remove();
|
|
52
|
+
if (onClose) onClose();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Add button handlers
|
|
56
|
+
modal.querySelectorAll('[data-action]').forEach(btn => {
|
|
57
|
+
btn.addEventListener('click', (e) => {
|
|
58
|
+
const action = e.target.dataset.action;
|
|
59
|
+
if (action === 'close') {
|
|
60
|
+
modal.remove();
|
|
61
|
+
if (onClose) onClose();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Close on background click
|
|
67
|
+
modal.addEventListener('click', (e) => {
|
|
68
|
+
if (e.target === modal) {
|
|
69
|
+
modal.remove();
|
|
70
|
+
if (onClose) onClose();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return modal;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Create a tabbed interface
|
|
79
|
+
*/
|
|
80
|
+
static createTabs(config = {}) {
|
|
81
|
+
const {
|
|
82
|
+
tabs = [],
|
|
83
|
+
activeTab = 0,
|
|
84
|
+
onChange = null
|
|
85
|
+
} = config;
|
|
86
|
+
|
|
87
|
+
const container = document.createElement('div');
|
|
88
|
+
container.className = 'tabs';
|
|
89
|
+
|
|
90
|
+
// Tab buttons
|
|
91
|
+
const tabButtons = document.createElement('div');
|
|
92
|
+
tabButtons.className = 'tab-buttons flex border-b';
|
|
93
|
+
|
|
94
|
+
tabs.forEach((tab, index) => {
|
|
95
|
+
const btn = document.createElement('button');
|
|
96
|
+
btn.className = `tab-button px-4 py-2 font-medium transition-colors ${
|
|
97
|
+
index === activeTab
|
|
98
|
+
? 'border-b-2 border-blue-500 text-blue-600 dark:text-blue-400'
|
|
99
|
+
: 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
|
|
100
|
+
}`;
|
|
101
|
+
btn.textContent = tab.label;
|
|
102
|
+
btn.dataset.tabIndex = index;
|
|
103
|
+
|
|
104
|
+
btn.addEventListener('click', () => {
|
|
105
|
+
// Update active button
|
|
106
|
+
tabButtons.querySelectorAll('.tab-button').forEach((b, i) => {
|
|
107
|
+
if (i === index) {
|
|
108
|
+
b.classList.add('border-b-2', 'border-blue-500', 'text-blue-600', 'dark:text-blue-400');
|
|
109
|
+
b.classList.remove('text-gray-600', 'dark:text-gray-400');
|
|
110
|
+
} else {
|
|
111
|
+
b.classList.remove('border-b-2', 'border-blue-500', 'text-blue-600', 'dark:text-blue-400');
|
|
112
|
+
b.classList.add('text-gray-600', 'dark:text-gray-400');
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Update tab content
|
|
117
|
+
tabContent.querySelectorAll('.tab-pane').forEach((pane, i) => {
|
|
118
|
+
pane.style.display = i === index ? 'block' : 'none';
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (onChange) onChange(index);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
tabButtons.appendChild(btn);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
container.appendChild(tabButtons);
|
|
128
|
+
|
|
129
|
+
// Tab content
|
|
130
|
+
const tabContent = document.createElement('div');
|
|
131
|
+
tabContent.className = 'tab-content mt-4';
|
|
132
|
+
|
|
133
|
+
tabs.forEach((tab, index) => {
|
|
134
|
+
const pane = document.createElement('div');
|
|
135
|
+
pane.className = 'tab-pane';
|
|
136
|
+
pane.style.display = index === activeTab ? 'block' : 'none';
|
|
137
|
+
pane.innerHTML = typeof tab.content === 'string' ? tab.content : '';
|
|
138
|
+
tabContent.appendChild(pane);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
container.appendChild(tabContent);
|
|
142
|
+
return container;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Create an alert/notification
|
|
147
|
+
*/
|
|
148
|
+
static createAlert(config = {}) {
|
|
149
|
+
const {
|
|
150
|
+
message = '',
|
|
151
|
+
type = 'info', // info, success, warning, error
|
|
152
|
+
duration = 5000,
|
|
153
|
+
dismissible = true
|
|
154
|
+
} = config;
|
|
155
|
+
|
|
156
|
+
const alert = document.createElement('div');
|
|
157
|
+
const typeClasses = {
|
|
158
|
+
'info': 'bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-900 dark:border-blue-700 dark:text-blue-200',
|
|
159
|
+
'success': 'bg-green-50 border-green-200 text-green-800 dark:bg-green-900 dark:border-green-700 dark:text-green-200',
|
|
160
|
+
'warning': 'bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900 dark:border-yellow-700 dark:text-yellow-200',
|
|
161
|
+
'error': 'bg-red-50 border-red-200 text-red-800 dark:bg-red-900 dark:border-red-700 dark:text-red-200'
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
alert.className = `alert border-l-4 p-4 mb-4 rounded ${typeClasses[type] || typeClasses['info']}`;
|
|
165
|
+
alert.innerHTML = `
|
|
166
|
+
<div class="flex justify-between items-center">
|
|
167
|
+
<span>${UIComponents.escapeHtml(message)}</span>
|
|
168
|
+
${dismissible ? '<button class="text-current hover:opacity-75">×</button>' : ''}
|
|
169
|
+
</div>
|
|
170
|
+
`;
|
|
171
|
+
|
|
172
|
+
if (dismissible) {
|
|
173
|
+
const closeBtn = alert.querySelector('button');
|
|
174
|
+
closeBtn.addEventListener('click', () => alert.remove());
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (duration > 0) {
|
|
178
|
+
setTimeout(() => alert.remove(), duration);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return alert;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Create a loading spinner
|
|
186
|
+
*/
|
|
187
|
+
static createSpinner(config = {}) {
|
|
188
|
+
const {
|
|
189
|
+
size = 'medium', // small, medium, large
|
|
190
|
+
text = 'Loading...'
|
|
191
|
+
} = config;
|
|
192
|
+
|
|
193
|
+
const sizeClasses = {
|
|
194
|
+
'small': 'w-4 h-4',
|
|
195
|
+
'medium': 'w-8 h-8',
|
|
196
|
+
'large': 'w-12 h-12'
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const container = document.createElement('div');
|
|
200
|
+
container.className = 'flex items-center gap-3 justify-center p-4';
|
|
201
|
+
container.innerHTML = `
|
|
202
|
+
<svg class="animate-spin ${sizeClasses[size] || sizeClasses['medium']} text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24">
|
|
203
|
+
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" opacity="0.25"></circle>
|
|
204
|
+
<path d="M4 12a8 8 0 018-8" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
|
|
205
|
+
</svg>
|
|
206
|
+
<span class="text-gray-700 dark:text-gray-300">${UIComponents.escapeHtml(text)}</span>
|
|
207
|
+
`;
|
|
208
|
+
return container;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Create a progress bar
|
|
213
|
+
*/
|
|
214
|
+
static createProgressBar(config = {}) {
|
|
215
|
+
const {
|
|
216
|
+
percentage = 0,
|
|
217
|
+
label = '',
|
|
218
|
+
showLabel = true
|
|
219
|
+
} = config;
|
|
220
|
+
|
|
221
|
+
const container = document.createElement('div');
|
|
222
|
+
container.className = 'progress-container';
|
|
223
|
+
|
|
224
|
+
let html = '';
|
|
225
|
+
if (label && showLabel) {
|
|
226
|
+
html += `<div class="flex justify-between mb-2 text-sm"><span>${UIComponents.escapeHtml(label)}</span><span>${Math.round(percentage)}%</span></div>`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
html += `
|
|
230
|
+
<div class="progress-bar bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
|
|
231
|
+
<div class="progress-fill bg-blue-500 h-full transition-all" style="width: ${Math.min(100, Math.max(0, percentage))}%"></div>
|
|
232
|
+
</div>
|
|
233
|
+
`;
|
|
234
|
+
|
|
235
|
+
container.innerHTML = html;
|
|
236
|
+
return container;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Create a collapsible section
|
|
241
|
+
*/
|
|
242
|
+
static createCollapsible(config = {}) {
|
|
243
|
+
const {
|
|
244
|
+
title = 'Details',
|
|
245
|
+
content = '',
|
|
246
|
+
isOpen = false
|
|
247
|
+
} = config;
|
|
248
|
+
|
|
249
|
+
const container = document.createElement('div');
|
|
250
|
+
container.className = 'collapsible';
|
|
251
|
+
|
|
252
|
+
container.innerHTML = `
|
|
253
|
+
<details ${isOpen ? 'open' : ''}>
|
|
254
|
+
<summary class="cursor-pointer font-semibold hover:bg-gray-100 dark:hover:bg-gray-800 px-2 py-1 rounded transition-colors">
|
|
255
|
+
${UIComponents.escapeHtml(title)}
|
|
256
|
+
</summary>
|
|
257
|
+
<div class="content mt-2 ml-4">
|
|
258
|
+
${typeof content === 'string' ? content : ''}
|
|
259
|
+
</div>
|
|
260
|
+
</details>
|
|
261
|
+
`;
|
|
262
|
+
|
|
263
|
+
return container;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Create a form input
|
|
268
|
+
*/
|
|
269
|
+
static createInput(config = {}) {
|
|
270
|
+
const {
|
|
271
|
+
type = 'text',
|
|
272
|
+
name = '',
|
|
273
|
+
label = '',
|
|
274
|
+
placeholder = '',
|
|
275
|
+
value = '',
|
|
276
|
+
required = false
|
|
277
|
+
} = config;
|
|
278
|
+
|
|
279
|
+
const container = document.createElement('div');
|
|
280
|
+
container.className = 'form-group mb-4';
|
|
281
|
+
|
|
282
|
+
let html = '';
|
|
283
|
+
if (label) {
|
|
284
|
+
html += `<label class="block text-sm font-medium mb-2">${UIComponents.escapeHtml(label)}</label>`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
html += `
|
|
288
|
+
<input
|
|
289
|
+
type="${type}"
|
|
290
|
+
name="${name}"
|
|
291
|
+
placeholder="${UIComponents.escapeHtml(placeholder)}"
|
|
292
|
+
value="${UIComponents.escapeHtml(value)}"
|
|
293
|
+
${required ? 'required' : ''}
|
|
294
|
+
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
295
|
+
/>
|
|
296
|
+
`;
|
|
297
|
+
|
|
298
|
+
container.innerHTML = html;
|
|
299
|
+
return container;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Create a select dropdown
|
|
304
|
+
*/
|
|
305
|
+
static createSelect(config = {}) {
|
|
306
|
+
const {
|
|
307
|
+
name = '',
|
|
308
|
+
label = '',
|
|
309
|
+
options = [],
|
|
310
|
+
value = '',
|
|
311
|
+
required = false
|
|
312
|
+
} = config;
|
|
313
|
+
|
|
314
|
+
const container = document.createElement('div');
|
|
315
|
+
container.className = 'form-group mb-4';
|
|
316
|
+
|
|
317
|
+
let html = '';
|
|
318
|
+
if (label) {
|
|
319
|
+
html += `<label class="block text-sm font-medium mb-2">${UIComponents.escapeHtml(label)}</label>`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
html += `
|
|
323
|
+
<select
|
|
324
|
+
name="${name}"
|
|
325
|
+
${required ? 'required' : ''}
|
|
326
|
+
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
327
|
+
>
|
|
328
|
+
${options.map(opt => `
|
|
329
|
+
<option value="${opt.value}" ${opt.value === value ? 'selected' : ''}>
|
|
330
|
+
${UIComponents.escapeHtml(opt.label)}
|
|
331
|
+
</option>
|
|
332
|
+
`).join('')}
|
|
333
|
+
</select>
|
|
334
|
+
`;
|
|
335
|
+
|
|
336
|
+
container.innerHTML = html;
|
|
337
|
+
return container;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Create a button group
|
|
342
|
+
*/
|
|
343
|
+
static createButtonGroup(config = {}) {
|
|
344
|
+
const {
|
|
345
|
+
buttons = [],
|
|
346
|
+
vertical = false
|
|
347
|
+
} = config;
|
|
348
|
+
|
|
349
|
+
const container = document.createElement('div');
|
|
350
|
+
container.className = `button-group flex gap-2 ${vertical ? 'flex-col' : 'flex-row'}`;
|
|
351
|
+
|
|
352
|
+
buttons.forEach(btn => {
|
|
353
|
+
const button = document.createElement('button');
|
|
354
|
+
button.className = `btn btn-${btn.variant || 'secondary'} flex-${vertical ? '1' : 'none'}`;
|
|
355
|
+
button.textContent = btn.label;
|
|
356
|
+
if (btn.onClick) {
|
|
357
|
+
button.addEventListener('click', btn.onClick);
|
|
358
|
+
}
|
|
359
|
+
container.appendChild(button);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
return container;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Create a badge/tag
|
|
367
|
+
*/
|
|
368
|
+
static createBadge(config = {}) {
|
|
369
|
+
const {
|
|
370
|
+
label = '',
|
|
371
|
+
variant = 'default', // default, primary, success, warning, error
|
|
372
|
+
size = 'medium' // small, medium, large
|
|
373
|
+
} = config;
|
|
374
|
+
|
|
375
|
+
const sizeClasses = {
|
|
376
|
+
'small': 'text-xs px-2 py-1',
|
|
377
|
+
'medium': 'text-sm px-3 py-1',
|
|
378
|
+
'large': 'text-base px-4 py-2'
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const variantClasses = {
|
|
382
|
+
'default': 'bg-gray-200 text-gray-800 dark:bg-gray-700 dark:text-gray-200',
|
|
383
|
+
'primary': 'bg-blue-200 text-blue-800 dark:bg-blue-700 dark:text-blue-200',
|
|
384
|
+
'success': 'bg-green-200 text-green-800 dark:bg-green-700 dark:text-green-200',
|
|
385
|
+
'warning': 'bg-yellow-200 text-yellow-800 dark:bg-yellow-700 dark:text-yellow-200',
|
|
386
|
+
'error': 'bg-red-200 text-red-800 dark:bg-red-700 dark:text-red-200'
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const badge = document.createElement('span');
|
|
390
|
+
badge.className = `badge rounded-full font-medium ${sizeClasses[size] || sizeClasses['medium']} ${variantClasses[variant] || variantClasses['default']}`;
|
|
391
|
+
badge.textContent = label;
|
|
392
|
+
return badge;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* HTML escape utility
|
|
397
|
+
*/
|
|
398
|
+
static escapeHtml(text) {
|
|
399
|
+
return window._escHtml(text);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Copy text to clipboard
|
|
404
|
+
*/
|
|
405
|
+
static copyToClipboard(text) {
|
|
406
|
+
return navigator.clipboard.writeText(text).catch(err => {
|
|
407
|
+
console.error('Failed to copy:', err);
|
|
408
|
+
return false;
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Download data as file
|
|
414
|
+
*/
|
|
415
|
+
static downloadFile(data, filename, mimeType = 'text/plain') {
|
|
416
|
+
const blob = new Blob([data], { type: mimeType });
|
|
417
|
+
const url = URL.createObjectURL(blob);
|
|
418
|
+
const link = document.createElement('a');
|
|
419
|
+
link.href = url;
|
|
420
|
+
link.download = filename;
|
|
421
|
+
document.body.appendChild(link);
|
|
422
|
+
link.click();
|
|
423
|
+
document.body.removeChild(link);
|
|
424
|
+
URL.revokeObjectURL(url);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Export for use in browser
|
|
429
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
430
|
+
module.exports = UIComponents;
|
|
431
|
+
}
|