mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-08-23 14:46:59 +00:00
263 lines
9.2 KiB
JavaScript
263 lines
9.2 KiB
JavaScript
/**
|
||
* Modern modal dialog system using HTML5 <dialog> 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 = `
|
||
<div class="modal-header">
|
||
<span class="modal-icon ${config.type}">${icons[config.type] || icons.info}</span>
|
||
<h2 class="modal-title" id="modal-title">${config.title}</h2>
|
||
</div>
|
||
<div class="modal-body" id="modal-body">
|
||
${config.message}
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="modal-btn-cancel pure-button" data-action="cancel">
|
||
${config.cancelText}
|
||
</button>
|
||
<button type="button" class="modal-btn-${config.type} pure-button" data-action="confirm">
|
||
${config.confirmText}
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
// 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', '<p>Are you sure you want to delete <strong>%(name)s</strong>?</p><p>This action cannot be undone.</p>').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', '<p>Are you sure you want to unlink all watches from <strong>%(name)s</strong>?</p><p>The tag will be kept but watches will be removed from it.</p>').split('%(name)s').join(safeName),
|
||
type: 'warning',
|
||
confirmText: _modalT('unlink', 'Unlink'),
|
||
cancelText: _modalT('cancel', 'Cancel'),
|
||
onConfirm: onConfirm
|
||
});
|
||
}
|
||
};
|
||
|
||
// Make available globally
|
||
window.ModalDialog = ModalDialog;
|
||
|
||
/**
|
||
* Submit `url` as a POST carrying the CSRF token, via a throwaway body-level form.
|
||
*
|
||
* Body-level (rather than wrapping the link) because these links often live inside another
|
||
* <form> - the watch list and the edit page both do - and nested forms are invalid HTML that
|
||
* browsers silently drop. The token comes from the global `csrftoken` set in base.html.
|
||
*
|
||
* Used for state-changing actions that must not be reachable by GET, otherwise an
|
||
* <img src="..."> on any page the operator visits performs the action for them.
|
||
*/
|
||
function postWithCsrf(url) {
|
||
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();
|
||
}
|
||
window.postWithCsrf = postWithCsrf;
|
||
|
||
/**
|
||
* Anchors marked data-method="POST" that do NOT also ask for confirmation.
|
||
* (The confirm variant is handled in the data-requires-confirm handler below.)
|
||
*/
|
||
$(document).ready(function() {
|
||
$(document).on('click', 'a[data-method="POST"]:not([data-requires-confirm])', function(e) {
|
||
e.preventDefault();
|
||
postWithCsrf($(this).attr('href'));
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Auto-attach modal confirmations to links with data-requires-confirm attribute
|
||
* Usage in HTML:
|
||
* <a href="/delete"
|
||
* data-requires-confirm
|
||
* data-confirm-type="danger"
|
||
* data-confirm-title="Delete Item?"
|
||
* data-confirm-message="Are you sure?"
|
||
* data-confirm-button="Delete">
|
||
*/
|
||
$(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', '<p>Are you sure you want to proceed?</p>'),
|
||
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 <form> (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 <img src=...> CSRF relies on GET firing.
|
||
const method = ($element.attr('data-method') || 'GET').toUpperCase();
|
||
if (method === 'POST') {
|
||
postWithCsrf(url);
|
||
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);
|
||
});
|
||
});
|