| 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 | } |