]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Feature] WebUI: Implement dark mode
authorAlexander Moisseev <moiseev@mezonplus.ru>
Sun, 2 Nov 2025 13:14:21 +0000 (16:14 +0300)
committerAlexander Moisseev <moiseev@mezonplus.ru>
Tue, 4 Nov 2025 07:07:38 +0000 (10:07 +0300)
interface/css/rspamd.css
interface/img/rspamd_logo_navbar_dark.png [new file with mode: 0644]
interface/index.html
interface/js/app/config.js
interface/js/app/rspamd.js
interface/js/main.js

index d23dddac1681857ff305a9fd24b7705887a0bfde..a43f1e5e1c31982f1a21c0ddf688e8614c59ab41 100644 (file)
@@ -31,6 +31,50 @@ THE SOFTWARE.
     /* Tweak bootstrap 5 colors for better accessibility */
     --bs-danger-rgb: 221, 0, 0;
     --bs-success-rgb: 40, 139, 69;
+
+    /* Light mode (default) colors */
+    --rspamd-bg-card-gradient-start: #f9f9f9;
+    --rspamd-bg-card-gradient-end: #ededed;
+    --rspamd-text-primary: #000000;
+    --rspamd-text-secondary: #666666;
+    --rspamd-nav-active-bg: #e7e7e7;
+    --rspamd-sidebar-bg: #ffffee;
+    --rspamd-symbol-description-color: #484848;
+    --rspamd-symbol-negative-bg: #eef9e7;
+    --rspamd-symbol-positive-bg: #fbe9e5;
+    --rspamd-symbol-special-bg: #e2e9fe;
+    --rspamd-symbol-hover-bg: #e6e6e6;
+    --rspamd-table-danger-bg: #fbe9e5;
+    --rspamd-table-success-bg: #eef9e7;
+    --rspamd-table-warning-bg: #fff8e6;
+    --rspamd-bs-table-bg: #f8f9fa;
+
+    /* Light mode logo display rules */
+    --rspamd-display-logo-light: inline;
+    --rspamd-display-logo-dark: none;
+}
+
+[data-theme="dark"] {
+    /* Dark mode colors */
+    --rspamd-bg-card-gradient-start: #2a2a2a;
+    --rspamd-bg-card-gradient-end: #252525;
+    --rspamd-text-primary: #cccccc;
+    --rspamd-text-secondary: #b0b0b0;
+    --rspamd-nav-active-bg: #404040;
+    --rspamd-sidebar-bg: #444422;
+    --rspamd-symbol-description-color: #848484;
+    --rspamd-symbol-negative-bg: #1a3a1a;
+    --rspamd-symbol-positive-bg: #3a1a1a;
+    --rspamd-symbol-special-bg: #3a3a5a;
+    --rspamd-symbol-hover-bg: #000000;
+    --rspamd-table-danger-bg: #390b00;
+    --rspamd-table-success-bg: #102d00;
+    --rspamd-table-warning-bg: #43380b;
+    --rspamd-bs-table-bg: #272b2f;
+
+    /* Dark mode logo display rules */
+    --rspamd-display-logo-light: none;
+    --rspamd-display-logo-dark: inline;
 }
 
 /* bootstrap 4 overrides */
