feat(client): add dark mode and fix search bar results going out of bounds

This commit is contained in:
perf3ct
2025-06-13 21:33:35 +00:00
parent ce5d8411c4
commit 6c0e381de2
5 changed files with 281 additions and 22 deletions

View File

@@ -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 (
<ThemeProvider theme={theme}>
<ThemeProvider>
<CssBaseline />
<div style={{
minHeight: '100vh',
@@ -48,7 +47,7 @@ function App(): JSX.Element {
}
return (
<ThemeProvider theme={theme}>
<ThemeProvider>
<CssBaseline />
<Routes>
<Route path="/login" element={!user ? <Login /> : <Navigate to="/dashboard" />} />

View File

@@ -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<GlobalSearchBarProps> = ({ sx, ...props }) => {
const navigate = useNavigate();
const theme = useTheme();
const [query, setQuery] = useState<string>('');
const [results, setResults] = useState<Document[]>([]);
const [loading, setLoading] = useState<boolean>(false);
@@ -378,20 +380,30 @@ const GlobalSearchBar: React.FC<GlobalSearchBarProps> = ({ 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<GlobalSearchBarProps> = ({ 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<GlobalSearchBarProps> = ({ 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,
}}

View File

@@ -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<AppLayoutProps> = ({ children }) => {
const theme = useTheme();
const theme = useMuiTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [mobileOpen, setMobileOpen] = useState<boolean>(false);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
@@ -93,15 +94,23 @@ const AppLayout: React.FC<AppLayoutProps> = ({ 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 */}
<Box sx={{
p: 3,
borderBottom: '1px solid rgba(226,232,240,0.3)',
background: 'linear-gradient(135deg, rgba(99,102,241,0.05) 0%, rgba(139,92,246,0.05) 100%)',
borderBottom: theme.palette.mode === 'light'
? '1px solid rgba(226,232,240,0.3)'
: '1px solid rgba(255,255,255,0.1)',
background: theme.palette.mode === 'light'
? 'linear-gradient(135deg, rgba(99,102,241,0.05) 0%, rgba(139,92,246,0.05) 100%)'
: 'linear-gradient(135deg, rgba(99,102,241,0.1) 0%, rgba(139,92,246,0.1) 100%)',
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box
@@ -303,10 +312,16 @@ const AppLayout: React.FC<AppLayoutProps> = ({ 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)',
}}
>
<Toolbar>
@@ -371,6 +386,27 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
</Badge>
</IconButton>
{/* Theme Toggle */}
<Box sx={{
mr: 2,
background: theme.palette.mode === 'light'
? 'linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(248,250,252,0.6) 100%)'
: 'linear-gradient(135deg, rgba(50,50,50,0.8) 0%, rgba(30,30,30,0.6) 100%)',
backdropFilter: 'blur(10px)',
border: theme.palette.mode === 'light'
? '1px solid rgba(255,255,255,0.3)'
: '1px solid rgba(255,255,255,0.1)',
borderRadius: 2.5,
transition: 'all 0.2s ease-in-out',
'&:hover': {
background: 'linear-gradient(135deg, rgba(99,102,241,0.1) 0%, rgba(139,92,246,0.1) 100%)',
transform: 'translateY(-2px)',
boxShadow: '0 8px 24px rgba(99,102,241,0.15)',
},
}}>
<ThemeToggle size="medium" color="inherit" />
</Box>
{/* Profile Menu */}
<IconButton
onClick={handleProfileMenuOpen}

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { IconButton, Tooltip, Box } from '@mui/material';
import { Brightness4, Brightness7 } from '@mui/icons-material';
import { useTheme } from '../../contexts/ThemeContext';
interface ThemeToggleProps {
size?: 'small' | 'medium' | 'large';
color?: 'inherit' | 'primary' | 'secondary' | 'default';
}
const ThemeToggle: React.FC<ThemeToggleProps> = ({
size = 'medium',
color = 'inherit'
}) => {
const { mode, toggleTheme } = useTheme();
return (
<Tooltip title={`Switch to ${mode === 'light' ? 'dark' : 'light'} mode`}>
<IconButton
onClick={toggleTheme}
color={color}
size={size}
sx={{
transition: 'all 0.3s ease-in-out',
'&:hover': {
transform: 'rotate(180deg)',
},
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'transform 0.3s ease-in-out',
}}
>
{mode === 'light' ? <Brightness4 /> : <Brightness7 />}
</Box>
</IconButton>
</Tooltip>
);
};
export default ThemeToggle;

View File

@@ -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<ThemeContextType | undefined>(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<ThemeProviderProps> = ({ children }) => {
const [mode, setMode] = useState<PaletteMode>(() => {
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 (
<ThemeContext.Provider value={{ mode, toggleTheme }}>
<MuiThemeProvider theme={theme}>
{children}
</MuiThemeProvider>
</ThemeContext.Provider>
);
};