Add theme toggle with light, dark, and system support
- **Header:** Integrate `ThemeToggle` component into the user menu area. - **Theme Provider:** Introduce `ThemeProvider` context for managing and persisting theme preferences. - **New Components:** Add `ThemeToggle` for switching themes and `ThemeProvider` to handle state and system preferences. - Ensure responsive updates and localStorage persistence for user-selected themes.
This commit is contained in:
@@ -22,6 +22,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Settings, LogOut, User, Shield } from 'lucide-react';
|
import { Settings, LogOut, User, Shield } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ThemeToggle } from '@/components/theme';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get user initials for avatar
|
* Get user initials for avatar
|
||||||
@@ -97,8 +98,9 @@ export function Header() {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - User menu */}
|
{/* Right side - Theme toggle and user menu */}
|
||||||
<div className="ml-auto flex items-center space-x-4">
|
<div className="ml-auto flex items-center space-x-2">
|
||||||
|
<ThemeToggle />
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
||||||
|
|||||||
83
frontend/src/components/theme/ThemeProvider.tsx
Normal file
83
frontend/src/components/theme/ThemeProvider.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Theme Provider
|
||||||
|
* Manages light/dark mode with persistence
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
interface ThemeContextValue {
|
||||||
|
theme: Theme;
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
resolvedTheme: 'light' | 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [theme, setThemeState] = useState<Theme>('system');
|
||||||
|
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
|
||||||
|
|
||||||
|
// Initialize theme from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem('theme') as Theme | null;
|
||||||
|
if (stored && ['light', 'dark', 'system'].includes(stored)) {
|
||||||
|
setThemeState(stored);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Apply theme to document and resolve system preference
|
||||||
|
useEffect(() => {
|
||||||
|
const root = window.document.documentElement;
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
|
||||||
|
const applyTheme = () => {
|
||||||
|
let effectiveTheme: 'light' | 'dark';
|
||||||
|
|
||||||
|
if (theme === 'system') {
|
||||||
|
effectiveTheme = mediaQuery.matches ? 'dark' : 'light';
|
||||||
|
} else {
|
||||||
|
effectiveTheme = theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
setResolvedTheme(effectiveTheme);
|
||||||
|
|
||||||
|
root.classList.remove('light', 'dark');
|
||||||
|
root.classList.add(effectiveTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
applyTheme();
|
||||||
|
|
||||||
|
// Listen for system theme changes
|
||||||
|
const handleChange = () => {
|
||||||
|
if (theme === 'system') {
|
||||||
|
applyTheme();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaQuery.addEventListener('change', handleChange);
|
||||||
|
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const setTheme = (newTheme: Theme) => {
|
||||||
|
setThemeState(newTheme);
|
||||||
|
localStorage.setItem('theme', newTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useTheme must be used within ThemeProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
51
frontend/src/components/theme/ThemeToggle.tsx
Normal file
51
frontend/src/components/theme/ThemeToggle.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Theme Toggle Button
|
||||||
|
* Switches between light, dark, and system themes
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Moon, Sun, Monitor } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { useTheme } from './ThemeProvider';
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Toggle theme">
|
||||||
|
{resolvedTheme === 'dark' ? (
|
||||||
|
<Moon className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<Sun className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => setTheme('light')}>
|
||||||
|
<Sun className="mr-2 h-4 w-4" />
|
||||||
|
Light
|
||||||
|
{theme === 'light' && <span className="ml-auto">✓</span>}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
||||||
|
<Moon className="mr-2 h-4 w-4" />
|
||||||
|
Dark
|
||||||
|
{theme === 'dark' && <span className="ml-auto">✓</span>}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme('system')}>
|
||||||
|
<Monitor className="mr-2 h-4 w-4" />
|
||||||
|
System
|
||||||
|
{theme === 'system' && <span className="ml-auto">✓</span>}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
frontend/src/components/theme/index.ts
Normal file
6
frontend/src/components/theme/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Theme components
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { ThemeProvider, useTheme } from './ThemeProvider';
|
||||||
|
export { ThemeToggle } from './ThemeToggle';
|
||||||
Reference in New Issue
Block a user