@@ -45,25 +89,24 @@ small,
     font-size: 85%;
 }
 .text-secondary {
-    color: #666 !important;
+    color: var(--rspamd-text-secondary) !important;
 }
 .navbar {
     padding-top: 0;
     padding-bottom: 0;
     margin-bottom: 20px;
-    border-bottom: 1px solid rgb(231 231 231);
 }
 .nav-pills .nav-link.active {
-    background-color: #e7e7e7;
+    background-color: var(--rspamd-nav-active-bg);
 }
 .danger > td {
-    background-color: #fbe9e5;
+    background-color: var(--rspamd-table-danger-bg);
 }
 .success > td {
-    background-color: #eef9e7;
+    background-color: var(--rspamd-table-success-bg);
 }
 td.warning {
-    background-color: #fff8e6;
+    background-color: var(--rspamd-table-warning-bg);
 }
 @media (max-width: 1199px) {
     .navbar-collapse.order-3 {
@@ -140,10 +183,66 @@ textarea {
     border-bottom-right-radius:4px
 }
 
+/* FooTable dark mode */
+[data-theme="dark"] .footable .pagination .footable-page-link,
+[data-theme="dark"] .footable .form-control {
+    color: #aaaaaa;
+    background-color: var(--bs-body-bg);
+    border-color: var(--bs-border-color)
+}
+[data-theme="dark"] .footable .btn-primary {
+    color: #ffffff;
+    background-color: #0d6efd;
+    border-color: #0d6efd;
+}
+[data-theme="dark"] .footable .btn-primary:hover {
+  background-color: #0b5ed7;
+  border-color: #0a58ca;
+}
+[data-theme="dark"] .footable .btn-default {
+    color: #ffffff;
+    background-color: #6c757d;
+    border-color: #6c757d;
+}
+[data-theme="dark"] .footable .btn-default:hover,
+[data-theme="dark"] .footable .open > .dropdown-toggle.btn-default {
+    background-color: #5c636a;
+    border-color: #565e64;
+}
+
+[data-theme="dark"] .footable .dropdown-menu {
+    background-color: var(--bs-body-bg);
+    border-color: var(--bs-dropdown-border-color);
+}
+[data-theme="dark"] .footable .dropdown-menu > li > a {
+    color: #aaaaaa;
+}
+[data-theme="dark"] .footable .dropdown-menu > li > a:hover {
+    background-color: var(--bs-dropdown-link-hover-bg);
+}
+
+[data-theme="dark"] .footable .pagination > li a:hover {
+    background-color: var(--bs-nav-link-disabled-color);
+    border-color: var(--bs-border-color);
+}
+[data-theme="dark"] .footable .pagination .footable-page-link:hover {
+    color: var(--bs-nav-link-hover-color);
+}
+[data-theme="dark"] .footable .pagination .footable-page.active .footable-page-link {
+    background-color: var(--rspamd-nav-active-bg);
+    color: var(--rspamd-text-primary);
+}
+
 /* local overrides */
 .navbar-brand > img {
     height: 50px;
 }
+.logo-light {
+    display: var(--rspamd-display-logo-light);
+}
+.logo-dark {
+    display: var(--rspamd-display-logo-dark);
+}
 .btn-group > .btn.radius-right {
     border-top-right-radius: var(--bs-btn-border-radius) !important;
     border-bottom-right-radius: var(--bs-btn-border-radius) !important;
@@ -173,7 +272,6 @@ table#symbolsTable input[type="number"] {
 }
 .alert {
     margin-bottom: 4px;
-    color: #c09853;
 }
 .alert.alert-modal {
     top: 0;
@@ -182,17 +280,20 @@ table#symbolsTable input[type="number"] {
     display: inline-block;
     padding-left: 35px;
 }
-.alert-success {
+[data-theme="light"] .alert-success {
     color: #468847;
     background: #dff0d8;
     border-color: #d6e9c6;
 }
-.alert-danger {
+[data-theme="light"] .alert-danger {
     color: #b94a48;
     background: #f2dede;
     border-color: #eed3d7;
 }
-.alert-info {
+[data-theme="light"] .alert-warning {
+    color: #c09853;
+}
+[data-theme="light"] .alert-info {
     color: #3a87ad;
     background: #d9edf7;
     border-color: #bce8f1;
@@ -207,16 +308,14 @@ table#symbolsTable input[type="number"] {
 
 .card-header,
 .modal-header {
-    background-color: #f3f3f3;
-    background-image: linear-gradient(to bottom, #fdfdfd, #eaeaea);
+    background-image: linear-gradient(to bottom, var(--rspamd-bg-card-gradient-start), var(--rspamd-bg-card-gradient-end));
 }
 .card-header .h6 {
     font-size: 0.857rem;
 }
 
 .stat-box {
-    background-color: #f3f3f3;
-    background-image: linear-gradient(to bottom, #f9f9f9, #ededed);
+    background-image: linear-gradient(to bottom, var(--rspamd-bg-card-gradient-start), var(--rspamd-bg-card-gradient-end));
     line-height: 1;
 }
 .stat-box:not(.float-end) {
@@ -236,25 +335,25 @@ table#symbolsTable input[type="number"] {
     padding-right: 2px;
 }
 .symbol-default:hover {
-    background-color: #e6e6e6;
+    background-color: var(--rspamd-symbol-hover-bg);
 }
 .symbol-negative.symbol-negative {
-    background-color: #eef9e7;
+    background-color: var(--rspamd-symbol-negative-bg);
 }
 .symbol-positive.symbol-positive {
-    background-color: #fbe9e5;
+    background-color: var(--rspamd-symbol-positive-bg);
 }
 .symbol-special {
-    background-color: #e2e9fe;
+    background-color: var(--rspamd-symbol-special-bg);
 }
 .symbol-negative:hover {
-    background-color: #dcf9d3;
+    background-color: var(--rspamd-symbol-hover-bg);
 }
 .symbol-positive:hover {
-    background-color: #fbd6d1;
+    background-color: var(--rspamd-symbol-hover-bg);
 }
 .symbol-special:hover {
-    background-color: #cddbff;
+    background-color: var(--rspamd-symbol-hover-bg);
 }
 
 /* For symbol description display on hover/focus */
@@ -263,7 +362,7 @@ table#symbolsTable input[type="number"] {
 }
 .symbol-description {
     display: none;
-    color: #484848;
+    color: var(--rspamd-symbol-description-color);
 }
 .symbol-default:hover .symbol-description,
 .symbol-default:focus .symbol-description {
@@ -358,6 +457,8 @@ table#symbolsTable input[type="number"] {
     text-align: left;
     font-size: 12px;
     z-index: 100;
+
+    --bs-table-bg: var(--rspamd-bs-table-bg);
 }
 #rrd-table td {
     color: inherit;
@@ -477,9 +578,13 @@ table#symbolsTable input[type="number"] {
     transition-property: flex-basis, max-width, width;
 }
 
+.sidebar,
+#sidebar-tab-left > a,
+#sidebar-tab-right > a {
+    background-color: var(--rspamd-sidebar-bg);
+}
 .sidebar {
     padding: 8px;
-    background-color: #ffe;
     transition: margin 0.3s ease;
 }
 .collapsed {
@@ -509,11 +614,10 @@ table#symbolsTable input[type="number"] {
 }
 .sidebar-nav .nav-link,
 .sidebar-nav .nav-link:hover {
-    border: 1px solid #ddd;
+    border: 1px solid var(--bs-card-border-color);
 }
 #sidebar-tab-left > a,
 #sidebar-tab-right > a {
-    background-color: #ffe;
     margin-left: 12px;
     margin-right: 12px;
 }
@@ -535,8 +639,8 @@ table#symbolsTable input[type="number"] {
         display: block;
     }
     #content {
-        border-left: 1px solid #ddd;
-        border-right: 1px solid #ddd;
+        border-left: 1px solid var(--bs-card-border-color);
+        border-right: 1px solid var(--bs-card-border-color);
     }
     #sidebar-tab-left {
         display: flex;
@@ -553,8 +657,8 @@ table#symbolsTable input[type="number"] {
         border-bottom-right-radius: 3.5px;
     }
     #content {
-        border-top: 1px solid #ddd;
-        border-bottom: 1px solid #ddd;
+        border-top: 1px solid var(--bs-card-border-color);
+        border-bottom: 1px solid var(--bs-card-border-color);
     }
     #sidebar-tab-right {
         bottom: 0;
