From edd112fec37730da7ce4758c9e1bd93ad161dcaf Mon Sep 17 00:00:00 2001 From: Matt Batchelder Date: Fri, 6 Feb 2026 23:57:16 -0500 Subject: [PATCH] feat: Enhance dark mode styling and improve dropdown menu behavior for better user experience --- custom/otssignange/css/override-dark.css | 387 +++++++++++++++++ custom/otssignange/css/override.css | 176 ++++++-- .../views/dashboard-status-page.twig | 16 + custom/otssignange/views/daypart-page.twig | 8 +- custom/otssignange/views/login.twig | 120 ++++++ .../views/partials/_dashboard-card.twig | 20 + custom/otssignange/views/theme-scripts.twig | 399 ++++++++++++++++++ 7 files changed, 1094 insertions(+), 32 deletions(-) create mode 100644 custom/otssignange/views/login.twig create mode 100644 custom/otssignange/views/partials/_dashboard-card.twig diff --git a/custom/otssignange/css/override-dark.css b/custom/otssignange/css/override-dark.css index b1fd1db..013518a 100644 --- a/custom/otssignange/css/override-dark.css +++ b/custom/otssignange/css/override-dark.css @@ -398,6 +398,40 @@ hr { } /* Navigation Icons and Text */ + +/* ========================================================================== + FORCE DARK BACKGROUND FALLBACKS + Ensure no white areas appear when scrolling or when elements overflow. + This uses high-specificity selectors and !important to override stray + light-background rules from other stylesheets. + ========================================================================== */ + +html, body, #page-wrapper, .ots-main, .ots-content, .page-content, .container { + background-color: var(--color-background) !important; + background: none !important; + color: var(--color-text-primary) !important; +} + +/* Remove or neutralise any pseudo-elements that may paint a light background */ +html::before, html::after, body::before, body::after, #page-wrapper::before, #page-wrapper::after { + background: transparent !important; + content: none !important; +} + +/* Ensure fixed/backdrop layers are dark where appropriate */ +.modal-backdrop, +.modal, +.modal-open, +.ots-shell, +.ots-footer { + background-color: var(--color-background) !important; +} + +/* Defensive: override any explicit white panel backgrounds that leak outside their container */ +.card, .panel, .panel-body, .dashboard-card, .ots-sidebar li.sidebar-list.active > a { + background-color: var(--color-surface) !important; + color: var(--color-text-primary) !important; +} .ots-nav-icon { width: 16px; height: 16px; @@ -785,6 +819,29 @@ textarea:focus { border-top: 1px solid var(--color-border) !important; } +/* Ensure modal footer buttons (cancel/secondary) are readable on dark surfaces */ +.modal-footer .btn, +.modal-footer button { + background: var(--ots-surface-3); + color: var(--ots-text) !important; + border: 1px solid var(--ots-border) !important; + box-shadow: none !important; +} + +.modal-footer .btn:hover, +.modal-footer button:hover { + background: var(--ots-surface-2); + color: var(--ots-primary) !important; + border-color: var(--ots-primary) !important; +} + +.modal-footer .btn.btn-primary, +.modal-footer button.btn-primary { + background: var(--ots-primary) !important; + color: #0b1020 !important; + border-color: var(--ots-primary-2) !important; +} + /* ============================================================================= HELP PANE / MISC ============================================================================= */ @@ -824,3 +881,333 @@ textarea:focus { font-size: 13px; color: var(--ots-text-muted); } + +/* ============================================================================= + LOGIN / SIGN-IN PAGE + Styles to match the requested sign-in card: centered panel, dark glass + surface, orange accent, soft shadow and modern inputs. + ============================================================================= */ + +:root { + --login-accent: #ff8a00; + --login-panel-bg: linear-gradient(180deg, rgba(8,12,20,0.9), rgba(10,16,28,0.85)); +} + +body.login, body.login-page, .xibo-login, #login, .login-wrapper { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 40px 20px; + background-image: radial-gradient(rgba(255,255,255,0.02) 1px, transparent 1px), + linear-gradient(135deg, rgba(6,16,30,0.6), rgba(8,12,24,0.65)); + background-size: 48px 48px, cover; +} + +.login-card, +.login-panel, +.auth-card, +.xibo-login-box, +#login-box { + width: 100%; + max-width: 560px; + border-radius: 12px; + padding: 32px 36px; + background: var(--login-panel-bg); + border: 1px solid rgba(255,255,255,0.04); + box-shadow: var(--ots-shadow-lg); + color: var(--ots-text); +} + +.login-card .login-logo, +.login-panel .login-logo { + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 18px; +} + +.login-card .login-logo .logo-icon, +.login-panel .login-logo .logo-icon { + width: 92px; + height: 92px; + border-radius: 10px; + background: var(--login-accent); + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: 0 6px 18px rgba(0,0,0,0.45); +} + +/* Brand text next to logo on login */ +.login-brand { + display: flex; + align-items: center; + justify-content: center; + gap: 18px; + margin-bottom: 18px; +} + +.login-brand .login-logo { + width: 92px; + height: 92px; + display: inline-block; +} + +.login-brand-text { + color: var(--ots-text); + font-size: 1.5rem; + font-weight: 700; + letter-spacing: 0.01em; +} + +.login-card h1, +.login-panel h1 { + text-align: center; + margin: 8px 0 6px 0; + font-size: 28px; + font-weight: 700; +} + +.login-card .lead, +.login-panel .lead { + text-align: center; + color: var(--ots-text-muted); + margin-bottom: 18px; +} + +.login-card .form-group, +.login-panel .form-group { + margin-bottom: 14px; +} + +.login-card input[type="text"], +.login-card input[type="email"], +.login-card input[type="password"], +.login-card .form-control, +.login-panel input[type="text"], +.login-panel input[type="email"], +.login-panel input[type="password"] { + width: 100%; + background: linear-gradient(180deg, rgba(255,255,255,0.016), rgba(255,255,255,0.01)); + border: 1px solid rgba(255,255,255,0.06); + color: var(--ots-text); + padding: 14px 16px; + border-radius: 12px; + font-size: 1rem; + line-height: 1.25; + transition: border-color 180ms ease, box-shadow 180ms ease, background 180ms ease; +} + +.login-card input:focus, +.login-panel input:focus, +.login-card .form-control:focus { + outline: none; + border-color: rgba(255,138,0,0.95); + background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01)); + box-shadow: 0 8px 22px rgba(0,0,0,0.32), 0 0 0 6px rgba(255,138,0,0.04); +} + +.login-card input::placeholder, +.login-panel input::placeholder { + color: rgba(255,255,255,0.58); +} + +.login-card input + input, +.login-card input + .form-control, +.login-panel input + input { + margin-top: 10px; +} + +.login-card .form-control[disabled], +.login-card input[disabled] { + opacity: 0.7; +} + +.login-card .btn-signin, +.login-panel .btn-signin, +.login-card .btn-primary.login, +.login-panel .btn-primary.login { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 10px 14px; + background: rgba(255,255,255,0.03); + color: var(--ots-text); + border-radius: 10px; + border: 1px solid rgba(255,255,255,0.12); + font-weight: 600; + box-shadow: none; + transition: background 160ms ease, border-color 160ms ease, color 160ms ease, box-shadow 160ms ease; +} + +.login-card .btn-signin .icon, +.login-panel .btn-signin .icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; +} + +.login-card .btn-signin:hover, +.login-panel .btn-signin:hover { + background: rgba(255,255,255,0.06); + border-color: rgba(255,255,255,0.18); +} + +.login-card .btn-signin:focus, +.login-panel .btn-signin:focus { + outline: none; + box-shadow: 0 6px 18px rgba(0,0,0,0.28), 0 0 0 6px rgba(255,138,0,0.04); + border-color: rgba(255,138,0,0.28); +} + +.login-card .forgot-link, +.login-panel .forgot-link { + display: block; + text-align: right; + margin-top: 8px; + color: var(--ots-text-muted); +} + +/* Small screens: compress card padding */ +@media (max-width: 520px) { + .login-card, .login-panel { padding: 20px; border-radius: 10px; } + .login-card .login-logo .logo-icon { width: 72px; height: 72px; } +} + +/* Animated background for login page: subtle moving gradient behind the card */ +body.login-page::before { + content: ""; + position: fixed; + inset: 0; + z-index: 0; + background: linear-gradient(120deg, rgba(14,28,45,0.6), rgba(6,16,30,0.55)), + radial-gradient( circle at 10% 20%, rgba(79,140,255,0.06), transparent 10% ), + radial-gradient( circle at 85% 80%, rgba(255,138,0,0.04), transparent 12% ); + background-blend-mode: overlay, normal, normal; + background-size: 200% 200%, 100% 100%, 100% 100%; + filter: blur(22px); + pointer-events: none; + opacity: 0.95; + /* no animation here - blobs will provide the motion */ +} + +@keyframes ots-login-bg-shift { + 0% { background-position: 0% 50%, 0% 0%, 0% 0%; } + 50% { background-position: 100% 50%, 0% 0%, 0% 0%; } + 100% { background-position: 0% 50%, 0% 0%, 0% 0%; } +} + +/* Bring the login card above the animated background */ +.login-card, +.login-panel, +.auth-card, +.xibo-login-box, +#login-box { + position: relative; + z-index: 2; +} + +/* Blurred animated color blobs behind the login card */ +.ots-login-blob { + position: fixed; + pointer-events: none; + filter: blur(60px) saturate(120%); + opacity: 0.9; + mix-blend-mode: screen; + z-index: 0; + will-change: transform, opacity; +} + +.ots-login-blob--1 { + width: 520px; + height: 520px; + left: -8%; + top: -6%; + background: radial-gradient(circle at 30% 30%, rgba(79,140,255,0.65), rgba(79,140,255,0.18) 35%, transparent 50%); + animation: ots-blob-move-1 20s ease-in-out infinite alternate !important; +} + +.ots-login-blob--2 { + width: 420px; + height: 420px; + right: 6%; + bottom: 18%; + background: radial-gradient(circle at 60% 40%, rgba(255,138,0,0.45), rgba(255,138,0,0.14) 36%, transparent 55%); + animation: ots-blob-move-2 26s ease-in-out infinite alternate !important; +} + +.ots-login-blob--3 { + width: 360px; + height: 360px; + left: 18%; + bottom: -4%; + background: radial-gradient(circle at 40% 60%, rgba(94,200,255,0.28), rgba(94,200,255,0.08) 40%, transparent 60%); + animation: ots-blob-move-3 22s ease-in-out infinite alternate !important; +} + +/* Disable other animations/transitions on the login page so only blobs animate */ +body.login-page *, +body.login-page *::before, +body.login-page *::after { + animation: none !important; + transition: none !important; +} + +/* Re-enable blob animations specifically (higher specificity) */ +.ots-login-blob, +.ots-login-blob--1, +.ots-login-blob--2, +.ots-login-blob--3 { + animation-play-state: running !important; +} + +@keyframes ots-blob-move-1 { + 0% { transform: translate3d(0,0,0) scale(1); opacity: 0.85; } + 100% { transform: translate3d(18px,26px,0) scale(1.06); opacity: 0.7; } +} + +@keyframes ots-blob-move-2 { + 0% { transform: translate3d(0,0,0) scale(1); opacity: 0.65; } + 100% { transform: translate3d(-28px,-18px,0) scale(1.08); opacity: 0.55; } +} + +@keyframes ots-blob-move-3 { + 0% { transform: translate3d(0,0,0) scale(1); opacity: 0.6; } + 100% { transform: translate3d(22px,-20px,0) scale(1.05); opacity: 0.5; } +} + + +/* EXTRA DEFENSIVE: ensure no white background shows through on long pages */ +html, body, #page-wrapper, .ots-shell, .ots-main, #content-wrapper, .content-wrapper, .ots-content, .page-content, .container, .container-fluid, .dashboard-page, .dashboard, .dashboard-card, .page { + background-color: var(--color-background) !important; + background-image: none !important; + background-repeat: no-repeat !important; + color: var(--color-text-primary) !important; + min-height: 100vh !important; +} + +/* Neutralise any inline styles or late-loaded styles that set white backgrounds */ +*[style] { + background-color: inherit !important; +} + +/* Provide a small utility to detect offending elements visually (useful while debugging) */ +body.debug-white-areas * { + outline: 1px solid rgba(255,0,0,0.04) !important; +} + +/* Strong fallback for modals/backdrops */ +.modal-backdrop, .modal, .modal-open, .ots-footer, .page-footer { + background-color: var(--color-background) !important; +} + +/* Ensure the root html background is also forced dark at the highest level */ +html[style], body[style] { + background-color: var(--color-background) !important; +} + diff --git a/custom/otssignange/css/override.css b/custom/otssignange/css/override.css index 146aa54..8d8a504 100644 --- a/custom/otssignange/css/override.css +++ b/custom/otssignange/css/override.css @@ -64,6 +64,65 @@ body { max-width: 100vw !important; } +/* Minimal mapping so `.dashboard-card` inherits the visual treatment used by widgets/panels. + This allows collapsing one level of DOM without visual regressions. */ +/* Consolidated dashboard-card styling (now in CONSOLIDATED CARD/PANEL/WIDGET section) */ + + +/* Floating menus that are moved to body to escape overflowed containers */ +.ots-floating-menu { + border-radius: 8px; + box-shadow: 0 8px 24px rgba(2,6,23,0.6); + background-color: var(--color-surface); + border: 1px solid var(--color-border); + overflow: visible !important; + position: fixed !important; + transform: none !important; +} + +/* Elevated z-index for menus so they render above other panels */ +.ots-floating-menu, .dropdown-menu { + z-index: 99999 !important; +} + +/* Force ALL dropdown/context menu classes to render on top of everything. + This is maximally aggressive to defeat any stacking context or overflow clipping. */ +.dropdown-menu, +.ots-notif-menu, +.ots-user-menu, +.context-menu, +.row-menu, +.rowMenu, +.menu-popover, +.dataTables_buttons .dropdown-menu, +.ots-floating-menu { + position: fixed !important; + z-index: 2147483647 !important; + transform: none !important; + will-change: auto !important; + pointer-events: auto !important; + visibility: visible !important; + display: block !important; + opacity: 1 !important; +} + +/* Ensure dropdown list items and children aren't clipped */ +.dropdown-menu li, +.dropdown-menu > li > a, +.dropdown-menu > li > span, +.dropdown-menu ul, +.dropdown-menu div { + overflow: visible !important; + position: relative !important; + z-index: 2147483647 !important; +} + +/* Remove any transform or overflow from menu ancestors that could create stacking context */ +.dropdown-menu *, +.ots-floating-menu * { + transform: none !important; +} + /* Light/dark mode toggle */ #ots-theme-toggle { display: flex !important; @@ -2602,53 +2661,66 @@ body.ots-sidebar-open .ots-topbar { flex-direction: column; } -/* Force Xibo panels/cards to dark theme (use higher specificity to override core styles) */ -body .panel, -body .panel.panel-default, +/* ============================================================================ + CONSOLIDATED CARD/PANEL/WIDGET STYLING (Simplified, single source of truth) + ============================================================================ */ + +.card, +.panel, +.panel.panel-default, .panel.panel-white, -.panel.card, .panel.box, -.widget { +.widget, +.dashboard-card { background-color: var(--color-surface) !important; color: var(--color-text-primary) !important; border: 1px solid var(--color-border) !important; + border-radius: 6px; + display: flex; + flex-direction: column; + overflow: hidden; } -body .panel .panel-body, -body .panel .panel-footer, -body .panel .panel-heading, -.panel .panel-header { - background-color: transparent !important; - color: var(--color-text-primary) !important; - border-color: var(--color-border) !important; -} - -body .panel .panel-heading, -.panel .panel-header { +/* Unified header/heading styling */ +.panel-heading, +.panel-header, +.widget-title, +.card-header, +.dashboard-card-header { background-color: var(--color-surface-elevated) !important; border-bottom: 1px solid var(--color-border) !important; + color: var(--color-text-primary) !important; + padding: 12px 16px; + font-weight: 600; } -/* Tables inside panels should inherit dark background and readable text */ +/* Unified body styling */ +.panel-body, +.panel-footer, +.widget-body, +.card-body, +.dashboard-card-body { + flex: 1; + padding: 12px 16px; + color: var(--color-text-primary) !important; + background-color: transparent !important; + min-height: 0; +} + +/* Tables inside cards inherit styling */ .panel table, .panel table thead, .panel table tbody, .panel table tr, .panel table td, .panel table th, -.panel .dataTables_wrapper { +.panel .dataTables_wrapper, +.card .dataTables_wrapper, +.widget .dataTables_wrapper { background-color: transparent !important; color: var(--color-text-primary) !important; } -/* Card-specific fallbacks */ -.card, -.card .card-body { - background-color: var(--color-surface) !important; - color: var(--color-text-primary) !important; - border: 1px solid var(--color-border) !important; -} - .panel-full { grid-column: 1 / -1; } @@ -3116,8 +3188,57 @@ body .panel .panel-heading, box-shadow: 0 6px 16px rgba(8, 15, 30, 0.25); } +/* ============================================================================ + SIMPLIFIED TABLE & DATATABLES STYLING (Consolidated) + ============================================================================ */ + +.table, +.table > thead > tr > th, +.table > tbody > tr > td { + color: var(--color-text-primary) !important; + border-color: var(--color-border) !important; + background-color: transparent !important; +} + +.table-striped > tbody > tr:nth-of-type(odd) { + background-color: rgba(79, 140, 255, 0.04) !important; +} + +.table-hover > tbody > tr:hover { + background-color: rgba(79, 140, 255, 0.08) !important; + color: var(--color-text-primary) !important; +} + +/* Selected rows */ +.table tbody tr.selected, +.table tbody tr.dt-row-selected { + background-color: rgba(16, 185, 129, 0.25) !important; + color: var(--color-text-primary) !important; +} + +/* DataTables controls */ +.dataTables_wrapper { + color: var(--color-text-primary) !important; +} + +.dataTables_wrapper .dataTables_length, +.dataTables_wrapper .dataTables_filter, +.dataTables_wrapper .dataTables_info, +.dataTables_wrapper .dataTables_paginate { + color: var(--color-text-secondary) !important; +} + +.dataTables_wrapper .dataTables_filter input, +.dataTables_wrapper .dataTables_length select { + background-color: var(--color-surface) !important; + color: var(--color-text-primary) !important; + border: 1px solid var(--color-border) !important; + padding: 4px 8px; + border-radius: 4px; +} + +/* OTS table card (inherits from consolidated .card rule above) */ .ots-table-card { - padding: 12px 16px 16px; flex: 1; min-width: 0; overflow: hidden; @@ -3259,7 +3380,6 @@ body.ots-light-mode .ots-table-toolbar .btn-primary { color: #e2e8f0 !important; border: 1px solid rgba(148, 163, 184, 0.25) !important; } - /* Extra specificity for Xibo Displays DataTable */ .ots-displays-page #datatable-container .XiboData .table, .ots-displays-page #datatable-container .XiboData table.dataTable { diff --git a/custom/otssignange/views/dashboard-status-page.twig b/custom/otssignange/views/dashboard-status-page.twig index bb28218..132050f 100644 --- a/custom/otssignange/views/dashboard-status-page.twig +++ b/custom/otssignange/views/dashboard-status-page.twig @@ -524,6 +524,22 @@ } } + // Set Chart.js default font/color from CSS variables so charts match theme + (function(){ + try { + var root = getComputedStyle(document.documentElement); + var cssColor = root.getPropertyValue('--ots-text') || root.getPropertyValue('--color-text-primary') || root.getPropertyValue('--color-text'); + cssColor = (cssColor || '').trim() || '#ffffff'; + if (window.Chart && Chart.defaults) { + // Chart.js v3+ uses Chart.defaults.color + if (typeof Chart.defaults.color !== 'undefined') Chart.defaults.color = cssColor; + // Backwards compatibility for older Chart.js + if (Chart.defaults.global) Chart.defaults.global.defaultFontColor = cssColor; + if (Chart.defaults.font) Chart.defaults.font.color = cssColor; + } + } catch (e) { /* ignore */ } + })(); + var bandwidthChart = new Chart($("#bandwidthChart"), { type: "line", data: {{ bandwidthWidget|raw }}, diff --git a/custom/otssignange/views/daypart-page.twig b/custom/otssignange/views/daypart-page.twig index 18e2212..111614e 100644 --- a/custom/otssignange/views/daypart-page.twig +++ b/custom/otssignange/views/daypart-page.twig @@ -34,8 +34,8 @@

