From 6c0e381de226bb113a94b76b4cbbb1b4b3b5f573 Mon Sep 17 00:00:00 2001 From: perf3ct Date: Fri, 13 Jun 2025 21:33:35 +0000 Subject: [PATCH] feat(client): add dark mode and fix search bar results going out of bounds --- frontend/src/App.tsx | 7 +- .../GlobalSearchBar/GlobalSearchBar.tsx | 39 ++++- frontend/src/components/Layout/AppLayout.tsx | 54 +++++- .../components/ThemeToggle/ThemeToggle.tsx | 45 +++++ frontend/src/contexts/ThemeContext.tsx | 158 ++++++++++++++++++ 5 files changed, 281 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/ThemeToggle/ThemeToggle.tsx create mode 100644 frontend/src/contexts/ThemeContext.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9ea8e84..f59cceb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,9 +1,8 @@ import React from 'react'; import { Routes, Route, Navigate } from 'react-router-dom'; -import { ThemeProvider } from '@mui/material/styles'; import { CssBaseline } from '@mui/material'; import { useAuth } from './contexts/AuthContext'; -import theme from './theme'; +import { ThemeProvider } from './contexts/ThemeContext'; import Login from './components/Auth/Login'; import AppLayout from './components/Layout/AppLayout'; import Dashboard from './components/Dashboard/Dashboard'; @@ -19,7 +18,7 @@ function App(): JSX.Element { if (loading) { return ( - +
+ : } /> diff --git a/frontend/src/components/GlobalSearchBar/GlobalSearchBar.tsx b/frontend/src/components/GlobalSearchBar/GlobalSearchBar.tsx index adbe6da..b0244bb 100644 --- a/frontend/src/components/GlobalSearchBar/GlobalSearchBar.tsx +++ b/frontend/src/components/GlobalSearchBar/GlobalSearchBar.tsx @@ -20,6 +20,7 @@ import { Skeleton, SxProps, Theme, + useTheme, } from '@mui/material'; import { Search as SearchIcon, @@ -58,6 +59,7 @@ interface SearchResponse { const GlobalSearchBar: React.FC = ({ sx, ...props }) => { const navigate = useNavigate(); + const theme = useTheme(); const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); @@ -378,20 +380,30 @@ const GlobalSearchBar: React.FC = ({ sx, ...props }) => { minWidth: 320, maxWidth: 420, '& .MuiOutlinedInput-root': { - background: 'linear-gradient(135deg, rgba(255,255,255,0.95) 0%, rgba(248,250,252,0.90) 100%)', + background: theme.palette.mode === 'light' + ? 'linear-gradient(135deg, rgba(255,255,255,0.95) 0%, rgba(248,250,252,0.90) 100%)' + : 'linear-gradient(135deg, rgba(50,50,50,0.95) 0%, rgba(30,30,30,0.90) 100%)', backdropFilter: 'blur(20px)', - border: '1px solid rgba(226,232,240,0.5)', + border: theme.palette.mode === 'light' + ? '1px solid rgba(226,232,240,0.5)' + : '1px solid rgba(255,255,255,0.1)', borderRadius: 3, transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', - boxShadow: '0 4px 16px rgba(0,0,0,0.04)', + boxShadow: theme.palette.mode === 'light' + ? '0 4px 16px rgba(0,0,0,0.04)' + : '0 4px 16px rgba(0,0,0,0.2)', '&:hover': { - background: 'linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(248,250,252,0.95) 100%)', + background: theme.palette.mode === 'light' + ? 'linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(248,250,252,0.95) 100%)' + : 'linear-gradient(135deg, rgba(60,60,60,0.98) 0%, rgba(40,40,40,0.95) 100%)', borderColor: 'rgba(99,102,241,0.4)', transform: 'translateY(-2px)', boxShadow: '0 8px 32px rgba(99,102,241,0.15)', }, '&.Mui-focused': { - background: 'linear-gradient(135deg, rgba(255,255,255,1) 0%, rgba(248,250,252,0.98) 100%)', + background: theme.palette.mode === 'light' + ? 'linear-gradient(135deg, rgba(255,255,255,1) 0%, rgba(248,250,252,0.98) 100%)' + : 'linear-gradient(135deg, rgba(70,70,70,1) 0%, rgba(50,50,50,0.98) 100%)', borderColor: '#6366f1', borderWidth: 2, transform: 'translateY(-2px)', @@ -401,8 +413,11 @@ const GlobalSearchBar: React.FC = ({ sx, ...props }) => { fontWeight: 500, letterSpacing: '0.025em', fontSize: '0.95rem', + color: theme.palette.text.primary, '&::placeholder': { - color: 'rgba(148,163,184,0.8)', + color: theme.palette.mode === 'light' + ? 'rgba(148,163,184,0.8)' + : 'rgba(200,200,200,0.6)', fontWeight: 400, }, }, @@ -446,11 +461,17 @@ const GlobalSearchBar: React.FC = ({ sx, ...props }) => { maxHeight: 420, overflowY: 'auto', overflowX: 'hidden', - background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(248,250,252,0.95) 100%)', + background: theme.palette.mode === 'light' + ? 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(248,250,252,0.95) 100%)' + : 'linear-gradient(180deg, rgba(40,40,40,0.98) 0%, rgba(25,25,25,0.95) 100%)', backdropFilter: 'blur(24px)', - border: '1px solid rgba(226,232,240,0.6)', + border: theme.palette.mode === 'light' + ? '1px solid rgba(226,232,240,0.6)' + : '1px solid rgba(255,255,255,0.1)', borderRadius: 3, - boxShadow: '0 20px 60px rgba(0,0,0,0.12), 0 8px 25px rgba(0,0,0,0.08)', + boxShadow: theme.palette.mode === 'light' + ? '0 20px 60px rgba(0,0,0,0.12), 0 8px 25px rgba(0,0,0,0.08)' + : '0 20px 60px rgba(0,0,0,0.4), 0 8px 25px rgba(0,0,0,0.3)', width: '100%', minWidth: 0, }} diff --git a/frontend/src/components/Layout/AppLayout.tsx b/frontend/src/components/Layout/AppLayout.tsx index bb76449..0d02908 100644 --- a/frontend/src/components/Layout/AppLayout.tsx +++ b/frontend/src/components/Layout/AppLayout.tsx @@ -16,7 +16,7 @@ import { Menu, MenuItem, Divider, - useTheme, + useTheme as useMuiTheme, useMediaQuery, Badge, } from '@mui/material'; @@ -35,6 +35,7 @@ import { import { useNavigate, useLocation } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; import GlobalSearchBar from '../GlobalSearchBar'; +import ThemeToggle from '../ThemeToggle/ThemeToggle'; const drawerWidth = 280; @@ -62,7 +63,7 @@ const navigationItems: NavigationItem[] = [ ]; const AppLayout: React.FC = ({ children }) => { - const theme = useTheme(); + const theme = useMuiTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); const [mobileOpen, setMobileOpen] = useState(false); const [anchorEl, setAnchorEl] = useState(null); @@ -93,15 +94,23 @@ const AppLayout: React.FC = ({ children }) => { height: '100%', display: 'flex', flexDirection: 'column', - background: 'linear-gradient(180deg, rgba(255,255,255,0.95) 0%, rgba(248,250,252,0.95) 100%)', + background: theme.palette.mode === 'light' + ? 'linear-gradient(180deg, rgba(255,255,255,0.95) 0%, rgba(248,250,252,0.95) 100%)' + : 'linear-gradient(180deg, rgba(30,30,30,0.95) 0%, rgba(18,18,18,0.95) 100%)', backdropFilter: 'blur(20px)', - borderRight: '1px solid rgba(226,232,240,0.5)', + borderRight: theme.palette.mode === 'light' + ? '1px solid rgba(226,232,240,0.5)' + : '1px solid rgba(255,255,255,0.1)', }}> {/* Logo Section */} = ({ children }) => { sx={{ width: { md: `calc(100% - ${drawerWidth}px)` }, ml: { md: `${drawerWidth}px` }, - background: 'linear-gradient(135deg, rgba(255,255,255,0.95) 0%, rgba(248,250,252,0.90) 100%)', + background: theme.palette.mode === 'light' + ? 'linear-gradient(135deg, rgba(255,255,255,0.95) 0%, rgba(248,250,252,0.90) 100%)' + : 'linear-gradient(135deg, rgba(30,30,30,0.95) 0%, rgba(18,18,18,0.90) 100%)', backdropFilter: 'blur(20px)', - borderBottom: '1px solid rgba(226,232,240,0.5)', - boxShadow: '0 4px 32px rgba(0,0,0,0.04)', + borderBottom: theme.palette.mode === 'light' + ? '1px solid rgba(226,232,240,0.5)' + : '1px solid rgba(255,255,255,0.1)', + boxShadow: theme.palette.mode === 'light' + ? '0 4px 32px rgba(0,0,0,0.04)' + : '0 4px 32px rgba(0,0,0,0.2)', }} > @@ -371,6 +386,27 @@ const AppLayout: React.FC = ({ children }) => { + {/* Theme Toggle */} + + + + {/* Profile Menu */} = ({ + size = 'medium', + color = 'inherit' +}) => { + const { mode, toggleTheme } = useTheme(); + + return ( + + + + {mode === 'light' ? : } + + + + ); +}; + +export default ThemeToggle; \ No newline at end of file diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000..4027be4 --- /dev/null +++ b/frontend/src/contexts/ThemeContext.tsx @@ -0,0 +1,158 @@ +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import { createTheme, Theme, ThemeProvider as MuiThemeProvider } from '@mui/material/styles'; +import { PaletteMode } from '@mui/material'; + +interface ThemeContextType { + mode: PaletteMode; + toggleTheme: () => void; +} + +const ThemeContext = createContext(undefined); + +export const useTheme = (): ThemeContextType => { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +}; + +interface ThemeProviderProps { + children: ReactNode; +} + +const createAppTheme = (mode: PaletteMode): Theme => { + return createTheme({ + palette: { + mode, + primary: { + main: '#667eea', + light: '#9bb5ff', + dark: '#304ffe', + }, + secondary: { + main: '#764ba2', + light: '#a777d9', + dark: '#4c1e74', + }, + background: { + default: mode === 'light' ? '#fafafa' : '#121212', + paper: mode === 'light' ? '#ffffff' : '#1e1e1e', + }, + text: { + primary: mode === 'light' ? '#333333' : '#ffffff', + secondary: mode === 'light' ? '#666666' : '#b0b0b0', + }, + }, + typography: { + fontFamily: [ + '-apple-system', + 'BlinkMacSystemFont', + '"Segoe UI"', + 'Roboto', + '"Helvetica Neue"', + 'Arial', + 'sans-serif', + ].join(','), + h4: { + fontWeight: 600, + }, + h5: { + fontWeight: 600, + }, + h6: { + fontWeight: 600, + }, + }, + components: { + MuiButton: { + styleOverrides: { + root: { + textTransform: 'none', + borderRadius: 8, + }, + }, + }, + MuiCard: { + styleOverrides: { + root: { + borderRadius: 12, + boxShadow: mode === 'light' + ? '0 2px 8px rgba(0,0,0,0.1)' + : '0 2px 8px rgba(0,0,0,0.3)', + backgroundColor: mode === 'light' ? '#ffffff' : '#1e1e1e', + }, + }, + }, + MuiPaper: { + styleOverrides: { + root: { + borderRadius: 8, + backgroundColor: mode === 'light' ? '#ffffff' : '#1e1e1e', + }, + }, + }, + MuiAppBar: { + styleOverrides: { + root: { + backgroundColor: mode === 'light' + ? 'rgba(255, 255, 255, 0.95)' + : 'rgba(30, 30, 30, 0.95)', + backdropFilter: 'blur(20px)', + }, + }, + }, + MuiDrawer: { + styleOverrides: { + paper: { + backgroundColor: mode === 'light' ? '#ffffff' : '#1e1e1e', + borderRight: mode === 'light' + ? '1px solid rgba(0, 0, 0, 0.12)' + : '1px solid rgba(255, 255, 255, 0.12)', + }, + }, + }, + }, + }); +}; + +export const ThemeProvider: React.FC = ({ children }) => { + const [mode, setMode] = useState(() => { + const savedMode = localStorage.getItem('themeMode'); + if (savedMode === 'light' || savedMode === 'dark') { + return savedMode; + } + // Default to system preference or light mode + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + }); + + const toggleTheme = () => { + const newMode = mode === 'light' ? 'dark' : 'light'; + setMode(newMode); + localStorage.setItem('themeMode', newMode); + }; + + const theme = createAppTheme(mode); + + // Listen for system theme changes + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = (e: MediaQueryListEvent) => { + // Only update if user hasn't manually set a preference + if (!localStorage.getItem('themeMode')) { + setMode(e.matches ? 'dark' : 'light'); + } + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, []); + + return ( + + + {children} + + + ); +}; \ No newline at end of file