feat(branding): force color mode (dark or light) site-wide

This commit is contained in:
Luca
2026-05-05 16:06:38 +02:00
parent 114aab5777
commit 5a162fc8be
11 changed files with 211 additions and 21 deletions
+11 -2
View File
@@ -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
+8
View File
@@ -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]);
+12 -9
View File
@@ -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
+43 -8
View File
@@ -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}>
+7
View File
@@ -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);
+42
View File
@@ -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;
+12 -1
View File
@@ -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
};
},
+55
View File
@@ -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();
});
});