{% trans "Manage time-based scheduling rules." %}

-
-
+ {% embed 'custom/otssignange/views/partials/_dashboard-card.twig' with {'classes':'ots-displays-card'} %} + {% block body %}
@@ -81,8 +81,8 @@
-
-
+ {% endblock %} + {% endembed %}
{% endblock %} diff --git a/custom/otssignange/views/login.twig b/custom/otssignange/views/login.twig new file mode 100644 index 0000000..f47692c --- /dev/null +++ b/custom/otssignange/views/login.twig @@ -0,0 +1,120 @@ + + + + {{ theme.getThemeConfig("theme_title") }} + + + + + + + + + + + + + + + + + + + + + + + +
+ {% if authCASEnabled %} + + {% else %} + + {% endif %} + + {% if passwordReminderEnabled %} + + {% endif %} + + +
+ + + + + diff --git a/custom/otssignange/views/partials/_dashboard-card.twig b/custom/otssignange/views/partials/_dashboard-card.twig new file mode 100644 index 0000000..6d00954 --- /dev/null +++ b/custom/otssignange/views/partials/_dashboard-card.twig @@ -0,0 +1,20 @@ +{# + Reusable dashboard card partial. + Usage (embed to allow overriding the `body` block): + {% embed 'custom/otssignange/views/partials/_dashboard-card.twig' with {'classes':'ots-displays-card'} %} + {% block body %} + ... inner content ... + {% endblock %} + {% endembed %} +#} +
+ {% if title is defined and title %} +
+ {{ title|raw }} +
+ {% endif %} + +
+ {% block body %}{% endblock %} +
+
diff --git a/custom/otssignange/views/theme-scripts.twig b/custom/otssignange/views/theme-scripts.twig index c28f0e4..1c4436d 100644 --- a/custom/otssignange/views/theme-scripts.twig +++ b/custom/otssignange/views/theme-scripts.twig @@ -335,8 +335,21 @@ dropdown.addEventListener('click', function(e) { if (e.target.closest('.user-btn') || e.target.closest('[aria-label="User menu"]') || e.target.closest('#navbarUserMenu')) { e.preventDefault(); + const nowActive = !dropdown.classList.contains('active'); dropdown.classList.toggle('active'); + // If the dropdown has a menu, float it out of any overflowed container + try { + const ddMenu = dropdown.querySelector('.dropdown-menu'); + if (ddMenu) { + if (nowActive) { + floatMenu(ddMenu, dropdown); + } else { + unfloatMenu(ddMenu); + } + } + } catch (err) { /* ignore */ } + // If this dropdown contains the user menu, compute placement to avoid going off-screen const menu = dropdown.querySelector('.dropdown-menu.ots-user-menu'); const trigger = dropdown.querySelector('#navbarUserMenu'); @@ -368,12 +381,318 @@ // Close menu when clicking outside document.addEventListener('click', function(e) { if (!dropdown.contains(e.target)) { + const hasActive = dropdown.classList.contains('active'); dropdown.classList.remove('active'); + if (hasActive) { + try { const ddMenu = dropdown.querySelector('.dropdown-menu'); if (ddMenu) unfloatMenu(ddMenu); } catch (err) {} + } } }); }); } + /** + * Float a menu element into document.body so it can escape overflowed parents. + * Adds `.ots-floating-menu` and positions absolutely based on the trigger rect. + */ + function floatMenu(menuEl, triggerEl) { + if (!menuEl || !triggerEl || menuEl.getAttribute('data-ots-floating') === '1') return; + try { + // Remember original parent and next sibling so we can restore later + menuEl._otsOriginalParent = menuEl.parentNode || null; + menuEl._otsOriginalNext = menuEl.nextSibling || null; + menuEl.setAttribute('data-ots-floating', '1'); + menuEl.classList.add('ots-floating-menu'); + // Append to body + document.body.appendChild(menuEl); + + const rect = triggerEl.getBoundingClientRect(); + // Default placement below trigger, align to left edge + const top = Math.max(8, Math.round(rect.bottom + window.scrollY + 6)); + const left = Math.max(8, Math.round(rect.left + window.scrollX)); + + // Use fixed positioning so the menu floats above all stacking contexts + // Use fixed positioning so the menu floats above all stacking contexts + try { + menuEl.style.setProperty('position', 'fixed', 'important'); + menuEl.style.setProperty('top', Math.max(6, Math.round(rect.bottom + 6)) + 'px', 'important'); + menuEl.style.setProperty('left', left + 'px', 'important'); + // Use the maximum reasonable z-index to ensure it appears on top + menuEl.style.setProperty('z-index', '2147483647', 'important'); + // Ensure transforms won't clip rendering + menuEl.style.setProperty('transform', 'none', 'important'); + menuEl.style.setProperty('min-width', (rect.width) + 'px', 'important'); + menuEl.style.setProperty('pointer-events', 'auto', 'important'); + } catch (err) { + // fallback to non-important inline style + menuEl.style.position = 'fixed'; + menuEl.style.top = Math.max(6, Math.round(rect.bottom + 6)) + 'px'; + menuEl.style.left = left + 'px'; + menuEl.style.zIndex = '2147483647'; + menuEl.style.transform = 'none'; + menuEl.style.minWidth = (rect.width) + 'px'; + menuEl.style.pointerEvents = 'auto'; + } + + // Reposition on scroll/resize while open + const reposition = function() { + if (menuEl.getAttribute('data-ots-floating') !== '1') return; + const r = triggerEl.getBoundingClientRect(); + // For fixed positioning we only need viewport coords + menuEl.style.top = Math.max(6, Math.round(r.bottom + 6)) + 'px'; + menuEl.style.left = Math.max(6, Math.round(r.left)) + 'px'; + }; + menuEl._otsReposition = reposition; + window.addEventListener('scroll', reposition, true); + window.addEventListener('resize', reposition); + + // Guard: some libraries move/drop menus. Keep a short-lived guard that + // re-attaches the menu to body and re-applies important styles while open. + let guardCount = 0; + const guard = setInterval(function() { + try { + if (menuEl.getAttribute('data-ots-floating') !== '1') { + clearInterval(guard); + return; + } + // If parent moved, re-append to body + if (menuEl.parentNode !== document.body) document.body.appendChild(menuEl); + // Re-ensure important styles + menuEl.style.setProperty('z-index', '2147483647', 'important'); + menuEl.style.setProperty('position', 'fixed', 'important'); + } catch (err) {} + guardCount += 1; + if (guardCount > 120) { + clearInterval(guard); + } + }, 100); + menuEl._otsGuard = guard; + } catch (err) { + console.warn('[OTS] floatMenu failed', err); + } + } + + function unfloatMenu(menuEl) { + if (!menuEl || menuEl.getAttribute('data-ots-floating') !== '1') return; + try { + menuEl.removeAttribute('data-ots-floating'); + menuEl.classList.remove('ots-floating-menu'); + menuEl.style.position = ''; + menuEl.style.top = ''; + menuEl.style.left = ''; + menuEl.style.zIndex = ''; + menuEl.style.minWidth = ''; + menuEl.style.pointerEvents = ''; + // Remove reposition listeners + if (menuEl._otsReposition) { + window.removeEventListener('scroll', menuEl._otsReposition, true); + window.removeEventListener('resize', menuEl._otsReposition); + delete menuEl._otsReposition; + } + // Attempt to restore the original parent and insertion point + try { + if (menuEl._otsOriginalParent) { + if (menuEl._otsOriginalNext && menuEl._otsOriginalNext.parentNode === menuEl._otsOriginalParent) { + menuEl._otsOriginalParent.insertBefore(menuEl, menuEl._otsOriginalNext); + } else { + menuEl._otsOriginalParent.appendChild(menuEl); + } + delete menuEl._otsOriginalParent; + delete menuEl._otsOriginalNext; + } else { + // fallback: append to body (leave it there) + document.body.appendChild(menuEl); + } + } catch (err) { + document.body.appendChild(menuEl); + } + } catch (err) { + console.warn('[OTS] unfloatMenu failed', err); + } + } + + /** + * Observe document for dynamically added dropdown menus and float them when necessary. + */ + function observeAndFloatMenus() { + try { + const mo = new MutationObserver(function(muts) { + muts.forEach(function(m) { + (m.addedNodes || []).forEach(function(node) { + try { + if (!node || node.nodeType !== 1) return; + // If the node itself is a dropdown menu + if (node.classList && node.classList.contains('dropdown-menu')) { + attachIfNeeded(node); + } + // Or contains dropdown menus + const menus = node.querySelectorAll && node.querySelectorAll('.dropdown-menu'); + if (menus && menus.length) { + menus.forEach(attachIfNeeded); + } + } catch (err) {} + }); + }); + }); + mo.observe(document.body, { childList: true, subtree: true }); + // keep alive for the lifetime of the page + } catch (err) { + // ignore + } + + function attachIfNeeded(menu) { + try { + if (!menu || menu.getAttribute('data-ots-floating-obs') === '1') return; + menu.setAttribute('data-ots-floating-obs', '1'); + // find a reasonable trigger element: aria-labelledby or previous element + let trigger = null; + const labelled = menu.getAttribute('aria-labelledby'); + if (labelled) trigger = document.getElementById(labelled); + if (!trigger) trigger = menu._otsOriginalParent ? menu._otsOriginalParent.querySelector('[data-toggle="dropdown"]') : null; + if (!trigger) trigger = menu.previousElementSibling || null; + // If the menu is visible and inside an overflowed ancestor, float it + const rect = menu.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) return; // not rendered yet + if (isClippedByOverflow(menu) && trigger) { + floatMenu(menu, trigger); + } + // Also watch for when dropdown gets toggled active via class + const obs = new MutationObserver(function(ms) { + ms.forEach(function(mm) { + if (mm.type === 'attributes' && mm.attributeName === 'class') { + const isActive = menu.classList.contains('show') || menu.parentNode && menu.parentNode.classList.contains('active'); + if (isActive && trigger) floatMenu(menu, trigger); + if (!isActive) unfloatMenu(menu); + } + }); + }); + obs.observe(menu, { attributes: true, attributeFilter: ['class'] }); + } catch (err) {} + } + + function isClippedByOverflow(el) { + let p = el.parentElement; + while (p && p !== document.body) { + const s = window.getComputedStyle(p); + if (/(hidden|auto|scroll)/.test(s.overflow + s.overflowY + s.overflowX)) { + const r = el.getBoundingClientRect(); + const pr = p.getBoundingClientRect(); + // if element overflows parent's rect then it's clipped + if (r.bottom > pr.bottom || r.top < pr.top || r.left < pr.left || r.right > pr.right) return true; + } + p = p.parentElement; + } + return false; + } + } + + /** + * Force common menu classes to the top by moving them to body and keeping them there. + * This is the most aggressive approach to ensure menus are never clipped. + */ + function forceTopMenus() { + const selectors = ['.dropdown-menu', '.ots-notif-menu', '.ots-user-menu', '.context-menu', '.row-menu', '.rowMenu', '.menu-popover']; + + function moveToBody(el) { + try { + if (!el || el.getAttribute('data-ots-moved-to-body') === '1') return; + + // Store original parent info + el._otsOriginalParent = el.parentElement; + el._otsOriginalNextSibling = el.nextElementSibling; + + el.setAttribute('data-ots-moved-to-body', '1'); + + // Force fixed positioning with maximum z-index + el.style.setProperty('position', 'fixed', 'important'); + el.style.setProperty('z-index', '2147483647', 'important'); + el.style.setProperty('transform', 'none', 'important'); + el.style.setProperty('pointer-events', 'auto', 'important'); + el.style.setProperty('visibility', 'visible', 'important'); + el.style.setProperty('display', 'block', 'important'); + el.style.setProperty('opacity', '1', 'important'); + el.style.setProperty('clip-path', 'none', 'important'); + + // Move to body if not already there + if (el.parentElement !== document.body) { + document.body.appendChild(el); + } + } catch (err) { + console.warn('[OTS] moveToBody failed', err); + } + } + + function applyMenuStyles(el) { + try { + if (!el) return; + el.style.setProperty('position', 'fixed', 'important'); + el.style.setProperty('z-index', '2147483647', 'important'); + el.style.setProperty('transform', 'none', 'important'); + el.style.setProperty('pointer-events', 'auto', 'important'); + } catch (err) {} + } + + // Apply to existing menus immediately + selectors.forEach(sel => { + document.querySelectorAll(sel).forEach(el => { + moveToBody(el); + applyMenuStyles(el); + }); + }); + + // Continuously guard: check that menus stay in body and have correct styles + let guardInterval = setInterval(function() { + try { + selectors.forEach(sel => { + document.querySelectorAll(sel).forEach(el => { + // If menu got moved back, move it to body again + if (el.parentElement !== document.body && el.parentElement !== null) { + document.body.appendChild(el); + } + // Reapply critical styles in case they got overridden + applyMenuStyles(el); + }); + }); + } catch (err) {} + }, 200); + + // Keep guard alive for the page lifetime, but stop if no menus found after 30s + let noMenuCount = 0; + const checkGuard = setInterval(function() { + const hasMenus = selectors.some(sel => document.querySelector(sel)); + if (!hasMenus) { + noMenuCount++; + if (noMenuCount > 150) { + clearInterval(guardInterval); + clearInterval(checkGuard); + } + } else { + noMenuCount = 0; + } + }, 200); + + // Observe for dynamically added menus + try { + const mo = new MutationObserver(function(muts) { + muts.forEach(m => { + (m.addedNodes || []).forEach(node => { + try { + if (!node || node.nodeType !== 1) return; + selectors.forEach(sel => { + if (node.matches && node.matches(sel)) { + moveToBody(node); + } + const found = node.querySelectorAll && node.querySelectorAll(sel); + found && found.forEach(moveToBody); + }); + } catch (err) {} + }); + }); + }); + mo.observe(document.body, { childList: true, subtree: true }); + } catch (err) {} + } + /** * Initialize search functionality */ @@ -805,6 +1124,7 @@ updateSidebarWidth(); updateSidebarNavOffset(); // updateSidebarGap() disabled - use CSS variables instead + initUserProfileQrFix(); var debouncedUpdate = debounce(function() { updateSidebarNavOffset(); updateSidebarWidth(); @@ -820,3 +1140,82 @@ init(); } })(); + +// Replace broken QR images in user profile modals with a friendly placeholder +function initUserProfileQrFix() { + function replaceIfEmptyDataUri(el) { + try { + if (!el || el.tagName !== 'IMG') return false; + if (!el.closest || !el.closest('.modal, .modal-dialog')) return false; + var src = el.getAttribute('src') || ''; + // matches empty/invalid data uri like "data:image/png;base64," or very short payloads + if (/^data:image\/[a-zA-Z0-9.+-]+;base64,\s*$/.test(src) || (src.indexOf('data:image') === 0 && src.split(',')[1] && src.split(',')[1].length < 10)) { + console.warn('[OTS] Replacing empty data URI for QR image inside modal:', src); + var svg = 'data:image/svg+xml;utf8,' + encodeURIComponent( + '' + + '' + + 'QR unavailable' + + '' + ); + if (el.getAttribute('data-ots-replaced') === '1') return true; + el.setAttribute('data-ots-replaced', '1'); + el.src = svg; + el.alt = 'QR code unavailable'; + var parent = el.parentNode; + if (parent && !parent.querySelector('.ots-qr-note')) { + var p = document.createElement('p'); + p.className = 'ots-qr-note text-muted'; + p.style.marginTop = '6px'; + p.textContent = 'QR failed to load. Close and re-open the Edit Profile dialog to retry.'; + parent.appendChild(p); + } + return true; + } + } catch (err) { + console.error('[OTS] replaceIfEmptyDataUri error', err); + } + return false; + } + + // Initial quick scan for any modal images already present + try { + var imgs = document.querySelectorAll('.modal img, .modal-dialog img'); + imgs.forEach(function(i) { replaceIfEmptyDataUri(i); }); + } catch (e) {} + + // Observe DOM for modals being added (some UIs load modal content via AJAX) + try { + var mo = new MutationObserver(function(muts) { + muts.forEach(function(m) { + m.addedNodes && m.addedNodes.forEach(function(node) { + try { + if (!node) return; + if (node.nodeType === 1) { + if (node.matches && node.matches('.modal, .modal-dialog')) { + var imgs = node.querySelectorAll('img'); + imgs.forEach(function(i) { replaceIfEmptyDataUri(i); }); + } else { + var imgs = node.querySelectorAll && node.querySelectorAll('img'); + imgs && imgs.forEach(function(i) { replaceIfEmptyDataUri(i); }); + } + } + } catch (err) {} + }); + }); + }); + mo.observe(document.body, { childList: true, subtree: true }); + // stop observing after 20s to avoid long-lived observers in older pages + setTimeout(function() { try { mo.disconnect(); } catch (e) {} }, 20000); + } catch (err) {} + + // Also a short polling fallback for dynamic UIs for the first 6s + var checks = 0; + var interval = setInterval(function() { + try { + var imgs = document.querySelectorAll('.modal img, .modal-dialog img'); + imgs.forEach(function(i) { replaceIfEmptyDataUri(i); }); + } catch (e) {} + checks += 1; + if (checks > 12) clearInterval(interval); + }, 500); +}