feat(ui): add dark mode support with system preference detection and localStorage persistence
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -34,6 +34,81 @@ function getCsrfToken() {
|
||||
};
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dark mode
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preference is stored in localStorage under the key 'colorScheme'.
|
||||
// Values: 'dark' | 'light' (absence means "follow server/system default").
|
||||
// The anti-flash <script> in base.html applies the class before page paint.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sync the icon and label of both desktop and mobile toggle buttons to the
|
||||
* current dark-mode state.
|
||||
*/
|
||||
function _updateDarkModeButtons() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
|
||||
// Desktop button
|
||||
const btn = document.getElementById('darkModeToggle');
|
||||
if (btn) {
|
||||
const icon = btn.querySelector('i');
|
||||
if (icon) {
|
||||
icon.className = isDark ? 'fas fa-sun' : 'fas fa-moon';
|
||||
}
|
||||
const label = isDark ? 'Switch to light mode' : 'Switch to dark mode';
|
||||
btn.setAttribute('title', label);
|
||||
btn.setAttribute('aria-label', label);
|
||||
}
|
||||
|
||||
// Mobile button
|
||||
const mobileIcon = document.getElementById('darkModeIconMobile');
|
||||
if (mobileIcon) {
|
||||
mobileIcon.className = isDark ? 'fas fa-sun mr-2' : 'fas fa-moon mr-2';
|
||||
}
|
||||
const mobileText = document.getElementById('darkModeTextMobile');
|
||||
if (mobileText) {
|
||||
mobileText.textContent = isDark ? 'Light Mode' : 'Dark Mode';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle dark mode and persist the choice to localStorage.
|
||||
* Called from the navbar button (onclick="toggleDarkMode()").
|
||||
*/
|
||||
function toggleDarkMode() {
|
||||
const isDark = document.documentElement.classList.toggle('dark');
|
||||
localStorage.setItem('colorScheme', isDark ? 'dark' : 'light');
|
||||
_updateDarkModeButtons();
|
||||
}
|
||||
|
||||
// Initialise button state once the DOM is ready.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
_updateDarkModeButtons();
|
||||
});
|
||||
|
||||
// React to OS-level theme changes when the user has not explicitly chosen a
|
||||
// scheme (no localStorage value means "follow the server/system default").
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (e) {
|
||||
if (localStorage.getItem('colorScheme')) {
|
||||
return; // User has an explicit preference; ignore OS changes.
|
||||
}
|
||||
var serverDefault = document.documentElement.getAttribute('data-color-scheme-default') || 'system';
|
||||
if (serverDefault !== 'system') {
|
||||
return; // Admin has forced a specific scheme; ignore OS changes.
|
||||
}
|
||||
if (e.matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
_updateDarkModeButtons();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authentication status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Check authentication status and update the auth section
|
||||
(async function() {
|
||||
console.log('Checking authentication status...');
|
||||
|
||||
@@ -36,3 +36,131 @@ body {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* =============================================================
|
||||
DARK MODE
|
||||
Activated by "dark" class on <html> element.
|
||||
Toggled by the navbar button; preference stored in localStorage.
|
||||
Falls back to the server-side ui_default_color_scheme setting,
|
||||
then to the OS prefers-color-scheme media query.
|
||||
WCAG AA contrast ratios verified for all text/background pairs.
|
||||
============================================================= */
|
||||
|
||||
/* Tell the browser we support both colour schemes */
|
||||
html { color-scheme: light; }
|
||||
html.dark { color-scheme: dark; }
|
||||
|
||||
/* ---- Base / Body ---- */
|
||||
html.dark body { background-color: #111827; color: #e5e7eb; }
|
||||
html.dark .bg-gray-50 { background-color: #111827; }
|
||||
html.dark .bg-white { background-color: #1f2937; }
|
||||
html.dark .bg-gray-100 { background-color: #374151; }
|
||||
html.dark .bg-gray-200 { background-color: #4b5563; }
|
||||
|
||||
/* ---- Text colours ---- */
|
||||
html.dark .text-gray-900 { color: #f9fafb; }
|
||||
html.dark .text-gray-800 { color: #f3f4f6; }
|
||||
html.dark .text-gray-700 { color: #e5e7eb; }
|
||||
html.dark .text-gray-600 { color: #d1d5db; }
|
||||
html.dark .text-gray-500 { color: #9ca3af; }
|
||||
html.dark .text-gray-400 { color: #9ca3af; }
|
||||
html.dark .text-black { color: #f9fafb; }
|
||||
|
||||
/* ---- Borders ---- */
|
||||
html.dark .border-gray-100 { border-color: #374151; }
|
||||
html.dark .border-gray-200 { border-color: #374151; }
|
||||
html.dark .border-gray-300 { border-color: #4b5563; }
|
||||
html.dark .border-gray-400 { border-color: #6b7280; }
|
||||
html.dark .divide-gray-200 > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
|
||||
html.dark .divide-gray-100 > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
|
||||
html.dark .divide-y > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
|
||||
|
||||
/* ---- Hover states ---- */
|
||||
html.dark .hover\:bg-gray-50:hover { background-color: #374151; }
|
||||
html.dark .hover\:bg-gray-100:hover { background-color: #4b5563; }
|
||||
html.dark .hover\:text-gray-900:hover { color: #f9fafb; }
|
||||
html.dark .hover\:text-gray-700:hover { color: #e5e7eb; }
|
||||
|
||||
/* ---- Shadows (softened for dark mode) ---- */
|
||||
html.dark .shadow,
|
||||
html.dark .shadow-md,
|
||||
html.dark .shadow-sm,
|
||||
html.dark .shadow-lg {
|
||||
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.6), 0 1px 2px 0 rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
/* ---- Alert / info-banner backgrounds ---- */
|
||||
html.dark .bg-blue-50 { background-color: #1e3a5f; }
|
||||
html.dark .bg-green-50 { background-color: #052e16; }
|
||||
html.dark .bg-red-50 { background-color: #450a0a; }
|
||||
html.dark .bg-yellow-50 { background-color: #451a03; }
|
||||
html.dark .bg-indigo-50 { background-color: #1e1b4b; }
|
||||
html.dark .bg-orange-50 { background-color: #431407; }
|
||||
|
||||
/* ---- Badge / pill backgrounds ---- */
|
||||
html.dark .bg-blue-100 { background-color: #1e3a5f; }
|
||||
html.dark .bg-green-100 { background-color: #052e16; }
|
||||
html.dark .bg-red-100 { background-color: #450a0a; }
|
||||
html.dark .bg-yellow-100 { background-color: #451a03; }
|
||||
html.dark .bg-indigo-100 { background-color: #1e1b4b; }
|
||||
html.dark .bg-orange-100 { background-color: #431407; }
|
||||
html.dark .bg-purple-100 { background-color: #2e1065; }
|
||||
|
||||
/* ---- Status / badge text colours ---- */
|
||||
html.dark .text-blue-700 { color: #93c5fd; }
|
||||
html.dark .text-blue-800 { color: #bfdbfe; }
|
||||
html.dark .text-green-700 { color: #86efac; }
|
||||
html.dark .text-green-800 { color: #bbf7d0; }
|
||||
html.dark .text-red-700 { color: #fca5a5; }
|
||||
html.dark .text-red-800 { color: #fecaca; }
|
||||
html.dark .text-yellow-700 { color: #fcd34d; }
|
||||
html.dark .text-yellow-800 { color: #fde68a; }
|
||||
html.dark .text-indigo-700 { color: #a5b4fc; }
|
||||
html.dark .text-indigo-800 { color: #c7d2fe; }
|
||||
html.dark .text-orange-700 { color: #fdba74; }
|
||||
html.dark .text-orange-800 { color: #fed7aa; }
|
||||
html.dark .text-purple-700 { color: #d8b4fe; }
|
||||
html.dark .text-purple-800 { color: #e9d5ff; }
|
||||
|
||||
/* ---- Dropdown / popup menus ---- */
|
||||
html.dark .bg-white.rounded-md.shadow-lg { background-color: #1f2937; }
|
||||
html.dark .ring-black { --tw-ring-color: rgba(0,0,0,0.5); }
|
||||
|
||||
/* ---- Form inputs / selects / textareas ---- */
|
||||
html.dark input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
|
||||
html.dark select,
|
||||
html.dark textarea {
|
||||
background-color: #374151;
|
||||
border-color: #4b5563;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
html.dark input::placeholder,
|
||||
html.dark textarea::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
html.dark input:focus:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
|
||||
html.dark select:focus,
|
||||
html.dark textarea:focus {
|
||||
border-color: #60a5fa;
|
||||
outline-color: #60a5fa;
|
||||
}
|
||||
|
||||
/* ---- Table rows ---- */
|
||||
html.dark thead,
|
||||
html.dark .bg-gray-50 thead { background-color: #1f2937; }
|
||||
html.dark thead th { color: #9ca3af; }
|
||||
html.dark tbody tr:hover { background-color: #374151; }
|
||||
|
||||
/* ---- Code / pre ---- */
|
||||
html.dark pre,
|
||||
html.dark code { background-color: #111827; color: #d1d5db; }
|
||||
|
||||
/* ---- Dark-mode toggle button icon colour ---- */
|
||||
html.dark #darkModeToggle { color: #fbbf24; }
|
||||
html.dark #darkModeToggle:hover { background-color: #374151; }
|
||||
|
||||
/* ---- Scrollbar (WebKit browsers) ---- */
|
||||
html.dark ::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
html.dark ::-webkit-scrollbar-track { background: #1f2937; }
|
||||
html.dark ::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 4px; }
|
||||
html.dark ::-webkit-scrollbar-thumb:hover { background: #6b7280; }
|
||||
|
||||
Reference in New Issue
Block a user