@@ -640,3 +744,48 @@ table#symbolsTable input[type="number"] {
         filter: invert(1);
     }
 }
+
+/* Dark mode overrides for Bootstrap tables */
+[data-theme="dark"] .table {
+    --bs-table-color-state: var(--rspamd-text-primary);
+}
+
+/* Dark mode overrides for D3Evolution */
+[data-theme="dark"] .d3evolution .grid line {
+    stroke: #404040;
+}
+[data-theme="dark"] .d3evolution .cursor .background {
+    stroke: #1a1a1a;
+}
+[data-theme="dark"] .d3evolution .cursor .x.foreground {
+    stroke: #4db8ff;
+}
+[data-theme="dark"] .d3evolution .cursor circle.foreground {
+    stroke: white;
+}
+[data-theme="dark"] .d3evolution .chart-title,
+[data-theme="dark"] .d3evolution .axis,
+[data-theme="dark"] .d3evolution .legend,
+[data-theme="dark"] .d3evolution .y.label,
+[data-theme="dark"] .d3evolution .cursor-time {
+    fill: var(--rspamd-text-primary);
+}
+
+/* Dark mode overrides for D3Pie */
+[data-theme="dark"] .d3pie .chart-title,
+[data-theme="dark"] .d3pie .outer-label,
+[data-theme="dark"] .d3pie .total-text,
+[data-theme="dark"] .d3pie .total-value {
+    fill: var(--rspamd-text-primary);
+}
+[data-theme="dark"] .d3pie .slice-g.first-slice .inner-label {
+    fill: #666666;
+}
+[data-theme="dark"] .d3pie-tooltip {
+    background-color: rgb(80 80 80 / 80%);
+}
+
+/* Dark mode for NProgress */
+[data-theme="dark"] #nprogress .bar {
+    background: #4db8ff;
+}
diff --git a/interface/img/rspamd_logo_navbar_dark.png b/interface/img/rspamd_logo_navbar_dark.png
new file mode 100644 (file)
index 0000000..0d62b2d
Binary files /dev/null and b/interface/img/rspamd_logo_navbar_dark.png differ
index 1c12badf51c60e08f56b015dc7bfbb78166c6da7..909b16ffa70523c2a8dfdd9e33244c6ddbfa0f52 100644 (file)
 
 <body>
 <!-- .vw-100 and .pe-3 prevent navbar layout shift caused by scrollbar -->
