fix(ui): resolve clipboard copy inside modal and enhance visual toast feedback

Port of Rosmi720/XC_VM@fa1e6691. The Quick Stream Links / Xtream API copy
buttons in the Download Playlist modal used the raw Clipboard API, which is
blocked in some in-modal / insecure-context cases and silently failed.

- copyToClipboard() tries navigator.clipboard.writeText and falls back to a
  textarea + execCommand appended inside the modal (so focus stays valid),
  wrapped in a Promise.
- copyTextWithFeedback() now routes success/empty/failure through the existing
  xcToast() global (Copied! / Nothing to copy), with per-button state reset and
  a prompt() last resort.
- Read-only fields select their text on focus/click for manual copy.

Views only; xcToast is already provided by the admin/reseller footer.
This commit is contained in:
AbdoAhmedElbanaa
2026-09-21 19:33:50 +03:00
committed by Divarion_D
parent bdaccc9805
commit 8ae1342bbc
2 changed files with 172 additions and 48 deletions
+86 -24
View File
@@ -377,7 +377,9 @@ renderUnifiedLayoutFooter('admin');
selected: <?= json_encode($language::get('selected')); ?>,
error: <?= json_encode($language::get('error_occured')); ?>,
confirmDelete: 'Are you sure you want to delete this line?',
confirmKill: 'Are you sure you want to kill all connections for this line?'
confirmKill: 'Are you sure you want to kill all connections for this line?',
copied: <?= json_encode($language::get('copied_success') ?: 'Copied!'); ?>,
noContent: <?= json_encode($language::get('select_an_option') ?: 'Nothing to copy'); ?>
};
// Status code -> [bootstrap colour, label].
var STATUS = {
@@ -826,40 +828,100 @@ renderUnifiedLayoutFooter('admin');
};
jQuery(dlType).on('change', buildDownload);
jQuery(outType).on('change', buildDownload);
var copyTextWithFeedback = function(text, btn) {
if (!text) return;
function copyToClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text);
} else {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
return navigator.clipboard.writeText(text).catch(function() {
return fallbackCopy(text);
});
}
if (btn) {
var icon = btn.querySelector('i');
if (icon) {
var origClass = icon.className;
icon.className = 'icon-base ti tabler-check text-success';
setTimeout(function() {
icon.className = origClass;
}, 1500);
return fallbackCopy(text);
}
function fallbackCopy(text) {
return new Promise(function(resolve, reject) {
try {
var textarea = document.createElement('textarea');
textarea.value = String(text != null ? text : '');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '0';
textarea.setAttribute('readonly', '');
// CRITICAL: Append inside the open modal (if any) so Bootstrap's focus trap
// does not steal focus and clear the selection before copy.
var host = document.querySelector('.modal.show') || document.body;
host.appendChild(textarea);
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
var ok = false;
try {
ok = document.execCommand('copy');
} catch (e) {
ok = false;
}
host.removeChild(textarea);
if (ok) {
resolve();
} else {
reject(new Error('copy command failed'));
}
} catch (err) {
reject(err);
}
});
}
var copyTextWithFeedback = function(text, btn, isFullButton) {
if (!text) {
if (window.xcToast) {
xcToast(lang.noContent || 'Nothing to copy', 'warning');
}
return;
}
copyToClipboard(text).then(function() {
if (window.xcToast) {
xcToast(lang.copied || 'Copied!', 'success');
}
if (btn) {
if (isFullButton) {
if (!btn._origHtml) btn._origHtml = btn.innerHTML;
btn.innerHTML = '<i class="icon-base ti tabler-check me-1"></i> ' + (lang.copied || 'Copied!');
clearTimeout(btn._resetTimer);
btn._resetTimer = setTimeout(function() {
btn.innerHTML = btn._origHtml;
btn._origHtml = null;
}, 1500);
} else {
var icon = btn.querySelector('i');
if (icon) {
if (!btn._origIconClass) btn._origIconClass = icon.className;
icon.className = 'icon-base ti tabler-check text-success';
clearTimeout(btn._resetTimer);
btn._resetTimer = setTimeout(function() {
icon.className = btn._origIconClass;
btn._origIconClass = null;
}, 1500);
}
}
}
}).catch(function() {
prompt('Copy to clipboard:', text);
});
};
jQuery(document).on('click', '.js-quick-copy', function() {
var targetId = this.getAttribute('data-target');
var el = document.getElementById(targetId);
if (el) {
copyTextWithFeedback(el.value, this);
copyTextWithFeedback(el.value, this, false);
}
});
// Quick click-to-select for readonly modal inputs
jQuery('#downloadModal input[readonly]').on('focus click', function() {
this.select();
});
var btnTogglePass = document.getElementById('btn_toggle_pass');
var inputPass = document.getElementById('xtream_pass');
var iconToggle = document.getElementById('toggle_pass_icon');
@@ -891,7 +953,7 @@ renderUnifiedLayoutFooter('admin');
"🔑 Password: " + p + "\n\n" +
"🔗 M3U Plus URL:\n" + m3u;
copyTextWithFeedback(text, btnCopyAll);
copyTextWithFeedback(text, btnCopyAll, true);
});
}
@@ -953,7 +1015,7 @@ renderUnifiedLayoutFooter('admin');
bootstrap.Modal.getOrCreateInstance(dlModal).show();
});
document.getElementById('download_copy').addEventListener('click', function() {
copyTextWithFeedback(dlUrl.value, this);
copyTextWithFeedback(dlUrl.value, this, false);
});
dlOpen.addEventListener('click', function() {
if (dlUrl.value) {
+86 -24
View File
@@ -357,7 +357,9 @@ renderUnifiedLayoutFooter('reseller');
del: <?= json_encode($language::get('delete')); ?>,
error: <?= json_encode($language::get('error_occured')); ?>,
confirmDelete: 'Are you sure you want to delete this line?',
confirmKill: 'Are you sure you want to kill all connections for this line?'
confirmKill: 'Are you sure you want to kill all connections for this line?',
copied: <?= json_encode($language::get('copied_success') ?: 'Copied!'); ?>,
noContent: <?= json_encode($language::get('select_an_option') ?: 'Nothing to copy'); ?>
};
// Status code -> [bootstrap colour, label].
var STATUS = { banned: ['danger', 'Banned'], disabled: ['secondary', 'Disabled'], expired: ['warning', 'Expired'], active: ['success', 'Active'] };
@@ -490,40 +492,100 @@ renderUnifiedLayoutFooter('reseller');
};
jQuery(dlType).on('change', buildDownload);
jQuery(outType).on('change', buildDownload);
var copyTextWithFeedback = function(text, btn) {
if (!text) return;
function copyToClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text);
} else {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
return navigator.clipboard.writeText(text).catch(function() {
return fallbackCopy(text);
});
}
if (btn) {
var icon = btn.querySelector('i');
if (icon) {
var origClass = icon.className;
icon.className = 'icon-base ti tabler-check text-success';
setTimeout(function() {
icon.className = origClass;
}, 1500);
return fallbackCopy(text);
}
function fallbackCopy(text) {
return new Promise(function(resolve, reject) {
try {
var textarea = document.createElement('textarea');
textarea.value = String(text != null ? text : '');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '0';
textarea.setAttribute('readonly', '');
// CRITICAL: Append inside the open modal (if any) so Bootstrap's focus trap
// does not steal focus and clear the selection before copy.
var host = document.querySelector('.modal.show') || document.body;
host.appendChild(textarea);
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
var ok = false;
try {
ok = document.execCommand('copy');
} catch (e) {
ok = false;
}
host.removeChild(textarea);
if (ok) {
resolve();
} else {
reject(new Error('copy command failed'));
}
} catch (err) {
reject(err);
}
});
}
var copyTextWithFeedback = function(text, btn, isFullButton) {
if (!text) {
if (window.xcToast) {
xcToast(lang.noContent || 'Nothing to copy', 'warning');
}
return;
}
copyToClipboard(text).then(function() {
if (window.xcToast) {
xcToast(lang.copied || 'Copied!', 'success');
}
if (btn) {
if (isFullButton) {
if (!btn._origHtml) btn._origHtml = btn.innerHTML;
btn.innerHTML = '<i class="icon-base ti tabler-check me-1"></i> ' + (lang.copied || 'Copied!');
clearTimeout(btn._resetTimer);
btn._resetTimer = setTimeout(function() {
btn.innerHTML = btn._origHtml;
btn._origHtml = null;
}, 1500);
} else {
var icon = btn.querySelector('i');
if (icon) {
if (!btn._origIconClass) btn._origIconClass = icon.className;
icon.className = 'icon-base ti tabler-check text-success';
clearTimeout(btn._resetTimer);
btn._resetTimer = setTimeout(function() {
icon.className = btn._origIconClass;
btn._origIconClass = null;
}, 1500);
}
}
}
}).catch(function() {
prompt('Copy to clipboard:', text);
});
};
jQuery(document).on('click', '.js-quick-copy', function() {
var targetId = this.getAttribute('data-target');
var el = document.getElementById(targetId);
if (el) {
copyTextWithFeedback(el.value, this);
copyTextWithFeedback(el.value, this, false);
}
});
// Quick click-to-select for readonly modal inputs
jQuery('#downloadModal input[readonly]').on('focus click', function() {
this.select();
});
var btnTogglePass = document.getElementById('btn_toggle_pass');
var inputPass = document.getElementById('xtream_pass');
var iconToggle = document.getElementById('toggle_pass_icon');
@@ -555,7 +617,7 @@ renderUnifiedLayoutFooter('reseller');
"🔑 Password: " + p + "\n\n" +
"🔗 M3U Plus URL:\n" + m3u;
copyTextWithFeedback(text, btnCopyAll);
copyTextWithFeedback(text, btnCopyAll, true);
});
}
@@ -617,7 +679,7 @@ renderUnifiedLayoutFooter('reseller');
bootstrap.Modal.getOrCreateInstance(dlModal).show();
});
document.getElementById('download_copy').addEventListener('click', function() {
copyTextWithFeedback(dlUrl.value, this);
copyTextWithFeedback(dlUrl.value, this, false);
});
dlOpen.addEventListener('click', function() {
if (dlUrl.value) {