fix(theme): centralise force-mode enforcement inside ThemeContext so every gallery flips
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
import { usePublicSettings } from '../hooks/usePublicSettings';
|
import { usePublicSettings } from '../hooks/usePublicSettings';
|
||||||
import { applyForceColorMode } from '../utils/themeMigration';
|
|
||||||
|
|
||||||
interface GlobalThemeProviderProps {
|
interface GlobalThemeProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -19,10 +18,8 @@ export const GlobalThemeProvider: React.FC<GlobalThemeProviderProps> = ({ childr
|
|||||||
|
|
||||||
if (!themeAppliedRef.current && settingsData?.theme_config && !isGalleryPage) {
|
if (!themeAppliedRef.current && settingsData?.theme_config && !isGalleryPage) {
|
||||||
themeAppliedRef.current = true;
|
themeAppliedRef.current = true;
|
||||||
// Honor instance-wide force color mode: when set, applyForceColorMode
|
// Instance-wide force color mode is enforced inside ThemeContext.applyTheme.
|
||||||
// also swaps the surface/text tokens so the page actually flips
|
setTheme(settingsData.theme_config);
|
||||||
// visually (not just the colorMode flag — see #397 follow-up).
|
|
||||||
setTheme(applyForceColorMode(settingsData.theme_config, settingsData.branding_force_color_mode));
|
|
||||||
}
|
}
|
||||||
}, [settingsData, setTheme]);
|
}, [settingsData, setTheme]);
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss';
|
|||||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||||
import type { Photo } from '../../types';
|
import type { Photo } from '../../types';
|
||||||
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||||
import { applyForceColorMode } from '../../utils/themeMigration';
|
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
interface GalleryViewProps {
|
interface GalleryViewProps {
|
||||||
@@ -342,15 +341,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
themeToApply = settingsData.theme_config;
|
themeToApply = settingsData.theme_config;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Honor instance-wide force color mode (Branding > Force color mode).
|
// Apply theme with a small delay to ensure it overrides any global theme.
|
||||||
// applyForceColorMode pins colorMode AND swaps surface/text tokens
|
// Instance-wide force color mode is enforced inside ThemeContext.applyTheme,
|
||||||
// when the active theme doesn't natively support the locked mode,
|
// so callers don't have to wrap the theme themselves.
|
||||||
// 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
|
|
||||||
if (themeToApply) {
|
if (themeToApply) {
|
||||||
// Use setTimeout to ensure this runs after any global theme application
|
// Use setTimeout to ensure this runs after any global theme application
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import React, { createContext, useContext, useState, useEffect, useCallback, use
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { ThemeConfig, EventTheme, GALLERY_THEME_PRESETS } from '../types/theme.types';
|
import { ThemeConfig, EventTheme, GALLERY_THEME_PRESETS } from '../types/theme.types';
|
||||||
import { fontsService, extractFamilyName, type FontDefinition } from '../services/fonts.service';
|
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
|
// Self-hosted font loader. Resolves the available-fonts list once (cached for
|
||||||
// 5 minutes) and lazily injects @font-face blocks into <head> only for the
|
// 5 minutes) and lazily injects @font-face blocks into <head> only for the
|
||||||
@@ -99,9 +101,30 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
|
|||||||
const [themeName, setThemeName] = useState(initialThemeName);
|
const [themeName, setThemeName] = useState(initialThemeName);
|
||||||
const [resolvedColorMode, setResolvedColorMode] = useState<'light' | 'dark'>(() => resolveColorMode(initialTheme.colorMode));
|
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;
|
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.
|
// Apply CSS variables — 8-token CI palette.
|
||||||
// Legacy --color-primary / --color-primary-light / --color-primary-dark
|
// Legacy --color-primary / --color-primary-light / --color-primary-dark
|
||||||
// are kept for any consumer still reading them; they mirror accent-dark.
|
// are kept for any consumer still reading them; they mirror accent-dark.
|
||||||
@@ -271,7 +294,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
|
|||||||
}
|
}
|
||||||
styleElement.textContent = themeConfig.customCss;
|
styleElement.textContent = themeConfig.customCss;
|
||||||
}
|
}
|
||||||
}, []);
|
}, [forcedMode]);
|
||||||
|
|
||||||
const setThemeConfig = useCallback((newTheme: ThemeConfig) => {
|
const setThemeConfig = useCallback((newTheme: ThemeConfig) => {
|
||||||
setTheme(newTheme);
|
setTheme(newTheme);
|
||||||
@@ -291,15 +314,11 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
|
|||||||
setThemeByName('default');
|
setThemeByName('default');
|
||||||
}, [setThemeByName]);
|
}, [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(() => {
|
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]);
|
}, [theme, applyTheme]);
|
||||||
|
|
||||||
// Load theme from localStorage on mount (skip if in gallery view)
|
// Load theme from localStorage on mount (skip if in gallery view)
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { GallerySkeleton } from '../components/gallery/GallerySkeleton';
|
|||||||
import { analyticsService } from '../services/analytics.service';
|
import { analyticsService } from '../services/analytics.service';
|
||||||
import { galleryService } from '../services';
|
import { galleryService } from '../services';
|
||||||
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
|
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
|
||||||
import { applyForceColorMode } from '../utils/themeMigration';
|
|
||||||
import { buildResourceUrl } from '../utils/url';
|
import { buildResourceUrl } from '../utils/url';
|
||||||
import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl';
|
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).
|
// Apply theme. Force color mode is enforced inside ThemeContext.applyTheme
|
||||||
// applyForceColorMode pins colorMode AND swaps surface/text tokens
|
// (it subscribes to public settings) so callers don't have to wrap the
|
||||||
// when the active theme doesn't natively support the locked mode,
|
// theme themselves — keeps the lock consistent across every entry point.
|
||||||
// so the gallery actually flips visually (#397 follow-up).
|
|
||||||
if (themeToApply) {
|
|
||||||
themeToApply = applyForceColorMode(themeToApply, settingsData.branding_force_color_mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply theme
|
|
||||||
if (themeToApply) {
|
if (themeToApply) {
|
||||||
setTheme(themeToApply);
|
setTheme(themeToApply);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user