-<nav class="navbar navbar-light bg-light navbar-expand-xl vw-100 pe-3 d-none" id="navBar">
+<nav class="navbar bg-body-tertiary border-bottom navbar-expand-xl vw-100 pe-3 d-none" id="navBar">
        <div class="container-fluid">
                <div class="navbar-header navbar-brand p-0">
-                       <img src="./img/rspamd_logo_navbar.png" alt="Rspamd">
+                       <img class="logo-light" src="./img/rspamd_logo_navbar.png" alt="Rspamd">
+                       <img class="logo-dark" src="./img/rspamd_logo_navbar_dark.png" alt="Rspamd">
                </div>
                <div class="collapse navbar-collapse order-3 order-xl-2 flex-grow-0">
                        <form class="my-2 me-auto">
@@ -89,6 +90,7 @@
                                        </div>
                                </div>
                                <button class="btn btn-outline-secondary ms-2" id="disconnect" title="Disconnect"><i class="fas fa-power-off"></i></button>
+                               <button class="btn btn-outline-secondary ms-2" id="theme-toggle" title="Toggle theme"><i class="fas fa-moon" id="theme-icon"></i></button>
                                <button class="btn btn-outline-secondary ms-2" id="settings" title="WebUI settings"><i class="fas fa-cog"></i></button>
                                <div class="d-none">
                                        <div id="settings-popover">
                                                        </div>
                                                </div>
 
+                                               <div class="card mt-1">
+                                                       <div class="card-body">
+                                                               <h6 class="card-title fw-bolder">Theme</h6>
+                                                               <label class="ms-2">
+                                                                       <input type="radio" class="me-2" name="theme" value="light">
+                                                                       Light
+                                                               </label>
+                                                               <label class="ms-2">
+                                                                       <input type="radio" class="me-2" name="theme" value="dark">
+                                                                       Dark
+                                                               </label>
+                                                               <label class="ms-2">
+                                                                       <input type="radio" class="me-2" name="theme" value="auto" checked>
+                                                                       Auto
+                                                               </label>
+                                                       </div>
+                                               </div>
+
                                                <div class="card mt-1">
                                                        <div class="card-body">
                                                                <h6 class="card-title fw-bolder">HTTP requests timeout, ms</h6>
        </div>
        <script>document.getElementById("loading").classList.remove("d-none");</script>
        <div class="row position-absolute w-100 h-100 align-items-center text-center">
