master
js 434 lines 17.4 KB
Raw
1 /**
2 * Reusable UI Components
3 * This file contains reusable JavaScript components that can be used across the application
4 *
5 * default3.handlebars current state (as of 2026-03-02) contains:
6 * - 256 `setModalContent(...)` calls
7 * - 243 `showModal(...)` calls
8 * each modal set/show pair can be reduced to 1 `openModal(...)` after migration to these components, resulting in a potential reduction of ~200 lines of code in default3.handlebars.
9 *
10 * Biggest gain first:
11 * - Standardize modal invocation through reusable helpers in this file,
12 * then migrate repeated modal calls in default3.handlebars.
13 * - Expected code reduction in default3.handlebars, and ensures lower duplication risk.
14 *
15 * More UI components can be added here, or moved to a dedicated components directory over time (one component per file) as needed.
16 */
17
18 // Modern Modal Component
19 class ModernModal {
20 constructor(modalId, options = {}) {
21 this.modalId = modalId;
22 this.options = {
23 size: 'medium',
24 showCloseButton: true,
25 backdrop: true,
26 keyboard: true,
27 ...options
28 };
29 }
30
31 show(title, content, okCallback = null, okButtonText = 'OK') {
32 const sizeClass = this.options.size === 'large' ? 'modal-lg' :
33 this.options.size === 'extra-large' ? 'modal-xl' : '';
34
35 let modalContent = `
36 <div class="modal-dialog modal-dialog-centered ${sizeClass}">
37 <div class="modal-content">
38 <div class="modal-header">
39 <h5 class="modal-title">${title}</h5>
40 ${this.options.showCloseButton ? '<button type="button" class="btn-close" data-bs-dismiss="modal"></button>' : ''}
41 </div>
42 <div class="modal-body">
43 ${content}
44 </div>
45 <div class="modal-footer">
46 <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
47 ${okCallback ? `<button type="button" class="btn btn-primary" id="${this.modalId}OkBtn">${okButtonText}</button>` : ''}
48 </div>
49 </div>
50 </div>
51 `;
52
53 setModalContent(this.modalId, title, content, this.options.size);
54
55 if (okCallback) {
56 showModal(this.modalId, `${this.modalId}OkBtn`, okCallback);
57 } else {
58 showModal(this.modalId);
59 }
60 }
61
62 hide() {
63 const modalElement = document.getElementById(this.modalId);
64 if (modalElement) {
65 const modal = bootstrap.Modal.getInstance(modalElement);
66 if (modal) {
67 modal.hide();
68 }
69 }
70 }
71 }
72
73 // Modern Card Component
74 class ModernCard {
75 constructor(container, options = {}) {
76 this.container = container;
77 this.options = {
78 title: '',
79 icon: '',
80 status: 'default', // default, success, warning, danger
81 actions: [],
82 ...options
83 };
84 }
85
86 render() {
87 const statusClasses = {
88 default: '',
89 success: 'border-success',
90 warning: 'border-warning',
91 danger: 'border-danger'
92 };
93
94 const statusIcons = {
95 default: 'fa-circle',
96 success: 'fa-check-circle',
97 warning: 'fa-exclamation-circle',
98 danger: 'fa-times-circle'
99 };
100
101 const statusColors = {
102 default: 'text-muted',
103 success: 'text-success',
104 warning: 'text-warning',
105 danger: 'text-danger'
106 };
107
108 let cardHTML = `
109 <div class="card modern-card ${statusClasses[this.options.status]} h-100">
110 <div class="card-header d-flex align-items-center">
111 <div class="bg-light rounded-circle p-2 me-3">
112 <i class="fas ${this.options.icon} fa-lg text-secondary"></i>
113 </div>
114 <div class="flex-grow-1">
115 <h6 class="card-title mb-1">${this.options.title}</h6>
116 <small class="status-badge ${statusColors[this.options.status]}">
117 <i class="fas ${statusIcons[this.options.status]} me-1"></i>
118 <span class="status-text">${this.options.status}</span>
119 </small>
120 </div>
121 </div>
122 <div class="card-body">
123 <div class="card-content">
124 ${this.options.content || ''}
125 </div>
126 </div>
127 `;
128
129 if (this.options.actions.length > 0) {
130 cardHTML += '<div class="card-footer">';
131 this.options.actions.forEach(action => {
132 cardHTML += `<button class="btn btn-sm ${action.class || 'btn-primary'}" onclick="${action.onclick}">${action.label}</button>`;
133 });
134 cardHTML += '</div>';
135 }
136
137 cardHTML += '</div>';
138
139 this.container.innerHTML = cardHTML;
140 }
141
142 updateStatus(status) {
143 this.options.status = status;
144 const card = this.container.querySelector('.modern-card');
145 const statusText = this.container.querySelector('.status-text');
146 const statusIcon = this.container.querySelector('.status-badge i');
147
148 // Remove all status classes
149 card.classList.remove('border-success', 'border-warning', 'border-danger');
150 statusText.classList.remove('text-muted', 'text-success', 'text-warning', 'text-danger');
151
152 // Add new status classes
153 const statusClasses = {
154 default: '',
155 success: 'border-success',
156 warning: 'border-warning',
157 danger: 'border-danger'
158 };
159
160 const statusIcons = {
161 default: 'fa-circle',
162 success: 'fa-check-circle',
163 warning: 'fa-exclamation-circle',
164 danger: 'fa-times-circle'
165 };
166
167 const statusColors = {
168 default: 'text-muted',
169 success: 'text-success',
170 warning: 'text-warning',
171 danger: 'text-danger'
172 };
173
174 card.classList.add(statusClasses[status]);
175 statusText.classList.add(statusColors[status]);
176 statusIcon.className = `fas ${statusIcons[status]} me-1`;
177 statusText.textContent = status;
178 }
179 }
180
181 // Icon Upload Component
182 // Reusable for any icon-upload card by passing callbacks/options:
183 // - `onUpload`, `onUrlInput`, `onRemove` for feature-specific behavior
184 // - `normalizePreviewUrl` for domain/path normalization
185 // - `iconKey`, `label`, `currentValue` for per-instance identity and content
186 // The component owns input/file/preview UI; persistence and status updates stay in page logic.
187 const CUSTOM_ICON_MAX_FILE_SIZE = 10485760;
188 const CUSTOM_ICON_MAX_DIMENSION = 64;
189
190 class IconUploadComponent {
191 constructor(iconKey, container, options = {}) {
192 this.iconKey = iconKey;
193 this.container = container;
194 this.options = {
195 label: iconKey,
196 currentValue: '',
197 onUpload: null,
198 onRemove: null,
199 onUrlInput: null,
200 normalizePreviewUrl: null,
201 ...options
202 };
203 }
204
205 getPreviewSrc(value) {
206 if ((typeof value !== 'string') || (value.length === 0)) { return ''; }
207 if (typeof this.options.normalizePreviewUrl !== 'function') { return value; }
208 try { return this.options.normalizePreviewUrl(value); } catch (ex) { return value; }
209 }
210
211 getImageDimensions(file) {
212 return new Promise((resolve, reject) => {
213 // Read image dimensions locally before upload so oversized icons fail fast.
214 const imageUrl = URL.createObjectURL(file);
215 const image = new Image();
216 image.onload = function () {
217 const dimensions = { width: image.naturalWidth, height: image.naturalHeight };
218 URL.revokeObjectURL(imageUrl);
219 resolve(dimensions);
220 };
221 image.onerror = function () {
222 URL.revokeObjectURL(imageUrl);
223 reject(new Error('Unable to read uploaded icon dimensions.'));
224 };
225 image.src = imageUrl;
226 });
227 }
228
229 render() {
230 const hasIcon = this.options.currentValue.length > 0;
231 const initialPreviewSrc = hasIcon ? this.getPreviewSrc(this.options.currentValue) : '';
232
233 const html = `
234 <div class="icon-upload-component" data-icon-key="${this.iconKey}">
235 <div class="input-group mb-3">
236 <input type="text" class="form-control" id="iconInput_${this.iconKey}"
237 value="${this.options.currentValue}"
238 placeholder="Enter URL or data URL for ${this.options.label} icon"
239 oninput="window.iconUploadComponents['${this.iconKey}'].handleUrlInput(this)" />
240 <button class="btn btn-outline-primary" type="button" onclick="window.iconUploadComponents['${this.iconKey}'].triggerFileUpload()">
241 <i class="fas fa-upload me-2"></i>Upload
242 </button>
243 </div>
244 <small class="text-muted d-block mb-3">Upload SVG, PNG or JPEG files up to ${CUSTOM_ICON_MAX_FILE_SIZE / 1048576} MB. PNG/JPEG files must be ${CUSTOM_ICON_MAX_DIMENSION} x ${CUSTOM_ICON_MAX_DIMENSION} pixels or smaller.</small>
245
246 <div class="icon-preview-container ${hasIcon ? '' : 'd-none'}" id="preview_container_${this.iconKey}">
247 <small class="text-muted me-2">Preview:</small>
248 <img class="icon-preview-item" id="preview_${this.iconKey}"
249 src="${initialPreviewSrc}" alt="Icon preview" />
250 <button class="btn btn-sm btn-outline-danger ms-auto" type="button"
251 onclick="window.iconUploadComponents['${this.iconKey}'].removeIcon()">
252 <i class="fas fa-times me-1"></i>Default icon
253 </button>
254 </div>
255
256 <input type="file" class="d-none" accept=".svg,.png,.jpg,.jpeg,image/svg+xml,image/png,image/jpeg"
257 id="iconFile_${this.iconKey}"
258 onchange="window.iconUploadComponents['${this.iconKey}'].handleFileUpload(this)" />
259 </div>
260 `;
261
262 this.container.innerHTML = html;
263
264 // Store reference for global access
265 if (!window.iconUploadComponents) {
266 window.iconUploadComponents = {};
267 }
268 window.iconUploadComponents[this.iconKey] = this;
269 }
270
271 triggerFileUpload() {
272 const fileInput = document.getElementById(`iconFile_${this.iconKey}`);
273 if (fileInput) {
274 fileInput.click();
275 }
276 }
277
278 handleUrlInput(input) {
279 const value = input.value.trim();
280 const previewContainer = document.getElementById(`preview_container_${this.iconKey}`);
281 const previewIcon = document.getElementById(`preview_${this.iconKey}`);
282
283 if (value.length > 0) {
284 previewContainer.classList.remove('d-none');
285 if (previewIcon.tagName.toLowerCase() === 'img') { previewIcon.src = this.getPreviewSrc(value); }
286 else { previewIcon.style.backgroundImage = `url('${value}')`; }
287 } else {
288 previewContainer.classList.add('d-none');
289 if (previewIcon.tagName.toLowerCase() === 'img') { previewIcon.removeAttribute('src'); }
290 else { previewIcon.style.backgroundImage = ''; }
291 }
292
293 if (this.options.onUrlInput) {
294 this.options.onUrlInput(this.iconKey, value);
295 }
296 }
297
298 async handleFileUpload(input) {
299 if (!input || !input.files || (input.files.length === 0)) {
300 return;
301 }
302
303 const button = this.container.querySelector('.btn-outline-primary');
304 const originalContent = button.innerHTML;
305 const file = input.files[0];
306
307 // Show loading state
308 button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Uploading...';
309 button.disabled = true;
310
311 try {
312 if (!(/^image\/(svg\+xml|png|jpeg)$/i.test(file.type)) && !(/\.(svg|png|jpg|jpeg)$/i.test(file.name || ''))) { throw new Error('Only SVG, PNG and JPEG icon files are supported.'); }
313 if ((file.size < 4) || (file.size > CUSTOM_ICON_MAX_FILE_SIZE)) { throw new Error('Icon files must be non-empty and ' + (CUSTOM_ICON_MAX_FILE_SIZE / 1048576) + ' MB or smaller.'); }
314 var uploadFile = file;
315 if (/\.(svg)$/i.test(file.name || '') || /^image\/svg\+xml$/i.test(file.type || '')) {
316 // Ensure DOMPurify is loaded
317 if ((typeof DOMPurify === 'undefined') || (typeof DOMPurify.sanitize !== 'function')) { throw new Error('Unable to clean SVG icon in this browser.'); }
318 // Clean SVG file
319 const cleanedSvg = DOMPurify.sanitize(await file.text(), { USE_PROFILES: { svg: true, svgFilters: true } });
320 if ((typeof cleanedSvg !== 'string') || (cleanedSvg.search(/<svg[\s>]/i) < 0)) { throw new Error('Invalid SVG icon file.'); }
321 uploadFile = new File([cleanedSvg], file.name, { type: 'image/svg+xml', lastModified: file.lastModified });
322 } else {
323 const dimensions = await this.getImageDimensions(file);
324 if ((dimensions.width < 1) || (dimensions.height < 1) || (dimensions.width > CUSTOM_ICON_MAX_DIMENSION) || (dimensions.height > CUSTOM_ICON_MAX_DIMENSION)) { throw new Error('PNG/JPEG icon images must be ' + CUSTOM_ICON_MAX_DIMENSION + ' x ' + CUSTOM_ICON_MAX_DIMENSION + ' pixels or smaller.'); }
325 }
326 if (this.options.onUpload) {
327 const result = await this.options.onUpload(this.iconKey, uploadFile);
328
329 // Show success state
330 button.innerHTML = '<i class="fas fa-check me-2"></i>Success!';
331 button.classList.remove('btn-outline-primary');
332 button.classList.add('btn-success');
333
334 // Update preview
335 const previewContainer = document.getElementById(`preview_container_${this.iconKey}`);
336 const previewIcon = document.getElementById(`preview_${this.iconKey}`);
337 const textInput = document.getElementById(`iconInput_${this.iconKey}`);
338
339 if (result && result.path) {
340 previewContainer.classList.remove('d-none');
341 if (previewIcon.tagName.toLowerCase() === 'img') { previewIcon.src = this.getPreviewSrc(result.path); }
342 else { previewIcon.style.backgroundImage = `url('${result.path}')`; }
343 textInput.value = result.path;
344 }
345
346 setTimeout(() => {
347 button.innerHTML = originalContent;
348 button.classList.remove('btn-success');
349 button.classList.add('btn-outline-primary');
350 button.disabled = false;
351 }, 2000);
352 }
353 } catch (error) {
354 // Show error state
355 button.innerHTML = '<i class="fas fa-exclamation-triangle me-2"></i>Failed';
356 button.title = (error && error.message) ? error.message : '';
357 button.classList.remove('btn-outline-primary');
358 button.classList.add('btn-danger');
359
360 setTimeout(() => {
361 button.innerHTML = originalContent;
362 button.title = '';
363 button.classList.remove('btn-danger');
364 button.classList.add('btn-outline-primary');
365 button.disabled = false;
366 }, 2000);
367 }
368
369 input.value = '';
370 }
371
372 removeIcon() {
373 const previewContainer = document.getElementById(`preview_container_${this.iconKey}`);
374 const previewIcon = document.getElementById(`preview_${this.iconKey}`);
375 const textInput = document.getElementById(`iconInput_${this.iconKey}`);
376
377 previewContainer.classList.add('d-none');
378 if (previewIcon.tagName.toLowerCase() === 'img') { previewIcon.removeAttribute('src'); }
379 else { previewIcon.style.backgroundImage = ''; }
380 textInput.value = '';
381 if (this.options.onUrlInput) {
382 this.options.onUrlInput(this.iconKey, '');
383 }
384
385 if (this.options.onRemove) {
386 this.options.onRemove(this.iconKey);
387 }
388 }
389 }
390
391 // Utility functions
392 function createModernModal(modalId, options = {}) {
393 return new ModernModal(modalId, options);
394 }
395
396 function createModernCard(container, options = {}) {
397 const card = new ModernCard(container, options);
398 card.render();
399 return card;
400 }
401
402 function openModal(options = {}) {
403 const {
404 modalId = 'xxAddAgent',
405 title = '',
406 body = '',
407 size = null,
408 okButtonId = 'idx_dlgOkButton',
409 onOk = null,
410 b = null,
411 tag = null
412 } = options;
413
414 setModalContent(modalId, title, body, size);
415 showModal(`${modalId}Modal`, okButtonId, onOk, b, tag);
416 }
417
418 function createIconUploadComponent(iconKey, container, options = {}) {
419 const component = new IconUploadComponent(iconKey, container, options);
420 component.render();
421 return component;
422 }
423
424 // Export for use in other modules
425 if (typeof module !== 'undefined' && module.exports) {
426 module.exports = {
427 ModernModal,
428 ModernCard,
429 IconUploadComponent,
430 createModernModal,
431 createModernCard,
432 createIconUploadComponent
433 };
434 }