From 21188f48d76dd29bc1251bcc6faf9d6d96c805b5 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Wed, 6 May 2026 02:27:05 +0200 Subject: [PATCH] fix(theme): centralise force-mode enforcement inside ThemeContext so every gallery flips --- .../src/components/GlobalThemeProvider.tsx | 7 +-- .../src/components/gallery/GalleryView.tsx | 13 ++---- frontend/src/contexts/ThemeContext.tsx | 45 +++++++++++++------ frontend/src/pages/GalleryPage.tsx | 13 ++---- 4 files changed, 40 insertions(+), 38 deletions(-) diff --git a/frontend/src/components/GlobalThemeProvider.tsx b/frontend/src/components/GlobalThemeProvider.tsx index ef84968f..b2a38280 100644 --- a/frontend/src/components/GlobalThemeProvider.tsx +++ b/frontend/src/components/GlobalThemeProvider.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useRef } from 'react'; import { useTheme } from '../contexts/ThemeContext'; import { usePublicSettings } from '../hooks/usePublicSettings'; -import { applyForceColorMode } from '../utils/themeMigration'; interface GlobalThemeProviderProps { children: React.ReactNode; @@ -19,10 +18,8 @@ export const GlobalThemeProvider: React.FC = ({ childr if (!themeAppliedRef.current && settingsData?.theme_config && !isGalleryPage) { themeAppliedRef.current = true; - // Honor instance-wide force color mode: when set, applyForceColorMode - // also swaps the surface/text tokens so the page actually flips - // visually (not just the colorMode flag — see #397 follow-up). - setTheme(applyForceColorMode(settingsData.theme_config, settingsData.branding_force_color_mode)); + // Instance-wide force color mode is enforced inside ThemeContext.applyTheme. + setTheme(settingsData.theme_config); } }, [settingsData, setTheme]); diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 99533262..5feaa1ab 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -28,7 +28,6 @@ import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss'; import { usePublicSettings } from '../../hooks/usePublicSettings'; import type { Photo } from '../../types'; import { GALLERY_THEME_PRESETS } from '../../types/theme.types'; -import { applyForceColorMode } from '../../utils/themeMigration'; import { useQueryClient } from '@tanstack/react-query'; interface GalleryViewProps { @@ -342,15 +341,9 @@ export const GalleryView: React.FC = ({ slug, event }) => { themeToApply = settingsData.theme_config; } - // Honor instance-wide force color mode (Branding > Force color mode). - // applyForceColorMode pins colorMode AND swaps surface/text tokens - // when the active theme doesn't natively support the locked mode, - // so the gallery actually flips visually (#397 follow-up). - if (themeToApply) { - themeToApply = applyForceColorMode(themeToApply, settingsData.branding_force_color_mode); - } - - // Apply theme with a small delay to ensure it overrides any global theme + // Apply theme with a small delay to ensure it overrides any global theme. + // Instance-wide force color mode is enforced inside ThemeContext.applyTheme, + // so callers don't have to wrap the theme themselves. if (themeToApply) { // Use setTimeout to ensure this runs after any global theme application const timer = setTimeout(() => { diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx index a7010eee..7e595d67 100644 --- a/frontend/src/contexts/ThemeContext.tsx +++ b/frontend/src/contexts/ThemeContext.tsx @@ -2,6 +2,8 @@ import React, { createContext, useContext, useState, useEffect, useCallback, use import type { ReactNode } from 'react'; import { ThemeConfig, EventTheme, GALLERY_THEME_PRESETS } from '../types/theme.types'; import { fontsService, extractFamilyName, type FontDefinition } from '../services/fonts.service'; +import { applyForceColorMode } from '../utils/themeMigration'; +import { usePublicSettings } from '../hooks/usePublicSettings'; // Self-hosted font loader. Resolves the available-fonts list once (cached for // 5 minutes) and lazily injects @font-face blocks into only for the @@ -90,8 +92,8 @@ interface ThemeProviderProps { initialThemeName?: string; } -export const ThemeProvider: React.FC = ({ - children, +export const ThemeProvider: React.FC = ({ + children, initialTheme = GALLERY_THEME_PRESETS.default.config, initialThemeName = 'default' }) => { @@ -99,9 +101,30 @@ export const ThemeProvider: React.FC = ({ const [themeName, setThemeName] = useState(initialThemeName); const [resolvedColorMode, setResolvedColorMode] = useState<'light' | 'dark'>(() => resolveColorMode(initialTheme.colorMode)); - const applyTheme = useCallback((themeConfig: ThemeConfig) => { + // Subscribe to the instance-wide force color mode setting. When an admin + // toggles "Force dark / light" in Branding, all open admin and gallery + // tabs re-apply the active theme through applyForceColorMode within the + // refetch interval so the lock takes effect without a full reload. + // Refetch is best-effort — a stale cached value just means a delayed flip, + // not a broken state. + const { data: publicSettings } = usePublicSettings({ refetchInterval: 30_000 }); + const forcedMode = publicSettings?.branding_force_color_mode === 'dark' + ? 'dark' + : publicSettings?.branding_force_color_mode === 'light' + ? 'light' + : null; + + const applyTheme = useCallback((rawThemeConfig: ThemeConfig) => { const root = document.documentElement; - + + // Honour the instance-wide force color mode at the chokepoint so every + // call site (gallery, admin, preview iframe, branding live preview) is + // forced to follow without each one having to remember to do it. + // applyForceColorMode is a no-op when forcedMode is null, and only + // swaps surface/text tokens when the active theme doesn't natively + // support the locked mode — accent CI colours are preserved either way. + const themeConfig = applyForceColorMode(rawThemeConfig, forcedMode); + // Apply CSS variables — 8-token CI palette. // Legacy --color-primary / --color-primary-light / --color-primary-dark // are kept for any consumer still reading them; they mirror accent-dark. @@ -271,7 +294,7 @@ export const ThemeProvider: React.FC = ({ } styleElement.textContent = themeConfig.customCss; } - }, []); + }, [forcedMode]); const setThemeConfig = useCallback((newTheme: ThemeConfig) => { setTheme(newTheme); @@ -291,15 +314,11 @@ export const ThemeProvider: React.FC = ({ setThemeByName('default'); }, [setThemeByName]); - // Apply theme when it changes, but skip if it's the same + // Apply theme when it changes, OR when force-mode changes (so an admin + // toggling Force dark / light in Branding flips every open tab on the + // next public-settings refetch tick — no reload needed). useEffect(() => { - const root = document.documentElement; - const currentPrimary = root.style.getPropertyValue('--color-primary'); - - // Only apply if the theme has actually changed - if (currentPrimary !== theme.primaryColor) { - applyTheme(theme); - } + applyTheme(theme); }, [theme, applyTheme]); // Load theme from localStorage on mount (skip if in gallery view) diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index d990c933..a59bdc6d 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -14,7 +14,6 @@ import { GallerySkeleton } from '../components/gallery/GallerySkeleton'; import { analyticsService } from '../services/analytics.service'; import { galleryService } from '../services'; import { GALLERY_THEME_PRESETS } from '../types/theme.types'; -import { applyForceColorMode } from '../utils/themeMigration'; import { buildResourceUrl } from '../utils/url'; import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl'; @@ -158,15 +157,9 @@ export const GalleryPage: React.FC = () => { } } - // Honor instance-wide force color mode (Branding > Force color mode). - // applyForceColorMode pins colorMode AND swaps surface/text tokens - // when the active theme doesn't natively support the locked mode, - // so the gallery actually flips visually (#397 follow-up). - if (themeToApply) { - themeToApply = applyForceColorMode(themeToApply, settingsData.branding_force_color_mode); - } - - // Apply theme + // Apply theme. Force color mode is enforced inside ThemeContext.applyTheme + // (it subscribes to public settings) so callers don't have to wrap the + // theme themselves — keeps the lock consistent across every entry point. if (themeToApply) { setTheme(themeToApply); }