-               <img class="img-fluid w-auto mh-100 mx-auto" src="./img/rspamd_logo_navbar.png" alt="Rspamd" />
+               <img class="img-fluid w-auto mh-100 mx-auto logo-light" src="./img/rspamd_logo_navbar.png" alt="Rspamd" />
+               <img class="img-fluid w-auto mh-100 mx-auto logo-dark" src="./img/rspamd_logo_navbar_dark.png" alt="Rspamd" />
        </div>
 </div>
 
                                </div>
                                <div class="row">
                                        <div class="col-lg-6">
-                                               <div class="card bg-light shadow my-3">
+                                               <div class="card shadow my-3">
                                                        <div class="card-header text-secondary py-2 d-flex align-items-center">
                                                                <span class="icon me-3"><i class="fas fa-server"></i></span>
                                                                <span class="h6 fw-bolder my-auto">Servers</span>
                                                                        </table>
                                                                </div>
                                                </div>
-                                               <div class="card bg-light shadow my-3">
+                                               <div class="card shadow my-3">
                                                        <div class="card-header text-secondary py-2 d-flex align-items-center">
                                                                <span class="icon me-3"><i class="fas fa-dice"></i></span>
                                                                <span class="h6 fw-bolder my-auto">Bayesian statistics</span>
                                                                        </table>
                                                                </div>
                                                </div>
-                                               <div class="card bg-light shadow my-3">
+                                               <div class="card shadow my-3">
                                                        <div class="card-header text-secondary py-2 d-flex align-items-center">
                                                                <span class="icon me-3"><i class="fas fa-hashtag"></i></span>
                                                                <span class="h6 fw-bolder my-auto">Fuzzy hashes</span>
                                                </div>
                                        </div>
                                        <div class="col-lg-6">
-                                               <div class="card bg-light shadow my-3">
+                                               <div class="card shadow my-3">
                                                        <div class="card-header text-secondary py-2 d-flex align-items-center">
                                                                <span class="icon me-3"><i class="fas fa-chart-pie"></i></span>
                                                                <span class="h6 fw-bolder my-auto">Statistics</span>
                                                        </div>
                                                        <div class="card-body">
                                                                <div class="row">
-                                                                       <div class="bg-white w-auto mx-auto" id="chart"></div>
+                                                                       <div class="w-auto mx-auto" id="chart"></div>
                                                                </div>
                                                        </div>
                                                </div>
                        </div>
 
                        <div class="tab-pane" id="throughput">
-                               <div class="card bg-light shadow my-3">
+                               <div class="card shadow my-3">
                                        <div class="card-header text-secondary py-2 d-flex align-items-center">
                                                <span class="icon me-3"><i class="fas fa-chart-area"></i></span>
                                                <span class="h6 fw-bolder my-auto">Throughput</span>
                                        </div>
                                        <div class="card-body text-center">
-                                               <div class="d-inline-block bg-white">
+                                               <div class="d-inline-block">
                                                        <div class="row">
                                                                <div id="graph" class="mx-auto"></div>
                                                        </div>
                                                        <div id="summary-row" class="row">
                                                                <div class="col-fixed" id="rrd-pie"></div>
                                                                <div class="col-fluid">
-                                                                       <table id="rrd-table" class="table table-light table-striped table-hover"></table>
+                                                                       <table id="rrd-table" class="table table-striped table-hover"></table>
                                                                        <div id="rrd-table_toggle"></div>
                                                                        <div id="rrd-total">Total messages: <span id="rrd-total-value"></span></div>
                                                                </div>
                        </div>
 
                        <div class="tab-pane" id="configuration">
