From 5a162fc8bec47a49cb1bcaa92ff72e197e8d2e42 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 5 May 2026 16:06:38 +0200 Subject: [PATCH] feat(branding): force color mode (dark or light) site-wide --- backend/src/routes/adminSettings.js | 13 ++++- backend/src/routes/publicSettings.js | 8 +++ .../src/components/GlobalThemeProvider.tsx | 9 ++- frontend/src/components/admin/AdminHeader.tsx | 21 ++++--- .../src/components/gallery/GalleryView.tsx | 7 +++ .../src/contexts/AdminDarkModeContext.tsx | 51 ++++++++++++++--- frontend/src/pages/GalleryPage.tsx | 7 +++ frontend/src/pages/admin/BrandingPage.tsx | 42 ++++++++++++++ .../src/services/publicSettings.service.ts | 6 ++ frontend/src/services/settings.service.ts | 13 ++++- tests/e2e/dark-mode.spec.ts | 55 +++++++++++++++++++ 11 files changed, 211 insertions(+), 21 deletions(-) diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 125b341a..7b832a18 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -219,9 +219,17 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re logo_display_header, logo_display_hero, logo_display_mode, - hide_powered_by + hide_powered_by, + force_color_mode } = req.body; + // Normalize force_color_mode: only 'dark' | 'light' | null are valid. + const normalizedForceColorMode = force_color_mode === 'dark' + ? 'dark' + : force_color_mode === 'light' + ? 'light' + : null; + // Get current watermark settings hash for change detection const oldSettingsHash = await watermarkService.getSettingsHash(); @@ -243,7 +251,8 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re logo_display_header, logo_display_hero, logo_display_mode, - hide_powered_by + hide_powered_by, + force_color_mode: normalizedForceColorMode }; // Handle favicon deletion if empty string or null is provided diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js index 1d689b3d..4c6c4fa8 100644 --- a/backend/src/routes/publicSettings.js +++ b/backend/src/routes/publicSettings.js @@ -65,6 +65,14 @@ router.get('/', async (req, res) => { branding_logo_display_hero: settingsObject.branding_logo_display_hero !== false, branding_logo_display_mode: settingsObject.branding_logo_display_mode || 'logo_and_text', branding_hide_powered_by: settingsObject.branding_hide_powered_by === true, + // Force a specific color mode site-wide. When set, the user toggle + // is hidden and the value overrides per-theme/system preference. + // Allowed values: 'dark' | 'light' | null (null = no force). + branding_force_color_mode: settingsObject.branding_force_color_mode === 'dark' + ? 'dark' + : settingsObject.branding_force_color_mode === 'light' + ? 'light' + : null, theme_config: settingsObject.theme_config || null, default_language: settingsObject.general_default_language || 'en', enable_analytics: settingsObject.general_enable_analytics !== false, diff --git a/frontend/src/components/GlobalThemeProvider.tsx b/frontend/src/components/GlobalThemeProvider.tsx index ee32207e..a5ab63d5 100644 --- a/frontend/src/components/GlobalThemeProvider.tsx +++ b/frontend/src/components/GlobalThemeProvider.tsx @@ -18,7 +18,14 @@ export const GlobalThemeProvider: React.FC = ({ childr if (!themeAppliedRef.current && settingsData?.theme_config && !isGalleryPage) { themeAppliedRef.current = true; - setTheme(settingsData.theme_config); + // Honor instance-wide force color mode: when set, override the + // theme's own colorMode so legacy themes can't render light against + // a force-dark instance (or vice-versa). + const forced = settingsData.branding_force_color_mode; + const themeWithForce = forced + ? { ...settingsData.theme_config, colorMode: forced } + : settingsData.theme_config; + setTheme(themeWithForce); } }, [settingsData, setTheme]); diff --git a/frontend/src/components/admin/AdminHeader.tsx b/frontend/src/components/admin/AdminHeader.tsx index 10b3c9f7..9aeacf9a 100644 --- a/frontend/src/components/admin/AdminHeader.tsx +++ b/frontend/src/components/admin/AdminHeader.tsx @@ -23,7 +23,7 @@ interface AdminHeaderProps { export const AdminHeader: React.FC = ({ onMenuClick }) => { const navigate = useNavigate(); const { user, logout } = useAdminAuth(); - const { isDark, toggle: toggleDarkMode } = useAdminDarkMode(); + const { isDark, toggle: toggleDarkMode, forcedMode } = useAdminDarkMode(); const { t } = useTranslation(); const { format } = useLocalizedDate(); const { formatTimeAgo } = useLocalizedTimeAgo(); @@ -116,14 +116,17 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { {/* Language Selector */} - {/* Dark Mode Toggle */} - + {/* Dark Mode Toggle — hidden entirely when an admin has locked + the instance to a specific mode via Branding > Force color mode. */} + {!forcedMode && ( + + )} {/* Notifications */}
diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 2ca14e66..18d6ba0c 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -341,6 +341,13 @@ export const GalleryView: React.FC = ({ slug, event }) => { themeToApply = settingsData.theme_config; } + // Honor instance-wide force color mode (Branding > Force color mode). + // The branding-level lock wins over per-event themes so a force-dark + // instance never accidentally renders a light gallery (and vice-versa). + if (themeToApply && settingsData.branding_force_color_mode) { + themeToApply = { ...themeToApply, colorMode: settingsData.branding_force_color_mode }; + } + // Apply theme with a small delay to ensure it overrides any global theme if (themeToApply) { // Use setTimeout to ensure this runs after any global theme application diff --git a/frontend/src/contexts/AdminDarkModeContext.tsx b/frontend/src/contexts/AdminDarkModeContext.tsx index b747f028..9ed6bfdc 100644 --- a/frontend/src/contexts/AdminDarkModeContext.tsx +++ b/frontend/src/contexts/AdminDarkModeContext.tsx @@ -1,11 +1,19 @@ import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react'; import { useLocation } from 'react-router-dom'; +import { usePublicSettings } from '../hooks/usePublicSettings'; type DarkModePreference = 'light' | 'dark' | 'system'; interface AdminDarkModeContextType { preference: DarkModePreference; isDark: boolean; + /** + * When the admin has set `branding_force_color_mode`, the toggle is locked + * to that value. Consumers (AdminHeader) hide their toggle when this is + * truthy — UI parity with the user's "disable lightmode option page wide" + * request from discussion #397. + */ + forcedMode: 'dark' | 'light' | null; setPreference: (pref: DarkModePreference) => void; toggle: () => void; } @@ -24,12 +32,25 @@ export const AdminDarkModeProvider: React.FC<{ children: React.ReactNode }> = ({ const location = useLocation(); const isLoginPage = location.pathname === '/admin/login'; + // Instance-wide force mode (read from branding settings). When set, this + // wins over user preference and system preference. Refetches every 30s so + // toggling it in the Branding tab propagates to other open tabs without + // needing a full reload. + const { data: publicSettings } = usePublicSettings({ refetchInterval: 30_000 }); + const forcedMode: 'dark' | 'light' | null = publicSettings?.branding_force_color_mode === 'dark' + ? 'dark' + : publicSettings?.branding_force_color_mode === 'light' + ? 'light' + : null; + const [preference, setPreferenceState] = useState(() => { const stored = localStorage.getItem(STORAGE_KEY); if (stored === 'dark' || stored === 'light' || stored === 'system') return stored; return 'light'; }); + // The effective dark state: if a force mode is set, that wins; otherwise + // we resolve from the user's preference (light / dark / system). const [isDark, setIsDark] = useState(() => resolveIsDark(preference)); const applyDarkClass = useCallback((dark: boolean, forceLight = false) => { @@ -42,26 +63,37 @@ export const AdminDarkModeProvider: React.FC<{ children: React.ReactNode }> = ({ }, []); const setPreference = useCallback((pref: DarkModePreference) => { + // If an admin has locked the instance to a specific mode, the user + // toggle is a no-op — silently ignore so we don't desync the UI. + if (forcedMode) return; setPreferenceState(pref); localStorage.setItem(STORAGE_KEY, pref); const dark = resolveIsDark(pref); setIsDark(dark); // Don't apply dark on login page applyDarkClass(dark, isLoginPage); - }, [applyDarkClass, isLoginPage]); + }, [applyDarkClass, isLoginPage, forcedMode]); const toggle = useCallback(() => { + if (forcedMode) return; setPreference(isDark ? 'light' : 'dark'); - }, [isDark, setPreference]); + }, [isDark, setPreference, forcedMode]); - // Apply on mount and when route changes - skip dark mode on login page + // Apply on mount and when route or force mode changes. The force-mode + // branch wins, then per-route login override, then user preference. useEffect(() => { + if (forcedMode) { + const dark = forcedMode === 'dark'; + setIsDark(dark); + applyDarkClass(dark, isLoginPage && forcedMode === 'light'); + return; + } applyDarkClass(isDark, isLoginPage); - }, [applyDarkClass, isDark, isLoginPage]); + }, [applyDarkClass, isDark, isLoginPage, forcedMode]); - // Listen for system changes when preference is 'system' + // Listen for system changes when preference is 'system' (and no force mode) useEffect(() => { - if (preference !== 'system') return; + if (forcedMode || preference !== 'system') return; const mql = window.matchMedia('(prefers-color-scheme: dark)'); const handler = (e: MediaQueryListEvent) => { @@ -70,7 +102,7 @@ export const AdminDarkModeProvider: React.FC<{ children: React.ReactNode }> = ({ }; mql.addEventListener('change', handler); return () => mql.removeEventListener('change', handler); - }, [preference, applyDarkClass]); + }, [preference, applyDarkClass, forcedMode]); // Strip dark class when unmounting (navigating away from admin) useEffect(() => { @@ -79,7 +111,10 @@ export const AdminDarkModeProvider: React.FC<{ children: React.ReactNode }> = ({ }; }, []); - const value = useMemo(() => ({ preference, isDark, setPreference, toggle }), [preference, isDark, setPreference, toggle]); + const value = useMemo( + () => ({ preference, isDark, forcedMode, setPreference, toggle }), + [preference, isDark, forcedMode, setPreference, toggle] + ); return ( diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index 0427f2b1..1adc8f65 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -157,6 +157,13 @@ export const GalleryPage: React.FC = () => { } } + // Honor instance-wide force color mode (Branding > Force color mode). + // When set, override per-event/per-theme colorMode so no gallery can + // render light against a force-dark instance. + if (themeToApply && settingsData.branding_force_color_mode) { + themeToApply = { ...themeToApply, colorMode: settingsData.branding_force_color_mode }; + } + // Apply theme if (themeToApply) { setTheme(themeToApply); diff --git a/frontend/src/pages/admin/BrandingPage.tsx b/frontend/src/pages/admin/BrandingPage.tsx index 2e6c84b9..2c031d74 100644 --- a/frontend/src/pages/admin/BrandingPage.tsx +++ b/frontend/src/pages/admin/BrandingPage.tsx @@ -31,6 +31,7 @@ export const BrandingPage: React.FC = () => { logo_display_hero: true, logo_display_mode: 'logo_and_text', hide_powered_by: false, + force_color_mode: null, }); const [currentTheme, setCurrentTheme] = useState(theme); @@ -564,6 +565,47 @@ export const BrandingPage: React.FC = () => {
+ {/* + * Force color mode: instance-wide lock for dark or light theme. + * When set, the user-facing dark/light toggle is hidden in the + * admin header and any per-event/per-theme colorMode is overridden. + * Three states: none (user choice), dark, light. Mutually exclusive. + */} +
+

+ {t('branding.forceColorMode', 'Force color mode')} +

+

+ {t( + 'branding.forceColorModeHelp', + 'Lock the entire admin and public site to dark or light. The user-facing toggle is hidden when active.' + )} +

+
+ {([ + { value: null, label: t('branding.forceColorModeNone', 'No force (user choice)') }, + { value: 'dark', label: t('branding.forceColorModeDark', 'Force dark') }, + { value: 'light', label: t('branding.forceColorModeLight', 'Force light') }, + ] as const).map(({ value, label }) => { + const active = (brandingSettings.force_color_mode ?? null) === value; + return ( + + ); + })} +
+
+