From 47ae674a204419083e1ffb11017a8ef484fa560e Mon Sep 17 00:00:00 2001 From: Brandon Haney <121782102+Brandon-Haney@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:23:29 -0500 Subject: [PATCH 1/6] Use a themed dialog for confirm prompts hx-confirm and the settings import prompt showed the browser's own confirm() box. app.js now handles htmx:confirm with a shared modal in the app's theme (data-confirm-title, data-confirm-label, red confirm button for destructive actions, Escape/backdrop cancel, focus kept in the dialog). Also styles .modal-close, which the existing modals use. --- tests/test_styled_confirm.py | 60 ++++++++++ web/static/css/plex-theme.css | 34 ++++++ web/static/js/app.js | 109 ++++++++++++++++++ web/templates/macros/pin_button.html | 2 +- web/templates/settings/backup.html | 8 +- .../settings/partials/arr_instance_card.html | 1 + .../settings/partials/path_mapping_card.html | 1 + web/templates/settings/security.html | 1 + 8 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 tests/test_styled_confirm.py diff --git a/tests/test_styled_confirm.py b/tests/test_styled_confirm.py new file mode 100644 index 00000000..5015d5e1 --- /dev/null +++ b/tests/test_styled_confirm.py @@ -0,0 +1,60 @@ +""" +Confirm prompts use the themed dialog, not the browser's confirm() box. + +app.js hooks htmx:confirm, so every hx-confirm gets pcConfirm(); plain JS calls +pcConfirm() directly. These checks keep native confirm() from coming back and +give each hx-confirm a dialog title. +""" + +import re +from pathlib import Path + +WEB = Path(__file__).resolve().parents[1] / "web" +APP_JS = (WEB / "static" / "js" / "app.js").read_text(encoding="utf-8") + + +def _sources(): + for path in (WEB / "templates").rglob("*.html"): + yield path + for path in (WEB / "static" / "js").rglob("*.js"): + if "vendor" not in path.parts: + yield path + + +_COMMENT = re.compile(r"""|\{#.*?#\}|/\*.*?\*/|(? exists).""" + offenders = [] + for path in _sources(): + source = _strip_comments(path.read_text(encoding="utf-8")) + for match in re.finditer(r"(?]|\{\{[^}]*\}\}|\{%[^%]*%\})*?\bhx-confirm=(?:[^<>]|\{\{[^}]*\}\}|\{%[^%]*%\})*>", re.S) + offenders = [] + for path in (WEB / "templates").rglob("*.html"): + source = path.read_text(encoding="utf-8") + for match in tag.finditer(source): + if "data-confirm-title=" not in match.group(0): + line = source[:match.start()].count("\n") + 1 + offenders.append(f"{path.relative_to(WEB)}:{line}") + assert not offenders, "Add data-confirm-title to: " + ", ".join(offenders) diff --git a/web/static/css/plex-theme.css b/web/static/css/plex-theme.css index 3cb47118..32a45a5c 100644 --- a/web/static/css/plex-theme.css +++ b/web/static/css/plex-theme.css @@ -1682,6 +1682,40 @@ textarea { background: inherit; } +.modal-close { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem; + background: none; + border: none; + border-radius: var(--plex-radius-sm); + color: var(--plex-text-muted); + cursor: pointer; +} + +.modal-close:hover { + color: var(--plex-text); + background: var(--plex-bg-hover); +} + +.modal-close svg { + width: 18px; + height: 18px; +} + +/* Shared confirm dialog (pcConfirm / hx-confirm in app.js) */ +.pc-confirm .modal-content { + max-width: 440px; + width: calc(100% - 2rem); +} + +.pc-confirm-message { + margin: 0; + line-height: 1.5; + white-space: pre-line; +} + .text-center { text-align: center; } diff --git a/web/static/js/app.js b/web/static/js/app.js index 8f0d6071..a4eb5655 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -137,3 +137,112 @@ try { localStorage.removeItem('plexcache_dashboard_verbose'); } } catch (e) { /* storage unavailable: nothing to migrate */ } + +// Themed confirm dialog. pcConfirm({title, message, confirmLabel, danger}) +// resolves true/false. Every hx-confirm in the app goes through it via the +// htmx:confirm hook below, so no page shows the browser's own confirm() box. +// Optional attributes on the hx-confirm element: data-confirm-title, +// data-confirm-label (defaults to the button's text) and data-confirm-danger +// (implied by .btn-danger) for a red confirm button. +(function() { + var modal = null, els = {}, resolver = null, opener = null; + + function build() { + modal = document.createElement('div'); + modal.className = 'modal pc-confirm'; + modal.style.display = 'none'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-labelledby', 'pc-confirm-title'); + modal.innerHTML = + '
' + + ''; + document.body.appendChild(modal); + els.title = modal.querySelector('#pc-confirm-title'); + els.message = modal.querySelector('.pc-confirm-message'); + els.ok = modal.querySelector('.pc-confirm-ok'); + els.cancel = modal.querySelector('.pc-confirm-cancel'); + els.ok.addEventListener('click', function() { close(true); }); + els.cancel.addEventListener('click', function() { close(false); }); + modal.querySelector('.modal-close').addEventListener('click', function() { close(false); }); + modal.querySelector('.modal-backdrop').addEventListener('click', function() { close(false); }); + // Capture phase so Escape/Tab stay inside the dialog while it is open. + document.addEventListener('keydown', function(e) { + if (!resolver) return; + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + close(false); + } else if (e.key === 'Tab') { + var items = modal.querySelectorAll('button'); + var first = items[0], last = items[items.length - 1]; + if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } + else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } + else if (!modal.contains(document.activeElement)) { e.preventDefault(); first.focus(); } + } + }, true); + } + + function close(result) { + if (!resolver) return; + var done = resolver; + resolver = null; + modal.style.display = 'none'; + if (opener && document.contains(opener)) opener.focus(); + opener = null; + done(result); + } + + window.pcConfirm = function(opts) { + opts = opts || {}; + if (!document.body) return Promise.resolve(window.confirm(opts.message || '')); + if (!modal) build(); + if (resolver) close(false); + els.title.textContent = opts.title || 'Please confirm'; + els.message.textContent = opts.message || ''; + els.ok.textContent = opts.confirmLabel || 'Confirm'; + els.ok.className = 'btn pc-confirm-ok ' + (opts.danger ? 'btn-danger' : 'btn-primary'); + opener = document.activeElement; + modal.style.display = 'flex'; + if (typeof lucide !== 'undefined') lucide.createIcons(); + // Like the browser dialog, Enter confirms; a destructive action starts on Cancel. + (opts.danger ? els.cancel : els.ok).focus(); + return new Promise(function(resolve) { resolver = resolve; }); + }; + + function labelFor(elt, evt) { + var source = elt.getAttribute('data-confirm-label'); + if (source) return source; + var button = (evt && evt.submitter) || (elt.tagName === 'FORM' ? elt.querySelector('[type="submit"]') : elt); + var text = button ? button.textContent.replace(/\s+/g, ' ').trim() : ''; + return text && text.length <= 30 ? text : 'Confirm'; + } + + document.addEventListener('htmx:confirm', function(e) { + var question = e.detail.question; + if (!question) return; + e.preventDefault(); + var elt = e.detail.elt; + var source = elt.closest('[hx-confirm]') || elt; + var button = (e.detail.triggeringEvent && e.detail.triggeringEvent.submitter) || elt; + window.pcConfirm({ + title: source.getAttribute('data-confirm-title'), + message: question, + confirmLabel: labelFor(source, e.detail.triggeringEvent), + danger: source.hasAttribute('data-confirm-danger') || + !!(button.classList && button.classList.contains('btn-danger')) + }).then(function(ok) { + if (ok) e.detail.issueRequest(true); + }); + }); +})(); diff --git a/web/templates/macros/pin_button.html b/web/templates/macros/pin_button.html index 427dbdfb..253719ed 100644 --- a/web/templates/macros/pin_button.html +++ b/web/templates/macros/pin_button.html @@ -36,7 +36,7 @@