mirror of
https://github.com/readur/readur.git
synced 2026-02-09 08:29:38 -06:00
feat(client): add dark mode and fix search bar results going out of bounds
This commit is contained in:
@@ -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" />} />
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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}
|
||||
|
||||
45
frontend/src/components/ThemeToggle/ThemeToggle.tsx
Normal file
45
frontend/src/components/ThemeToggle/ThemeToggle.tsx
Normal 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;
|
||||
158
frontend/src/contexts/ThemeContext.tsx
Normal file
158
frontend/src/contexts/ThemeContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user