feat(branding): force color mode (dark or light) site-wide
This commit is contained in:
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user