-                               <div class="card bg-light shadow my-3">
+                               <div class="card shadow my-3">
                                        <div class="card-header text-secondary py-2 d-flex align-items-center">
                                                <span class="icon me-3"><i class="fas fa-tasks"></i></span>
                                                <span class="h6 fw-bolder my-auto">Actions</span>
                                                </div>
                                        </div>
                                </div>
-                               <div class="card bg-light shadow my-3">
+                               <div class="card shadow my-3">
                                        <div class="card-header text-secondary py-2 d-flex align-items-center">
                                                <span class="icon me-3"><i class="fas fa-list"></i></span>
                                                <span class="h6 fw-bolder my-auto">Maps</span>
                        </div>
 
                        <div class="tab-pane" id="symbols">
-                               <div class="card bg-light shadow my-3">
+                               <div class="card shadow my-3">
                                        <div class="card-header text-secondary py-1 d-flex align-items-center">
                                                <span class="icon me-3"><i class="fas fa-tasks"></i></span>
                                                <span class="h6 fw-bolder my-auto ms-0">Symbols and rules</span>
                        </div>
 
                        <div class="tab-pane" id="scan">
-                               <div class="card bg-light shadow my-3">
+                               <div class="card shadow my-3">
                                        <div class="card-header text-secondary py-1 d-flex align-items-center">
                                                <span class="icon me-3"><i class="fas fa-envelope"></i></span>
                                                <span class="h6 fw-bolder my-auto">Scan suspected message</span>
                                        <div class="card-body">
                                                <div class="row g-3">
                                                        <div class="col-lg-auto d-flex">
-                                                               <div class="card bg-light shadow card-body card p-2">
+                                                               <div class="card shadow card-body card p-2">
                                                                        <p>Learn Bayesian classifier:</p>
                                                                        <form>
                                                                                <div class="d-flex flex-wrap flex-lg-column align-items-start align-items-lg-stretch gap-2">
                                                                </div>
                                                        </div>
                                                        <div class="col-lg d-flex">
-                                                               <div class="card bg-light shadow card-body p-2">
+                                                               <div class="card shadow card-body p-2">
                                                                        <p>Fuzzy hash storage management:</p>
                                                                        <div class="row g-2 align-items-center">
                                                                                <div class="col-auto d-flex align-items-center me-1">
                                        </div>
                                </div>
 
-                               <div id="hash-card" class="card bg-light shadow my-3 d-none">
+                               <div id="hash-card" class="card shadow my-3 d-none">
                                        <div class="card-header text-secondary py-2 d-flex align-items-center">
                                                <span class="icon me-3"><i class="fas fa-hashtag"></i></span>
                                                <span class="h6 fw-bolder my-auto">Fuzzy hashes</span>
                                                </div>
                                </div>
 
-                               <div class="card bg-light shadow my-3">
+                               <div class="card shadow my-3">
                                        <div class="card-header text-secondary py-1 d-flex align-items-center">
                                                <span class="icon me-3"><i class="fas fa-eye"></i></span>
                                                <span class="h6 fw-bolder my-auto ms-0">Scan results history</span>
                        </div>
 
                        <div class="tab-pane" id="selectors">
-                               <div class="card bg-light shadow my-3">
+                               <div class="card shadow my-3">
                                        <div class="card-header text-secondary py-2 d-flex align-items-center">
                                                <span class="icon me-3"><i class="fas fa-envelope"></i></span>
                                                <span class="h6 fw-bolder my-auto">Test Rspamd selectors</span>
                                        <div class="card-body p-0">
                                                <div class="row h-100 m-0" id="row-main">
                                                        <div class="col-lg-3 sidebar h-100" id="sidebar-left">
-                                                               <div class="p-0 table-responsive mh-100 bg-white">
+                                                               <div class="p-0 table-responsive mh-100">
                                                                        <table class="table table-sm small table-striped table-hover table-bordered mb-0" id="selectorsTable-extractors">
                                                                                <thead><tr><th>Name</th><th>Description</th></tr></thead>
                                                                                <tbody/>
 
                        <div class="tab-pane" id="history">
 
