feat: navigation and UX improvements complete
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* @ai-summary Theme context for managing light/dark mode across the app
|
||||
* Uses same localStorage key as useSettings for consistency
|
||||
* Supports: system preference detection, localStorage persistence, backend sync
|
||||
* Applies Tailwind dark class to document root
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useState, useCallback, ReactNode } from 'react';
|
||||
import { createContext, useContext, useEffect, useMemo, useState, useCallback, ReactNode, useRef } from 'react';
|
||||
import { ThemeProvider as MuiThemeProvider, Theme } from '@mui/material/styles';
|
||||
import { CssBaseline } from '@mui/material';
|
||||
import { md3LightTheme, md3DarkTheme } from './md3Theme';
|
||||
@@ -15,8 +15,10 @@ const SETTINGS_STORAGE_KEY = 'motovaultpro-mobile-settings';
|
||||
interface ThemeContextValue {
|
||||
isDarkMode: boolean;
|
||||
toggleDarkMode: () => void;
|
||||
setDarkMode: (value: boolean) => void;
|
||||
setDarkMode: (value: boolean, syncToBackend?: boolean) => void;
|
||||
theme: Theme;
|
||||
// Callback to register backend sync function
|
||||
registerBackendSync: (syncFn: (darkMode: boolean) => void) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
@@ -25,13 +27,21 @@ interface ThemeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Read dark mode preference from localStorage (synced with useSettings)
|
||||
const getStoredDarkMode = (): boolean => {
|
||||
// Detect system dark mode preference
|
||||
const getSystemPreference = (): boolean => {
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Check if user has explicitly set a preference in localStorage
|
||||
const hasStoredPreference = (): boolean => {
|
||||
try {
|
||||
const stored = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const settings = JSON.parse(stored);
|
||||
return settings.darkMode ?? false;
|
||||
return settings.darkMode !== undefined && settings.darkMode !== null;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
@@ -39,6 +49,31 @@ const getStoredDarkMode = (): boolean => {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Read dark mode preference from localStorage
|
||||
const getStoredDarkMode = (): boolean | undefined => {
|
||||
try {
|
||||
const stored = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const settings = JSON.parse(stored);
|
||||
if (settings.darkMode !== undefined && settings.darkMode !== null) {
|
||||
return settings.darkMode;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Get initial dark mode: localStorage > system preference
|
||||
const getInitialDarkMode = (): boolean => {
|
||||
const stored = getStoredDarkMode();
|
||||
if (stored !== undefined) {
|
||||
return stored;
|
||||
}
|
||||
return getSystemPreference();
|
||||
};
|
||||
|
||||
// Update dark mode in localStorage while preserving other settings
|
||||
const setStoredDarkMode = (darkMode: boolean): void => {
|
||||
try {
|
||||
@@ -52,15 +87,33 @@ const setStoredDarkMode = (darkMode: boolean): void => {
|
||||
};
|
||||
|
||||
export const ThemeProvider = ({ children }: ThemeProviderProps) => {
|
||||
const [isDarkMode, setIsDarkMode] = useState<boolean>(getStoredDarkMode);
|
||||
const [isDarkMode, setIsDarkMode] = useState<boolean>(getInitialDarkMode);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const backendSyncRef = useRef<((darkMode: boolean) => void) | null>(null);
|
||||
|
||||
// Initialize on mount
|
||||
useEffect(() => {
|
||||
setIsDarkMode(getStoredDarkMode());
|
||||
setIsDarkMode(getInitialDarkMode());
|
||||
setIsInitialized(true);
|
||||
}, []);
|
||||
|
||||
// Listen for system preference changes (only when no explicit preference set)
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
// Only react to system changes if user hasn't set explicit preference
|
||||
if (!hasStoredPreference()) {
|
||||
setIsDarkMode(e.matches);
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, []);
|
||||
|
||||
// Apply dark class to document root for Tailwind
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
@@ -90,13 +143,23 @@ export const ThemeProvider = ({ children }: ThemeProviderProps) => {
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, []);
|
||||
|
||||
const setDarkMode = useCallback((value: boolean) => {
|
||||
// Register backend sync function (called by useThemeSync hook)
|
||||
const registerBackendSync = useCallback((syncFn: (darkMode: boolean) => void) => {
|
||||
backendSyncRef.current = syncFn;
|
||||
}, []);
|
||||
|
||||
const setDarkMode = useCallback((value: boolean, syncToBackend = true) => {
|
||||
setIsDarkMode(value);
|
||||
setStoredDarkMode(value);
|
||||
|
||||
// Sync to backend if registered and requested
|
||||
if (syncToBackend && backendSyncRef.current) {
|
||||
backendSyncRef.current(value);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleDarkMode = useCallback(() => {
|
||||
setDarkMode(!isDarkMode);
|
||||
setDarkMode(!isDarkMode, true);
|
||||
}, [isDarkMode, setDarkMode]);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
@@ -109,8 +172,9 @@ export const ThemeProvider = ({ children }: ThemeProviderProps) => {
|
||||
toggleDarkMode,
|
||||
setDarkMode,
|
||||
theme,
|
||||
registerBackendSync,
|
||||
}),
|
||||
[isDarkMode, toggleDarkMode, setDarkMode, theme]
|
||||
[isDarkMode, toggleDarkMode, setDarkMode, theme, registerBackendSync]
|
||||
);
|
||||
|
||||
// Prevent flash of wrong theme during SSR/initial load
|
||||
|
||||
66
frontend/src/shared-minimal/theme/useThemeSync.ts
Normal file
66
frontend/src/shared-minimal/theme/useThemeSync.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @ai-summary Hook to sync dark mode preference with backend
|
||||
* Load from backend on auth, sync changes to backend on toggle
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { usePreferences, useUpdatePreferences } from '../../features/settings/hooks/usePreferences';
|
||||
import { useTheme } from './ThemeContext';
|
||||
|
||||
/**
|
||||
* Syncs theme preference with backend.
|
||||
* Place this hook in an authenticated component (e.g., Layout or App authenticated section).
|
||||
*
|
||||
* Behavior:
|
||||
* - On auth: loads darkMode from backend and updates ThemeContext
|
||||
* - On toggle: syncs new darkMode value to backend
|
||||
*/
|
||||
export const useThemeSync = () => {
|
||||
const { isAuthenticated } = useAuth0();
|
||||
const { data: preferences, isLoading } = usePreferences();
|
||||
const updatePreferences = useUpdatePreferences();
|
||||
const { setDarkMode, registerBackendSync, isDarkMode } = useTheme();
|
||||
const hasLoadedFromBackend = useRef(false);
|
||||
|
||||
// Register backend sync function
|
||||
useEffect(() => {
|
||||
const syncToBackend = (darkMode: boolean) => {
|
||||
if (isAuthenticated) {
|
||||
updatePreferences.mutate({ darkMode });
|
||||
}
|
||||
};
|
||||
|
||||
registerBackendSync(syncToBackend);
|
||||
}, [isAuthenticated, updatePreferences, registerBackendSync]);
|
||||
|
||||
// Load from backend on initial auth (only once)
|
||||
useEffect(() => {
|
||||
if (
|
||||
isAuthenticated &&
|
||||
!isLoading &&
|
||||
preferences &&
|
||||
!hasLoadedFromBackend.current
|
||||
) {
|
||||
hasLoadedFromBackend.current = true;
|
||||
|
||||
// Only update if backend has an explicit preference
|
||||
if (preferences.darkMode !== null && preferences.darkMode !== undefined) {
|
||||
// Update local state without syncing back to backend
|
||||
setDarkMode(preferences.darkMode, false);
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, isLoading, preferences, setDarkMode]);
|
||||
|
||||
// Reset flag when user logs out
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
hasLoadedFromBackend.current = false;
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
return {
|
||||
isDarkMode,
|
||||
isLoading: isLoading && isAuthenticated,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user