/** * Modern modal dialog system using HTML5 element * Provides accessible, animated confirmation dialogs */ // Escapes a string for safe insertion via innerHTML function _modalEscapeHTML(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } // Translatable strings are injected by base.html into window.ModalDialogI18n so // Babel can extract them from the Jinja template. Fall back to English when a // key is missing (e.g. a page that renders modal.js without the bridge). function _modalT(key, fallback) { const i18n = window.ModalDialogI18n || {}; return (key in i18n) ? i18n[key] : fallback; } const ModalDialog = { /** * Show a confirmation dialog * @param {Object} options - Configuration options * @param {string} options.title - Dialog title * @param {string} options.message - Dialog message (can include HTML) * @param {string} options.type - Dialog type: 'danger', 'warning', or 'info' (default: 'info') * @param {string} options.confirmText - Confirm button text (default: 'Confirm') * @param {string} options.cancelText - Cancel button text (default: 'Cancel') * @param {Function} options.onConfirm - Callback when confirmed * @param {Function} options.onCancel - Callback when cancelled (optional) * @returns {Promise} Resolves with true if confirmed, false if cancelled */ confirm: function(options) { return new Promise((resolve) => { const defaults = { title: _modalT('confirmTitle', 'Confirm Action'), message: _modalT('confirmMessage', 'Are you sure?'), type: 'info', confirmText: _modalT('confirm', 'Confirm'), cancelText: _modalT('cancel', 'Cancel'), onConfirm: null, onCancel: null }; const config = { ...defaults, ...options }; // Icon mapping const icons = { danger: '⚠️', warning: '⚠️', info: 'ℹ️' }; // Create dialog element const dialog = document.createElement('dialog'); dialog.className = 'modal-dialog'; dialog.setAttribute('aria-labelledby', 'modal-title'); dialog.setAttribute('aria-describedby', 'modal-body'); // Build dialog content dialog.innerHTML = ` ${icons[config.type] || icons.info} ${config.title} ${config.message} `; // Append to body document.body.appendChild(dialog); // Handle button clicks const handleClose = (confirmed) => { dialog.close(); setTimeout(() => { dialog.remove(); }, 200); if (confirmed && config.onConfirm) { config.onConfirm(); } else if (!confirmed && config.onCancel) { config.onCancel(); } resolve(confirmed); }; // Attach event listeners dialog.querySelector('[data-action="confirm"]').addEventListener('click', () => { handleClose(true); }); dialog.querySelector('[data-action="cancel"]').addEventListener('click', () => { handleClose(false); }); // Handle Escape key dialog.addEventListener('cancel', (e) => { e.preventDefault(); handleClose(false); }); // Handle backdrop click dialog.addEventListener('click', (e) => { const rect = dialog.getBoundingClientRect(); if ( e.clientY < rect.top || e.clientY > rect.bottom || e.clientX < rect.left || e.clientX > rect.right ) { handleClose(false); } }); // Show dialog dialog.showModal(); // Focus confirm button for accessibility setTimeout(() => { dialog.querySelector('[data-action="confirm"]').focus(); }, 100); }); }, /** * Helper method for delete confirmations * @param {string} itemName - Name of the item being deleted * @param {Function} onConfirm - Callback when confirmed */ confirmDelete: function(itemName, onConfirm) { const safeName = _modalEscapeHTML(itemName); return this.confirm({ title: _modalT('deleteTitle', 'Delete %(name)s?').split('%(name)s').join(safeName), message: _modalT('deleteMessage', 'Are you sure you want to delete %(name)s?This action cannot be undone.').split('%(name)s').join(safeName), type: 'danger', confirmText: _modalT('delete', 'Delete'), cancelText: _modalT('cancel', 'Cancel'), onConfirm: onConfirm }); }, /** * Helper method for unlink confirmations * @param {string} itemName - Name of the item being unlinked * @param {Function} onConfirm - Callback when confirmed */ confirmUnlink: function(itemName, onConfirm) { const safeName = _modalEscapeHTML(itemName); return this.confirm({ title: _modalT('unlinkTitle', 'Unlink %(name)s?').split('%(name)s').join(safeName), message: _modalT('unlinkMessage', 'Are you sure you want to unlink all watches from %(name)s?The tag will be kept but watches will be removed from it.').split('%(name)s').join(safeName), type: 'warning', confirmText: _modalT('unlink', 'Unlink'), cancelText: _modalT('cancel', 'Cancel'), onConfirm: onConfirm }); } }; // Make available globally window.ModalDialog = ModalDialog; /** * Auto-attach modal confirmations to links with data-requires-confirm attribute * Usage in HTML: * */ $(document).ready(function() { $(document).on('click', 'a[data-requires-confirm], button[data-requires-confirm]', function(e) { e.preventDefault(); const $element = $(this); const url = $element.attr('href'); const config = { type: $element.attr('data-confirm-type') || 'danger', title: $element.attr('data-confirm-title') || _modalT('confirmTitle', 'Confirm Action'), message: $element.attr('data-confirm-message') || _modalT('proceedMessage', 'Are you sure you want to proceed?'), confirmText: $element.attr('data-confirm-button') || _modalT('confirm', 'Confirm'), cancelText: $element.attr('data-cancel-button') || _modalT('cancel', 'Cancel'), onConfirm: function() { // data-method="POST" — build a body-level hidden form with the CSRF // token and submit it. Avoids nested-form HTML invalidity when the // anchor lives inside an outer (e.g. settings tabs). The CSRF // token comes from the global `csrftoken` set in base.html. // GHSA-g36r-fm2p-87xm: anchors that mutate server state must not fire // on a bare GET, since CSRF relies on GET firing. const method = ($element.attr('data-method') || 'GET').toUpperCase(); if (method === 'POST') { const form = document.createElement('form'); form.method = 'POST'; form.action = url; form.style.display = 'none'; if (typeof csrftoken !== 'undefined' && csrftoken) { const tok = document.createElement('input'); tok.type = 'hidden'; tok.name = 'csrf_token'; tok.value = csrftoken; form.appendChild(tok); } document.body.appendChild(form); form.submit(); return; } // If it's a link, navigate to the URL if ($element.is('a')) { window.location.href = url; } // If it's a button in a form, submit the form else if ($element.is('button')) { // Use requestSubmit() to include the button's name/value in the form data $element.closest('form')[0].requestSubmit($element[0]); } } }; ModalDialog.confirm(config); }); });
Are you sure you want to delete %(name)s?
This action cannot be undone.
Are you sure you want to unlink all watches from %(name)s?
The tag will be kept but watches will be removed from it.
Are you sure you want to proceed?