feat: dark / light theme almost complete
This commit is contained in:
140
frontend/src/shared-minimal/theme/ThemeContext.tsx
Normal file
140
frontend/src/shared-minimal/theme/ThemeContext.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @ai-summary Theme context for managing light/dark mode across the app
|
||||
* Uses same localStorage key as useSettings for consistency
|
||||
* Applies Tailwind dark class to document root
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useState, useCallback, ReactNode } from 'react';
|
||||
import { ThemeProvider as MuiThemeProvider, Theme } from '@mui/material/styles';
|
||||
import { CssBaseline } from '@mui/material';
|
||||
import { md3LightTheme, md3DarkTheme } from './md3Theme';
|
||||
|
||||
// Same storage key as useSettingsPersistence for consistency
|
||||
const SETTINGS_STORAGE_KEY = 'motovaultpro-mobile-settings';
|
||||
|
||||
interface ThemeContextValue {
|
||||
isDarkMode: boolean;
|
||||
toggleDarkMode: () => void;
|
||||
setDarkMode: (value: boolean) => void;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Read dark mode preference from localStorage (synced with useSettings)
|
||||
const getStoredDarkMode = (): boolean => {
|
||||
try {
|
||||
const stored = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const settings = JSON.parse(stored);
|
||||
return settings.darkMode ?? false;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Update dark mode in localStorage while preserving other settings
|
||||
const setStoredDarkMode = (darkMode: boolean): void => {
|
||||
try {
|
||||
const stored = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||
const settings = stored ? JSON.parse(stored) : {};
|
||||
settings.darkMode = darkMode;
|
||||
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
};
|
||||
|
||||
export const ThemeProvider = ({ children }: ThemeProviderProps) => {
|
||||
const [isDarkMode, setIsDarkMode] = useState<boolean>(getStoredDarkMode);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// Initialize on mount
|
||||
useEffect(() => {
|
||||
setIsDarkMode(getStoredDarkMode());
|
||||
setIsInitialized(true);
|
||||
}, []);
|
||||
|
||||
// Apply dark class to document root for Tailwind
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (isDarkMode) {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
}, [isDarkMode]);
|
||||
|
||||
// Listen for storage changes from other tabs/components
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === SETTINGS_STORAGE_KEY && e.newValue) {
|
||||
try {
|
||||
const settings = JSON.parse(e.newValue);
|
||||
if (typeof settings.darkMode === 'boolean') {
|
||||
setIsDarkMode(settings.darkMode);
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, []);
|
||||
|
||||
const setDarkMode = useCallback((value: boolean) => {
|
||||
setIsDarkMode(value);
|
||||
setStoredDarkMode(value);
|
||||
}, []);
|
||||
|
||||
const toggleDarkMode = useCallback(() => {
|
||||
setDarkMode(!isDarkMode);
|
||||
}, [isDarkMode, setDarkMode]);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
return isDarkMode ? md3DarkTheme : md3LightTheme;
|
||||
}, [isDarkMode]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
isDarkMode,
|
||||
toggleDarkMode,
|
||||
setDarkMode,
|
||||
theme,
|
||||
}),
|
||||
[isDarkMode, toggleDarkMode, setDarkMode, theme]
|
||||
);
|
||||
|
||||
// Prevent flash of wrong theme during SSR/initial load
|
||||
if (!isInitialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>
|
||||
<MuiThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</MuiThemeProvider>
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTheme = (): ThemeContextValue => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Re-export for convenience
|
||||
export { md3LightTheme, md3DarkTheme } from './md3Theme';
|
||||
@@ -1,35 +1,29 @@
|
||||
/**
|
||||
* @ai-summary Material Design 3 theme configuration for MotoVaultPro
|
||||
* Supports both light and dark modes
|
||||
*/
|
||||
|
||||
import { createTheme, alpha } from '@mui/material/styles';
|
||||
import { createTheme, alpha, ThemeOptions } from '@mui/material/styles';
|
||||
|
||||
// Brand color from mockup
|
||||
// Brand colors
|
||||
const primaryHex = '#7A212A';
|
||||
|
||||
export const md3Theme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: primaryHex
|
||||
},
|
||||
secondary: {
|
||||
main: alpha(primaryHex, 0.8)
|
||||
},
|
||||
background: {
|
||||
default: '#F8F5F3',
|
||||
paper: '#FFFFFF'
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 16
|
||||
// Dark theme palette (Ferrari-inspired)
|
||||
const nero = '#231F1C'; // Nero Daytona - page base
|
||||
const avus = '#F2F3F6'; // Bianco Avus - primary text
|
||||
const titanio = '#A8B8C0'; // Grigio Titanio - secondary text
|
||||
|
||||
// Shared theme options
|
||||
const baseThemeOptions: ThemeOptions = {
|
||||
shape: {
|
||||
borderRadius: 16
|
||||
},
|
||||
typography: {
|
||||
fontFamily:
|
||||
'Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif',
|
||||
h3: {
|
||||
fontWeight: 700,
|
||||
letterSpacing: -0.5
|
||||
h3: {
|
||||
fontWeight: 700,
|
||||
letterSpacing: -0.5
|
||||
},
|
||||
},
|
||||
components: {
|
||||
@@ -37,8 +31,6 @@ export const md3Theme = createTheme({
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 20,
|
||||
boxShadow:
|
||||
'0 1px 2px rgba(16,24,40,.04), 0 4px 16px rgba(16,24,40,.06)',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -54,8 +46,8 @@ export const md3Theme = createTheme({
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
paddingInline: 18,
|
||||
'&:hover': {
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.18)
|
||||
'&:hover': {
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.18)
|
||||
},
|
||||
}),
|
||||
},
|
||||
@@ -71,6 +63,47 @@ export const md3Theme = createTheme({
|
||||
},
|
||||
],
|
||||
},
|
||||
MuiBottomNavigationAction: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
minWidth: 0,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
'&.Mui-selected': {
|
||||
color: primaryHex
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Light theme
|
||||
export const md3LightTheme = createTheme({
|
||||
...baseThemeOptions,
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: { main: primaryHex },
|
||||
secondary: { main: alpha(primaryHex, 0.8) },
|
||||
background: {
|
||||
default: '#F8F5F3',
|
||||
paper: '#FFFFFF'
|
||||
},
|
||||
text: {
|
||||
primary: '#1a1a1a',
|
||||
secondary: '#666666',
|
||||
},
|
||||
},
|
||||
components: {
|
||||
...baseThemeOptions.components,
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 20,
|
||||
boxShadow: '0 1px 2px rgba(16,24,40,.04), 0 4px 16px rgba(16,24,40,.06)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiBottomNavigation: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
@@ -80,17 +113,48 @@ export const md3Theme = createTheme({
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiBottomNavigationAction: {
|
||||
},
|
||||
});
|
||||
|
||||
// Dark theme
|
||||
export const md3DarkTheme = createTheme({
|
||||
...baseThemeOptions,
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: { main: primaryHex },
|
||||
secondary: { main: alpha(primaryHex, 0.8) },
|
||||
background: {
|
||||
default: nero,
|
||||
paper: '#1D1A18'
|
||||
},
|
||||
text: {
|
||||
primary: avus,
|
||||
secondary: titanio,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
...baseThemeOptions.components,
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
minWidth: 0,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
'&.Mui-selected': {
|
||||
color: primaryHex
|
||||
},
|
||||
borderRadius: 20,
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,.2), 0 4px 16px rgba(0,0,0,.3)',
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiBottomNavigation: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderTop: '1px solid rgba(255,255,255,.1)',
|
||||
background: alpha(nero, 0.9),
|
||||
backdropFilter: 'blur(8px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Default export for backward compatibility
|
||||
export const md3Theme = md3LightTheme;
|
||||
|
||||
Reference in New Issue
Block a user