feat: navigation and UX improvements complete

This commit is contained in:
Eric Gullickson
2025-12-26 09:25:42 -06:00
parent 50baec390f
commit 8c13dc0a55
23 changed files with 327 additions and 126 deletions

View File

@@ -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

View 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,
};
};