From 8ae1342bbcd434d453570ca00a2ee01b50d0b742 Mon Sep 17 00:00:00 2001 From: AbdoAhmedElbanaa Date: Mon, 21 Sep 2026 19:33:50 +0300 Subject: [PATCH] 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. --- src/Public/Views/admin/lines.php | 110 ++++++++++++++++++++++------ src/Public/Views/reseller/lines.php | 110 ++++++++++++++++++++++------ 2 files changed, 172 insertions(+), 48 deletions(-) diff --git a/src/Public/Views/admin/lines.php b/src/Public/Views/admin/lines.php index fcaeab06..5cae414c 100644 --- a/src/Public/Views/admin/lines.php +++ b/src/Public/Views/admin/lines.php @@ -377,7 +377,9 @@ renderUnifiedLayoutFooter('admin'); selected: , error: , 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: , + noContent: }; // 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 = ' ' + (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) { diff --git a/src/Public/Views/reseller/lines.php b/src/Public/Views/reseller/lines.php index e7ca5521..d480cf7c 100644 --- a/src/Public/Views/reseller/lines.php +++ b/src/Public/Views/reseller/lines.php @@ -357,7 +357,9 @@ renderUnifiedLayoutFooter('reseller'); del: , error: , 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: , + noContent: }; // 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 = ' ' + (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) {