feat(branding): force color mode (dark or light) site-wide
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,7 +18,14 @@ export const GlobalThemeProvider: React.FC<GlobalThemeProviderProps> = ({ 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]);
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ interface AdminHeaderProps {
|
||||
export const AdminHeader: React.FC<AdminHeaderProps> = ({ 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<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
{/* Language Selector */}
|
||||
<LanguageSelector />
|
||||
|
||||
{/* Dark Mode Toggle */}
|
||||
<button
|
||||
onClick={toggleDarkMode}
|
||||
className="p-2 text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
|
||||
title={isDark ? t('admin.lightMode', 'Switch to light mode') : t('admin.darkMode', 'Switch to dark mode')}
|
||||
>
|
||||
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||
</button>
|
||||
{/* Dark Mode Toggle — hidden entirely when an admin has locked
|
||||
the instance to a specific mode via Branding > Force color mode. */}
|
||||
{!forcedMode && (
|
||||
<button
|
||||
onClick={toggleDarkMode}
|
||||
className="p-2 text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
|
||||
title={isDark ? t('admin.lightMode', 'Switch to light mode') : t('admin.darkMode', 'Switch to dark mode')}
|
||||
>
|
||||
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Notifications */}
|
||||
<div className="relative" ref={notificationRef}>
|
||||
|
||||
@@ -341,6 +341,13 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ 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
|
||||
|
||||
@@ -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<DarkModePreference>(() => {
|
||||
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 (
|
||||
<AdminDarkModeContext.Provider value={value}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<ThemeConfig>(theme);
|
||||
@@ -564,6 +565,47 @@ export const BrandingPage: React.FC = () => {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* 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.
|
||||
*/}
|
||||
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h3 className="text-md font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
|
||||
{t('branding.forceColorMode', 'Force color mode')}
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-600 dark:text-neutral-400 mb-3">
|
||||
{t(
|
||||
'branding.forceColorModeHelp',
|
||||
'Lock the entire admin and public site to dark or light. The user-facing toggle is hidden when active.'
|
||||
)}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{([
|
||||
{ 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 (
|
||||
<button
|
||||
type="button"
|
||||
key={String(value)}
|
||||
onClick={() => handleBrandingChange('force_color_mode', value)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
|
||||
active
|
||||
? 'bg-primary-600 text-white border-primary-600'
|
||||
: 'bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-200 border-neutral-300 dark:border-neutral-600 hover:bg-neutral-50 dark:hover:bg-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
|
||||
@@ -19,6 +19,12 @@ export interface PublicSettings {
|
||||
branding_logo_display_header?: boolean;
|
||||
branding_logo_display_hero?: boolean;
|
||||
branding_hide_powered_by?: boolean;
|
||||
/**
|
||||
* Force the entire site into a specific color mode (instance-wide).
|
||||
* 'dark' or 'light' override user/system preference; null = no force.
|
||||
* AdminDarkModeContext + ThemeContext both honor this.
|
||||
*/
|
||||
branding_force_color_mode?: 'dark' | 'light' | null;
|
||||
theme_config: any;
|
||||
default_language: string;
|
||||
enable_analytics: boolean;
|
||||
|
||||
@@ -19,6 +19,12 @@ export interface BrandingSettings {
|
||||
logo_display_hero?: boolean;
|
||||
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
|
||||
hide_powered_by?: boolean;
|
||||
/**
|
||||
* Force the entire admin and public site into a specific color mode.
|
||||
* When set, the user-facing dark/light toggle is hidden and any per-theme
|
||||
* `colorMode` override is ignored. `null` means no force (default behavior).
|
||||
*/
|
||||
force_color_mode?: 'dark' | 'light' | null;
|
||||
}
|
||||
|
||||
export interface ThemeSettings {
|
||||
@@ -288,7 +294,12 @@ export const settingsService = {
|
||||
logo_display_header: this._parseBoolean(rawSettings.branding_logo_display_header, true),
|
||||
logo_display_hero: this._parseBoolean(rawSettings.branding_logo_display_hero, true),
|
||||
logo_display_mode: rawSettings.branding_logo_display_mode || 'logo_and_text',
|
||||
hide_powered_by: this._parseBoolean(rawSettings.branding_hide_powered_by, false)
|
||||
hide_powered_by: this._parseBoolean(rawSettings.branding_hide_powered_by, false),
|
||||
force_color_mode: rawSettings.branding_force_color_mode === 'dark'
|
||||
? 'dark'
|
||||
: rawSettings.branding_force_color_mode === 'light'
|
||||
? 'light'
|
||||
: null
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -215,3 +215,58 @@ test.describe('Gallery Theme Color Mode', () => {
|
||||
await expect(page.getByRole('button', { name: /^Auto$/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Force color mode (instance-wide lock)', () => {
|
||||
// The force color mode setting is exposed in Branding > Force color mode.
|
||||
// When set, the user-facing dark/light toggle in the admin header should
|
||||
// disappear entirely. We verify both presence of the controls and that
|
||||
// toggling them hides the header chip.
|
||||
test('force-mode controls are present in Branding settings', async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip('Force color mode validated on desktop viewport');
|
||||
}
|
||||
|
||||
await loginToAdmin(page);
|
||||
|
||||
await page.goto('/admin/branding');
|
||||
await expect(page.getByRole('heading', { name: /Force color mode|Farbmodus erzwingen/i })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// The three states must be selectable as buttons.
|
||||
await expect(page.getByRole('button', { name: /No force|Kein/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Force dark|Dunkel erzwingen/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Force light|Hell erzwingen/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('selecting force-dark hides the header dark mode toggle on next reload', async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip('Force color mode validated on desktop viewport');
|
||||
}
|
||||
|
||||
await loginToAdmin(page);
|
||||
|
||||
// Confirm the toggle is initially visible (no force mode set).
|
||||
let toggle = page.getByRole('button', { name: /dark mode|light mode|Dunkelmodus|Hellmodus/i });
|
||||
await expect(toggle).toBeVisible();
|
||||
|
||||
// Set force-dark via Branding page.
|
||||
await page.goto('/admin/branding');
|
||||
await page.getByRole('button', { name: /Force dark|Dunkel erzwingen/i }).click();
|
||||
await page.getByRole('button', { name: /^Save|^Speichern/i }).first().click();
|
||||
|
||||
// Reload so the public-settings refetch picks up the new value.
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
toggle = page.getByRole('button', { name: /dark mode|light mode|Dunkelmodus|Hellmodus/i });
|
||||
await expect(toggle).toHaveCount(0);
|
||||
|
||||
// The .dark class should be applied to <html>.
|
||||
const html = page.locator('html');
|
||||
await expect(html).toHaveClass(/(^|\s)dark(\s|$)/);
|
||||
|
||||
// Restore: clear the force mode so subsequent test runs aren't affected.
|
||||
await page.goto('/admin/branding');
|
||||
await page.getByRole('button', { name: /No force|Kein/i }).click();
|
||||
await page.getByRole('button', { name: /^Save|^Speichern/i }).first().click();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user