UNPKG

meshcentral

Version:

Web based remote computer management server

435 lines (381 loc) • 18.2 kB
/** * Reusable UI Components * This file contains reusable JavaScript components that can be used across the application * * default3.handlebars current state (as of 2026-03-02) contains: * - 256 `setModalContent(...)` calls * - 243 `showModal(...)` calls * 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. * * Biggest gain first: * - Standardize modal invocation through reusable helpers in this file, * then migrate repeated modal calls in default3.handlebars. * - Expected code reduction in default3.handlebars, and ensures lower duplication risk. * * More UI components can be added here, or moved to a dedicated components directory over time (one component per file) as needed. */ // Modern Modal Component class ModernModal { constructor(modalId, options = {}) { this.modalId = modalId; this.options = { size: 'medium', showCloseButton: true, backdrop: true, keyboard: true, ...options }; } show(title, content, okCallback = null, okButtonText = 'OK') { const sizeClass = this.options.size === 'large' ? 'modal-lg' : this.options.size === 'extra-large' ? 'modal-xl' : ''; let modalContent = ` <div class="modal-dialog modal-dialog-centered ${sizeClass}"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title">${title}</h5> ${this.options.showCloseButton ? '<button type="button" class="btn-close" data-bs-dismiss="modal"></button>' : ''} </div> <div class="modal-body"> ${content} </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> ${okCallback ? `<button type="button" class="btn btn-primary" id="${this.modalId}OkBtn">${okButtonText}</button>` : ''} </div> </div> </div> `; setModalContent(this.modalId, title, content, this.options.size); if (okCallback) { showModal(this.modalId, `${this.modalId}OkBtn`, okCallback); } else { showModal(this.modalId); } } hide() { const modalElement = document.getElementById(this.modalId); if (modalElement) { const modal = bootstrap.Modal.getInstance(modalElement); if (modal) { modal.hide(); } } } } // Modern Card Component class ModernCard { constructor(container, options = {}) { this.container = container; this.options = { title: '', icon: '', status: 'default', // default, success, warning, danger actions: [], ...options }; } render() { const statusClasses = { default: '', success: 'border-success', warning: 'border-warning', danger: 'border-danger' }; const statusIcons = { default: 'fa-circle', success: 'fa-check-circle', warning: 'fa-exclamation-circle', danger: 'fa-times-circle' }; const statusColors = { default: 'text-muted', success: 'text-success', warning: 'text-warning', danger: 'text-danger' }; let cardHTML = ` <div class="card modern-card ${statusClasses[this.options.status]} h-100"> <div class="card-header d-flex align-items-center"> <div class="bg-light rounded-circle p-2 me-3"> <i class="fas ${this.options.icon} fa-lg text-secondary"></i> </div> <div class="flex-grow-1"> <h6 class="card-title mb-1">${this.options.title}</h6> <small class="status-badge ${statusColors[this.options.status]}"> <i class="fas ${statusIcons[this.options.status]} me-1"></i> <span class="status-text">${this.options.status}</span> </small> </div> </div> <div class="card-body"> <div class="card-content"> ${this.options.content || ''} </div> </div> `; if (this.options.actions.length > 0) { cardHTML += '<div class="card-footer">'; this.options.actions.forEach(action => { cardHTML += `<button class="btn btn-sm ${action.class || 'btn-primary'}" onclick="${action.onclick}">${action.label}</button>`; }); cardHTML += '</div>'; } cardHTML += '</div>'; this.container.innerHTML = cardHTML; } updateStatus(status) { this.options.status = status; const card = this.container.querySelector('.modern-card'); const statusText = this.container.querySelector('.status-text'); const statusIcon = this.container.querySelector('.status-badge i'); // Remove all status classes card.classList.remove('border-success', 'border-warning', 'border-danger'); statusText.classList.remove('text-muted', 'text-success', 'text-warning', 'text-danger'); // Add new status classes const statusClasses = { default: '', success: 'border-success', warning: 'border-warning', danger: 'border-danger' }; const statusIcons = { default: 'fa-circle', success: 'fa-check-circle', warning: 'fa-exclamation-circle', danger: 'fa-times-circle' }; const statusColors = { default: 'text-muted', success: 'text-success', warning: 'text-warning', danger: 'text-danger' }; card.classList.add(statusClasses[status]); statusText.classList.add(statusColors[status]); statusIcon.className = `fas ${statusIcons[status]} me-1`; statusText.textContent = status; } } // Icon Upload Component // Reusable for any icon-upload card by passing callbacks/options: // - `onUpload`, `onUrlInput`, `onRemove` for feature-specific behavior // - `normalizePreviewUrl` for domain/path normalization // - `iconKey`, `label`, `currentValue` for per-instance identity and content // The component owns input/file/preview UI; persistence and status updates stay in page logic. const CUSTOM_ICON_MAX_FILE_SIZE = 10485760; const CUSTOM_ICON_MAX_DIMENSION = 64; class IconUploadComponent { constructor(iconKey, container, options = {}) { this.iconKey = iconKey; this.container = container; this.options = { label: iconKey, currentValue: '', onUpload: null, onRemove: null, onUrlInput: null, normalizePreviewUrl: null, ...options }; } getPreviewSrc(value) { if ((typeof value !== 'string') || (value.length === 0)) { return ''; } if (typeof this.options.normalizePreviewUrl !== 'function') { return value; } try { return this.options.normalizePreviewUrl(value); } catch (ex) { return value; } } getImageDimensions(file) { return new Promise((resolve, reject) => { // Read image dimensions locally before upload so oversized icons fail fast. const imageUrl = URL.createObjectURL(file); const image = new Image(); image.onload = function () { const dimensions = { width: image.naturalWidth, height: image.naturalHeight }; URL.revokeObjectURL(imageUrl); resolve(dimensions); }; image.onerror = function () { URL.revokeObjectURL(imageUrl); reject(new Error('Unable to read uploaded icon dimensions.')); }; image.src = imageUrl; }); } render() { const hasIcon = this.options.currentValue.length > 0; const initialPreviewSrc = hasIcon ? this.getPreviewSrc(this.options.currentValue) : ''; const html = ` <div class="icon-upload-component" data-icon-key="${this.iconKey}"> <div class="input-group mb-3"> <input type="text" class="form-control" id="iconInput_${this.iconKey}" value="${this.options.currentValue}" placeholder="Enter URL or data URL for ${this.options.label} icon" oninput="window.iconUploadComponents['${this.iconKey}'].handleUrlInput(this)" /> <button class="btn btn-outline-primary" type="button" onclick="window.iconUploadComponents['${this.iconKey}'].triggerFileUpload()"> <i class="fas fa-upload me-2"></i>Upload </button> </div> <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> <div class="icon-preview-container ${hasIcon ? '' : 'd-none'}" id="preview_container_${this.iconKey}"> <small class="text-muted me-2">Preview:</small> <img class="icon-preview-item" id="preview_${this.iconKey}" src="${initialPreviewSrc}" alt="Icon preview" /> <button class="btn btn-sm btn-outline-danger ms-auto" type="button" onclick="window.iconUploadComponents['${this.iconKey}'].removeIcon()"> <i class="fas fa-times me-1"></i>Default icon </button> </div> <input type="file" class="d-none" accept=".svg,.png,.jpg,.jpeg,image/svg+xml,image/png,image/jpeg" id="iconFile_${this.iconKey}" onchange="window.iconUploadComponents['${this.iconKey}'].handleFileUpload(this)" /> </div> `; this.container.innerHTML = html; // Store reference for global access if (!window.iconUploadComponents) { window.iconUploadComponents = {}; } window.iconUploadComponents[this.iconKey] = this; } triggerFileUpload() { const fileInput = document.getElementById(`iconFile_${this.iconKey}`); if (fileInput) { fileInput.click(); } } handleUrlInput(input) { const value = input.value.trim(); const previewContainer = document.getElementById(`preview_container_${this.iconKey}`); const previewIcon = document.getElementById(`preview_${this.iconKey}`); if (value.length > 0) { previewContainer.classList.remove('d-none'); if (previewIcon.tagName.toLowerCase() === 'img') { previewIcon.src = this.getPreviewSrc(value); } else { previewIcon.style.backgroundImage = `url('${value}')`; } } else { previewContainer.classList.add('d-none'); if (previewIcon.tagName.toLowerCase() === 'img') { previewIcon.removeAttribute('src'); } else { previewIcon.style.backgroundImage = ''; } } if (this.options.onUrlInput) { this.options.onUrlInput(this.iconKey, value); } } async handleFileUpload(input) { if (!input || !input.files || (input.files.length === 0)) { return; } const button = this.container.querySelector('.btn-outline-primary'); const originalContent = button.innerHTML; const file = input.files[0]; // Show loading state button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Uploading...'; button.disabled = true; try { 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.'); } 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.'); } var uploadFile = file; if (/\.(svg)$/i.test(file.name || '') || /^image\/svg\+xml$/i.test(file.type || '')) { // Ensure DOMPurify is loaded if ((typeof DOMPurify === 'undefined') || (typeof DOMPurify.sanitize !== 'function')) { throw new Error('Unable to clean SVG icon in this browser.'); } // Clean SVG file const cleanedSvg = DOMPurify.sanitize(await file.text(), { USE_PROFILES: { svg: true, svgFilters: true } }); if ((typeof cleanedSvg !== 'string') || (cleanedSvg.search(/<svg[\s>]/i) < 0)) { throw new Error('Invalid SVG icon file.'); } uploadFile = new File([cleanedSvg], file.name, { type: 'image/svg+xml', lastModified: file.lastModified }); } else { const dimensions = await this.getImageDimensions(file); 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.'); } } if (this.options.onUpload) { const result = await this.options.onUpload(this.iconKey, uploadFile); // Show success state button.innerHTML = '<i class="fas fa-check me-2"></i>Success!'; button.classList.remove('btn-outline-primary'); button.classList.add('btn-success'); // Update preview const previewContainer = document.getElementById(`preview_container_${this.iconKey}`); const previewIcon = document.getElementById(`preview_${this.iconKey}`); const textInput = document.getElementById(`iconInput_${this.iconKey}`); if (result && result.path) { previewContainer.classList.remove('d-none'); if (previewIcon.tagName.toLowerCase() === 'img') { previewIcon.src = this.getPreviewSrc(result.path); } else { previewIcon.style.backgroundImage = `url('${result.path}')`; } textInput.value = result.path; } setTimeout(() => { button.innerHTML = originalContent; button.classList.remove('btn-success'); button.classList.add('btn-outline-primary'); button.disabled = false; }, 2000); } } catch (error) { // Show error state button.innerHTML = '<i class="fas fa-exclamation-triangle me-2"></i>Failed'; button.title = (error && error.message) ? error.message : ''; button.classList.remove('btn-outline-primary'); button.classList.add('btn-danger'); setTimeout(() => { button.innerHTML = originalContent; button.title = ''; button.classList.remove('btn-danger'); button.classList.add('btn-outline-primary'); button.disabled = false; }, 2000); } input.value = ''; } removeIcon() { const previewContainer = document.getElementById(`preview_container_${this.iconKey}`); const previewIcon = document.getElementById(`preview_${this.iconKey}`); const textInput = document.getElementById(`iconInput_${this.iconKey}`); previewContainer.classList.add('d-none'); if (previewIcon.tagName.toLowerCase() === 'img') { previewIcon.removeAttribute('src'); } else { previewIcon.style.backgroundImage = ''; } textInput.value = ''; if (this.options.onUrlInput) { this.options.onUrlInput(this.iconKey, ''); } if (this.options.onRemove) { this.options.onRemove(this.iconKey); } } } // Utility functions function createModernModal(modalId, options = {}) { return new ModernModal(modalId, options); } function createModernCard(container, options = {}) { const card = new ModernCard(container, options); card.render(); return card; } function openModal(options = {}) { const { modalId = 'xxAddAgent', title = '', body = '', size = null, okButtonId = 'idx_dlgOkButton', onOk = null, b = null, tag = null } = options; setModalContent(modalId, title, body, size); showModal(`${modalId}Modal`, okButtonId, onOk, b, tag); } function createIconUploadComponent(iconKey, container, options = {}) { const component = new IconUploadComponent(iconKey, container, options); component.render(); return component; } // Export for use in other modules if (typeof module !== 'undefined' && module.exports) { module.exports = { ModernModal, ModernCard, IconUploadComponent, createModernModal, createModernCard, createIconUploadComponent }; }