-                               <div class="card bg-light shadow my-3">
+                               <div class="card shadow my-3">
                                        <div class="card-header text-secondary py-1 d-flex align-items-center">
                                                <span class="icon me-3"><i class="fas fa-eye"></i></span>
                                                <span class="h6 fw-bolder my-auto ms-0">History</span>
                                                </div>
                                        </div>
                                </div>
-                               <div class="card bg-light shadow my-3 ro-hide" id="errors-history">
+                               <div class="card shadow my-3 ro-hide" id="errors-history">
                                        <div class="card-header text-secondary py-1 d-flex align-items-center">
                                                <span class="icon me-3"><i class="fas fa-exclamation-triangle"></i></span>
                                                <span class="h6 fw-bolder my-auto ms-0">Errors</span>
index d19724e63bfbeb9c9b830196b0476aea417e4d57..4e2c589ab1af6dad5334fd414bfeafd757021090 100644 (file)
@@ -130,7 +130,7 @@ define(["jquery", "app/common"],
                         });
 
                         const $tr = $("<tr>").append($td).append($("<td>" + item.type + "</td>"));
-                        if (!item.loaded) $tr.addClass("table-light opacity-50");
+                        if (!item.loaded) $tr.addClass("table-active opacity-50");
 
                         const $span = $('<span class="map-link">' + item.uri + "</span>").data("item", item);
                         $span.wrap("<td>").parent().appendTo($tr);
index ea19cc2ae16b1110f7d56ca1529433fa86e0b329..ee8ad9830a4d43a3f8138d863d5cba89a279dd89 100644 (file)
@@ -350,6 +350,22 @@ define(["jquery", "app/common", "stickytabs", "visibility",
         });
     };
 
