feat: Enhance dark mode styling and improve dropdown menu behavior for better user experience

This commit is contained in:
Matt Batchelder
2026-02-06 23:57:16 -05:00
parent 87a444b8de
commit edd112fec3
7 changed files with 1094 additions and 32 deletions

View File

@@ -398,6 +398,40 @@ hr {
} }
/* Navigation Icons and Text */ /* 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 { .ots-nav-icon {
width: 16px; width: 16px;
height: 16px; height: 16px;
@@ -785,6 +819,29 @@ textarea:focus {
border-top: 1px solid var(--color-border) !important; 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 HELP PANE / MISC
============================================================================= */ ============================================================================= */
@@ -824,3 +881,333 @@ textarea:focus {
font-size: 13px; font-size: 13px;
color: var(--ots-text-muted); 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;
}

View File

@@ -64,6 +64,65 @@ body {
max-width: 100vw !important; 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 */ /* Light/dark mode toggle */
#ots-theme-toggle { #ots-theme-toggle {
display: flex !important; display: flex !important;
@@ -2602,53 +2661,66 @@ body.ots-sidebar-open .ots-topbar {
flex-direction: column; flex-direction: column;
} }
/* Force Xibo panels/cards to dark theme (use higher specificity to override core styles) */ /* ============================================================================
body .panel, CONSOLIDATED CARD/PANEL/WIDGET STYLING (Simplified, single source of truth)
body .panel.panel-default, ============================================================================ */
.card,
.panel,
.panel.panel-default,
.panel.panel-white, .panel.panel-white,
.panel.card,
.panel.box, .panel.box,
.widget { .widget,
.dashboard-card {
background-color: var(--color-surface) !important; background-color: var(--color-surface) !important;
color: var(--color-text-primary) !important; color: var(--color-text-primary) !important;
border: 1px solid var(--color-border) !important; border: 1px solid var(--color-border) !important;
border-radius: 6px;
display: flex;
flex-direction: column;
overflow: hidden;
} }
body .panel .panel-body, /* Unified header/heading styling */
body .panel .panel-footer, .panel-heading,
body .panel .panel-heading, .panel-header,
.panel .panel-header { .widget-title,
background-color: transparent !important; .card-header,
color: var(--color-text-primary) !important; .dashboard-card-header {
border-color: var(--color-border) !important;
}
body .panel .panel-heading,
.panel .panel-header {
background-color: var(--color-surface-elevated) !important; background-color: var(--color-surface-elevated) !important;
border-bottom: 1px solid var(--color-border) !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,
.panel table thead, .panel table thead,
.panel table tbody, .panel table tbody,
.panel table tr, .panel table tr,
.panel table td, .panel table td,
.panel table th, .panel table th,
.panel .dataTables_wrapper { .panel .dataTables_wrapper,
.card .dataTables_wrapper,
.widget .dataTables_wrapper {
background-color: transparent !important; background-color: transparent !important;
color: var(--color-text-primary) !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 { .panel-full {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
@@ -3116,8 +3188,57 @@ body .panel .panel-heading,
box-shadow: 0 6px 16px rgba(8, 15, 30, 0.25); 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 { .ots-table-card {
padding: 12px 16px 16px;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@@ -3259,7 +3380,6 @@ body.ots-light-mode .ots-table-toolbar .btn-primary {
color: #e2e8f0 !important; color: #e2e8f0 !important;
border: 1px solid rgba(148, 163, 184, 0.25) !important; border: 1px solid rgba(148, 163, 184, 0.25) !important;
} }
/* Extra specificity for Xibo Displays DataTable */ /* Extra specificity for Xibo Displays DataTable */
.ots-displays-page #datatable-container .XiboData .table, .ots-displays-page #datatable-container .XiboData .table,
.ots-displays-page #datatable-container .XiboData table.dataTable { .ots-displays-page #datatable-container .XiboData table.dataTable {

View File

@@ -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"), { var bandwidthChart = new Chart($("#bandwidthChart"), {
type: "line", type: "line",
data: {{ bandwidthWidget|raw }}, data: {{ bandwidthWidget|raw }},

View File

@@ -34,8 +34,8 @@
<p class="text-muted">{% trans "Manage time-based scheduling rules." %}</p> <p class="text-muted">{% trans "Manage time-based scheduling rules." %}</p>
</div> </div>
<div class="widget dashboard-card ots-displays-card"> {% embed 'custom/otssignange/views/partials/_dashboard-card.twig' with {'classes':'ots-displays-card'} %}
<div class="widget-body ots-displays-body"> {% block body %}
<div class="XiboGrid" id="{{ random() }}"> <div class="XiboGrid" id="{{ random() }}">
<div class="XiboFilter card mb-3 bg-light dashboard-card ots-filter-card"> <div class="XiboFilter card mb-3 bg-light dashboard-card ots-filter-card">
<div class="ots-filter-header"> <div class="ots-filter-header">
@@ -81,8 +81,8 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div> {% endblock %}
</div> {% endembed %}
</div> </div>
{% endblock %} {% endblock %}

View 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>

View 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>

View File

@@ -335,8 +335,21 @@
dropdown.addEventListener('click', function(e) { dropdown.addEventListener('click', function(e) {
if (e.target.closest('.user-btn') || e.target.closest('[aria-label="User menu"]') || e.target.closest('#navbarUserMenu')) { if (e.target.closest('.user-btn') || e.target.closest('[aria-label="User menu"]') || e.target.closest('#navbarUserMenu')) {
e.preventDefault(); e.preventDefault();
const nowActive = !dropdown.classList.contains('active');
dropdown.classList.toggle('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 // If this dropdown contains the user menu, compute placement to avoid going off-screen
const menu = dropdown.querySelector('.dropdown-menu.ots-user-menu'); const menu = dropdown.querySelector('.dropdown-menu.ots-user-menu');
const trigger = dropdown.querySelector('#navbarUserMenu'); const trigger = dropdown.querySelector('#navbarUserMenu');
@@ -368,12 +381,318 @@
// Close menu when clicking outside // Close menu when clicking outside
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
if (!dropdown.contains(e.target)) { if (!dropdown.contains(e.target)) {
const hasActive = dropdown.classList.contains('active');
dropdown.classList.remove('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 * Initialize search functionality
*/ */
@@ -805,6 +1124,7 @@
updateSidebarWidth(); updateSidebarWidth();
updateSidebarNavOffset(); updateSidebarNavOffset();
// updateSidebarGap() disabled - use CSS variables instead // updateSidebarGap() disabled - use CSS variables instead
initUserProfileQrFix();
var debouncedUpdate = debounce(function() { var debouncedUpdate = debounce(function() {
updateSidebarNavOffset(); updateSidebarNavOffset();
updateSidebarWidth(); updateSidebarWidth();
@@ -820,3 +1140,82 @@
init(); 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);
}