diff --git a/js/ajax_modal.js b/js/ajax_modal.js
index 81a9824f..af12db20 100644
--- a/js/ajax_modal.js
+++ b/js/ajax_modal.js
@@ -1,61 +1,114 @@
-// Ajax Modal Load Script
-$(document).on('click', '.ajax-modal', function (e) {
- e.preventDefault();
-
- const $trigger = $(this);
-
- // Prefer data-modal-url, fallback to href
- let modalUrl = $trigger.data('modal-url') || $trigger.attr('href') || '#';
- const modalSize = $trigger.data('modal-size') || 'md';
- const modalId = 'ajaxModal_' + Date.now();
-
- // If no usable URL, bail
- if (!modalUrl || modalUrl === '#') {
- console.warn('ajax-modal: No modal URL found on trigger:', this);
- return;
- }
-
- // Show loading spinner while fetching content
- const loadingSpinner = `
-
-
-
`;
- $('.content-wrapper').append(loadingSpinner);
-
- // Make AJAX request
- $.ajax({
- url: modalUrl,
- method: 'GET',
- dataType: 'json',
- success: function (response) {
- $('#modal-loading-spinner').remove();
-
- if (response.error) {
- alert(response.error);
- return;
- }
-
- const modalHtml = `
-
-
-
- ${response.content}
-
-
-
`;
-
- $('.content-wrapper').append(modalHtml);
- const $modal = $('#' + modalId);
- $modal.modal('show');
-
- $modal.on('hidden.bs.modal', function () {
- $(this).remove();
- });
- },
- error: function (xhr, status, error) {
- $('#modal-loading-spinner').remove();
- alert('Error loading modal content. Please try again.');
- console.error('Modal AJAX Error:', status, error);
+// Ajax Modal Load Script (deduped + locked)
+function hashKey(str) {
+ let h = 0;
+ for (let i = 0; i < str.length; i++) {
+ h = ((h << 5) - h + str.charCodeAt(i)) | 0;
}
- });
+ return Math.abs(h).toString(36);
+}
+
+$(document).on('click', '.ajax-modal', function (e) {
+ e.preventDefault();
+
+ const $trigger = $(this);
+
+ // prevent spam clicks on same trigger
+ if ($trigger.data('ajaxModalLoading')) {
+ return;
+ }
+
+ $trigger
+ .data('ajaxModalLoading', true)
+ .prop('disabled', true)
+ .addClass('disabled');
+
+ // Prefer data-modal-url, fallback to href
+ const modalUrl = $trigger.data('modal-url') || $trigger.attr('href') || '#';
+ const modalSize = $trigger.data('modal-size') || 'md';
+
+ if (!modalUrl || modalUrl === '#') {
+ console.warn('ajax-modal: No modal URL found on trigger:', this);
+
+ $trigger
+ .data('ajaxModalLoading', false)
+ .prop('disabled', false)
+ .removeClass('disabled');
+
+ return;
+ }
+
+ // stable IDs based on URL (prevents duplicates)
+ const key = hashKey(String(modalUrl));
+ const modalId = 'ajaxModal_' + key;
+ const spinnerId = 'modal-loading-spinner-' + key;
+
+ // if modal already exists, just show it
+ const $existing = $('#' + modalId);
+ if ($existing.length) {
+ $existing.modal('show');
+
+ $trigger
+ .data('ajaxModalLoading', false)
+ .prop('disabled', false)
+ .removeClass('disabled');
+
+ return;
+ }
+
+ // Show loading spinner while fetching content (deduped)
+ $('#' + spinnerId).remove();
+ $('.content-wrapper').append(`
+
+
+
+ `);
+
+ $.ajax({
+ url: modalUrl,
+ method: 'GET',
+ dataType: 'json'
+ })
+ .done(function (response) {
+ $('#' + spinnerId).remove();
+
+ if (response && response.error) {
+ alert(response.error);
+ return;
+ }
+
+ // guard against race: if another request already created it
+ if ($('#' + modalId).length) {
+ $('#' + modalId).modal('show');
+ return;
+ }
+
+ const modalHtml = `
+
+
+
+ ${response.content || ''}
+
+
+
`;
+
+ $('.content-wrapper').append(modalHtml);
+
+ const $modal = $('#' + modalId);
+ $modal.modal('show');
+
+ $modal.on('hidden.bs.modal', function () {
+ $(this).remove();
+ });
+ })
+ .fail(function (xhr, status, error) {
+ $('#' + spinnerId).remove();
+ alert('Error loading modal content. Please try again.');
+ console.error('Modal AJAX Error:', status, error);
+ })
+ .always(function () {
+ $trigger
+ .data('ajaxModalLoading', false)
+ .prop('disabled', false)
+ .removeClass('disabled');
+ });
});