+    function updateThemeIcon(theme) {
+        const icon = $("#theme-icon");
+        icon.removeClass("fa-moon fa-sun fa-display");
+
+        switch (theme) {
+            case "light":
+                icon.addClass("fa-sun");
+                break;
+            case "dark":
+                icon.addClass("fa-moon");
+                break;
+            default:
+                icon.addClass("fa-display");
+                break;
+        }
+    }
 
     (function initSettings() {
         let selected_locale = null;
@@ -417,6 +433,10 @@ define(["jquery", "app/common", "stickytabs", "visibility",
             ajaxSetup(localStorage.getItem("ajax_timeout"), true);
 
             $(historyCountSelector).val(parseInt(localStorage.getItem("historyCount"), 10) || historyCountDef);
+
+            // Restore theme selection
+            const savedTheme = localStorage.getItem("theme") || "auto";
+            $('.popover #settings-popover input:radio[name="theme"]').val([savedTheme]);
         });
         $(document).on("change", '.popover #settings-popover input:radio[name="locale"]', function () {
             selected_locale = this.value;
@@ -427,6 +447,14 @@ define(["jquery", "app/common", "stickytabs", "visibility",
             custom_locale = $(localeTextbox).val();
             validateLocale(true);
         });
+        $(document).on("change", '.popover #settings-popover input:radio[name="theme"]', function () {
+            const theme = this.value;
+            if (window.rspamd && window.rspamd.theme) {
+                window.rspamd.theme.applyPreference(theme);
+            }
+            updateThemeIcon(theme || "auto");
+        });
+        updateThemeIcon(localStorage.getItem("theme") || "auto");
         $(document).on("input", ajaxTimeoutBox, () => {
             ajaxSetup($(ajaxTimeoutBox).val(), false, true);
         });
@@ -492,6 +520,34 @@ define(["jquery", "app/common", "stickytabs", "visibility",
         tabClick("#autoRefresh");
     });
 
+    $("#theme-toggle").on("click", (e) => {
+        e.preventDefault();
+        const currentTheme = localStorage.getItem("theme") || "light";
+        // eslint-disable-next-line no-useless-assignment
+        let newTheme = null;
+
+        // Cycle through: light -> dark -> auto -> light
+        switch (currentTheme) {
+            case "light":
+                newTheme = "dark";
+                break;
+            case "dark":
+                newTheme = "auto";
+                break;
+            default:
+                newTheme = "light";
+                break;
+        }
+
+        if (window.rspamd && window.rspamd.theme) {
+            window.rspamd.theme.applyPreference(newTheme);
+        }
+        updateThemeIcon(newTheme);
+
+        // Update radio button in settings popover if it's open
+        $('.popover #settings-popover input:radio[name="theme"]').val([newTheme]);
+    });
+
     $("#selSrv").change(function () {
         checked_server = this.value;
         $("#selSrv [value=\"" + checked_server + "\"]").prop("checked", true);
index 64cee05a180b9ebc70e12c01747750374336bc32..b2a3b37f19d28d43613f9d59f8acee24999cb888 100644 (file)
@@ -1,5 +1,91 @@
 /* global d3:writable, require, requirejs */ // eslint-disable-line no-unused-vars
 
+/**
+ * Theme initialization and management
+ *
+ * Initializes theme as early as possible, before loading any modules.
+ * Provides automatic theme detection and switching based on user preference
+ * and system settings. Invalid or missing preferences default to "auto" mode,
+ * which follows the system's color scheme preference.
+ *
+ * @exports window.rspamd.theme.applyPreference - Apply theme preference with listener management
+ * @exports window.rspamd.theme.getEffectiveTheme - Get effective theme for a given preference
+ */
+(function () {
+    "use strict";
+
+    const mq = window.matchMedia("(prefers-color-scheme: dark)");
+
+    function normalizeTheme(value) {
+        const pref = (typeof value === "string") ? value.trim().toLowerCase() : "";
+        const allowed = new Set(["light", "dark", "auto"]);
+        return {
+            isAuto: !pref || !allowed.has(pref) || pref === "auto",
+            pref: pref
+        };
+    }
+
+    /**
+     * Get effective theme based on preference
+     * @param {string} themePref - Theme preference ("light", "dark", "auto", or invalid)
+     * @returns {string} Effective theme: "light" or "dark"
+     */
+    function getEffectiveTheme(themePref) {
+        const {isAuto, pref} = normalizeTheme(themePref);
+        // eslint-disable-next-line no-nested-ternary
+        return isAuto ? (mq.matches ? "dark" : "light") : pref;
+    }
+
+    function apply(theme) {
+        document.documentElement.setAttribute("data-bs-theme", theme);
+        document.body.setAttribute("data-theme", theme);
+    }
+
+    function handler() {
+        apply(getEffectiveTheme(localStorage.getItem("theme")));
+    }
+
+    // Apply theme immediately on page load
+    handler();
+
+    // Set up listener for system theme changes if in auto mode
+    const {isAuto: initialIsAuto} = normalizeTheme(localStorage.getItem("theme"));
+    if (initialIsAuto && typeof mq.addEventListener === "function") {
+        mq.addEventListener("change", handler);
+    }
+
+
+    // Export theme API to window.rspamd namespace
+    if (!window.rspamd) window.rspamd = {};
+
+    /**
+     * Theme management API
+     * @namespace
+     *
+     * @property {Function} applyPreference - Apply theme preference (handles auto mode and listener management)
+     * @property {Function} getEffectiveTheme - Get effective theme for a given preference
+     */
+    window.rspamd.theme = {
+
+        /**
+         * Apply theme preference (handles auto mode and listener management)
+         * @param {string} themePref - Theme preference to apply
+         */
+        applyPreference: (themePref) => {
+            localStorage.setItem("theme", themePref);
+            apply(getEffectiveTheme(themePref));
+
+            const {isAuto} = normalizeTheme(themePref);
+            mq.removeEventListener("change", handler);
+            if (isAuto) {
+                mq.addEventListener("change", handler);
+            }
+        },
+
+        getEffectiveTheme: getEffectiveTheme
+    };
+}());
+
 requirejs.config({
     baseUrl: "js/lib",
     paths: {