feat: Enhance dark mode styling and improve dropdown menu behavior for better user experience
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }},
|
||||
|
||||
@@ -34,8 +34,8 @@
|
||||
<p class="text-muted">{% trans "Manage time-based scheduling rules." %}</p>
|
||||
</div>
|
||||
|
||||
<div class="widget dashboard-card ots-displays-card">
|
||||
<div class="widget-body ots-displays-body">
|
||||
{% embed 'custom/otssignange/views/partials/_dashboard-card.twig' with {'classes':'ots-displays-card'} %}
|
||||
{% block body %}
|
||||
<div class="XiboGrid" id="{{ random() }}">
|
||||
<div class="XiboFilter card mb-3 bg-light dashboard-card ots-filter-card">
|
||||
<div class="ots-filter-header">
|
||||
@@ -81,8 +81,8 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
120
custom/otssignange/views/login.twig
Normal file
120
custom/otssignange/views/login.twig
Normal file
@@ -0,0 +1,120 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{ theme.getThemeConfig("theme_title") }}</title>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="token" content="{{ csrfToken }}"/>
|
||||
<meta name="public-path" content="{{ theme.rootUri() }}"/>
|
||||
<link rel="shortcut icon" href="{{ theme.uri("img/favicon.ico") }}" />
|
||||
<!-- Import CSS bundle from dist -->
|
||||
<script src="{{ theme.rootUri() }}dist/style.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
|
||||
|
||||
<!-- Minimal inline adjustments (layout only) -->
|
||||
<style type="text/css">
|
||||
html { font-size: 14px; }
|
||||
body { padding-top: 40px !important; padding-bottom: 40px !important; font-size: 1rem; }
|
||||
</style>
|
||||
<!-- Import user made CSS from theme -->
|
||||
<link href="{{ theme.uri("css/override.css") }}?{{ version }}" rel="stylesheet" media="screen">
|
||||
<link href="{{ theme.uri("css/override-dark.css") }}?{{ version }}" rel="stylesheet" media="screen">
|
||||
</head>
|
||||
<body class="login-page">
|
||||
<!-- Fallback animated background element (inline styles ensure it appears even if external CSS is cached) -->
|
||||
<div class="ots-login-bg" aria-hidden="true"></div>
|
||||
<!-- Animated blurred color blobs -->
|
||||
<div class="ots-login-blob ots-login-blob--1" aria-hidden="true"></div>
|
||||
<div class="ots-login-blob ots-login-blob--2" aria-hidden="true"></div>
|
||||
<div class="ots-login-blob ots-login-blob--3" aria-hidden="true"></div>
|
||||
<style>
|
||||
.ots-login-bg{position:fixed;inset:0;z-index:0;pointer-events:none;filter:blur(20px);opacity:0.95;
|
||||
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-size:200% 200%,100% 100%,100% 100%;
|
||||
animation: ots-login-bg-shift-inline 14s linear infinite;}
|
||||
@keyframes ots-login-bg-shift-inline{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}}
|
||||
/* Ensure login card sits above fallback background */
|
||||
.login-card{position:relative;z-index:2}
|
||||
</style>
|
||||
<div class="container">
|
||||
{% if authCASEnabled %}
|
||||
<form id="cas-login-form" class="login-card text-center" action="{{ url_for("cas.login") }}" method="post">
|
||||
{% for priorRoute in flash('priorRoute') %}
|
||||
<input name="priorRoute" type="hidden" value="{{ priorRoute }}" />
|
||||
{% endfor %}
|
||||
<p class="login-brand"><img alt="Logo" class="login-logo" src="{{ theme.uri("img/xibologo.png") }}"><span class="login-brand-text">OTS Signs</span></p>
|
||||
|
||||
<p>{% trans %}Connect with the Central Authentication Server{% endtrans %}</p>
|
||||
|
||||
{% for loginMessage in flash('cas_login_message') %}
|
||||
<div class="alert alert-danger">{{ loginMessage }}</div>
|
||||
{% endfor %}
|
||||
|
||||
<p><button class="btn btn-signin" type="submit" name="logincas">{% trans %} CAS Login{% endtrans %}</button></p>
|
||||
</form>
|
||||
{% else %}
|
||||
<form id="login-form" class="login-card text-center" action="{{ url_for("login") }}" method="post">
|
||||
{% for priorRoute in flash('priorRoute') %}
|
||||
<input name="priorRoute" type="hidden" value="{{ priorRoute }}" />
|
||||
{% endfor %}
|
||||
<input type="hidden" name="{{ csrfKey }}" value="{{ csrfToken }}" />
|
||||
<p class="login-brand"><a href="{{ theme.getThemeConfig("theme_url") }}"><img class="login-logo" src="{{ theme.uri("img/xibologo.png") }}"></a><span class="login-brand-text">OTS Signs</span></p>
|
||||
|
||||
<p class="lead">{% trans "Please provide your credentials" %}</p>
|
||||
|
||||
<input id="username" class="form-control input-block-level" name="username" type="text" placeholder="{% trans "User" %}" autofocus autocomplete="username">
|
||||
<input id="password" class="form-control input-block-level" name="password" type="password" placeholder="{% trans "Password" %}" autocomplete="current-password">
|
||||
|
||||
{% for loginMessage in flash('login_message') %}
|
||||
<div class="alert alert-danger">{{ loginMessage }}</div>
|
||||
{% endfor %}
|
||||
|
||||
<p><button class="btn btn-signin" type="submit">{% trans "Login" %}</button></p>
|
||||
|
||||
{% if passwordReminderEnabled %}<p><a href="#" id="reminder-form-toggle">{% trans "Forgotten your password?" %}</a></p>{% endif %}
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if passwordReminderEnabled %}
|
||||
<form id="reminder-form" class="login-card text-center d-none" action="{{ url_for("login.forgotten") }}" method="post">
|
||||
<input type="hidden" name="{{ csrfKey }}" value="{{ csrfToken }}" />
|
||||
<p class="login-brand"><a href="{{ theme.getThemeConfig("theme_url") }}"><img class="login-logo" src="{{ theme.uri("img/xibologo.png") }}"></a><span class="login-brand-text">OTS Signs</span></p>
|
||||
|
||||
<p>{% trans "Please provide your user name" %}</p>
|
||||
<input id="username" class="form-control input-block-level" name="username" type="text" placeholder="{% trans "User" %}">
|
||||
|
||||
{% for loginMessage in flash('login_message') %}
|
||||
<div class="alert alert-danger">{{ loginMessage }}</div>
|
||||
{% endfor %}
|
||||
|
||||
<p><button class="btn btn-signin" type="submit">{% trans "Send Reset" %}</button></p>
|
||||
|
||||
<p><a href="#" id="login-form-toggle">{% trans "Login instead?" %}</a></p>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
|
||||
</div> <!-- /container -->
|
||||
<!-- Import JS bundle from dist -->
|
||||
<script src="{{ theme.rootUri() }}dist/vendor.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
|
||||
<script type="text/javascript" nonce="{{ cspNonce }}">
|
||||
$(function() {
|
||||
$("#reminder-form-toggle").on("click", function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
$("#login-form").addClass("d-none");
|
||||
$("#reminder-form").removeClass("d-none");
|
||||
});
|
||||
|
||||
$("#login-form-toggle").on("click", function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
$("#login-form").removeClass("d-none");
|
||||
$("#reminder-form").addClass("d-none");
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
20
custom/otssignange/views/partials/_dashboard-card.twig
Normal file
20
custom/otssignange/views/partials/_dashboard-card.twig
Normal file
@@ -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 %}
|
||||
#}
|
||||
<div class="dashboard-card {{ classes|default('') }}" {% if id is defined %}id="{{ id }}"{% endif %}>
|
||||
{% if title is defined and title %}
|
||||
<div class="dashboard-card-header">
|
||||
{{ title|raw }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="dashboard-card-body">
|
||||
{% block body %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160">'
|
||||
+ '<rect width="100%" height="100%" fill="#213041"/>'
|
||||
+ '<text x="50%" y="50%" fill="#9fb1c8" font-family="Arial,Helvetica,sans-serif" font-size="14" text-anchor="middle" dy=".3em">QR unavailable</text>'
|
||||
+ '</svg>'
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user