Add dark mode with theme toggle

- Add dark theme CSS variables
- Theme toggle button in navbar and auth pages
- Persist theme preference in localStorage
- Update chart colors for dark mode
- Auto-detect theme changes via MutationObserver

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
clucraft 2026-01-20 14:27:17 -05:00
parent a2b0c2cc65
commit 4ef61517e3
5 changed files with 165 additions and 22 deletions

View file

@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext';
import Login from './pages/Login';
@ -5,6 +6,16 @@ import Register from './pages/Register';
import Dashboard from './pages/Dashboard';
import ProductDetail from './pages/ProductDetail';
function ThemeInitializer({ children }: { children: React.ReactNode }) {
useEffect(() => {
const saved = localStorage.getItem('theme');
if (saved) {
document.documentElement.setAttribute('data-theme', saved);
}
}, []);
return <>{children}</>;
}
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, isLoading } = useAuth();
@ -97,10 +108,12 @@ function AppRoutes() {
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</BrowserRouter>
<ThemeInitializer>
<BrowserRouter>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</BrowserRouter>
</ThemeInitializer>
);
}

View file

@ -1,4 +1,4 @@
import { useState, FormEvent } from 'react';
import { useState, useEffect, FormEvent } from 'react';
import { Link } from 'react-router-dom';
interface AuthFormProps {
@ -12,6 +12,19 @@ export default function AuthForm({ mode, onSubmit }: AuthFormProps) {
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
const saved = localStorage.getItem('theme');
return (saved as 'light' | 'dark') || 'light';
});
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
@ -94,8 +107,34 @@ export default function AuthForm({ mode, onSubmit }: AuthFormProps) {
.auth-form-footer a {
font-weight: 500;
}
.auth-theme-toggle {
position: absolute;
top: 1rem;
right: 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 0.5rem;
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
transition: all 0.2s;
}
.auth-theme-toggle:hover {
border-color: var(--primary);
}
`}</style>
<button
className="auth-theme-toggle"
onClick={toggleTheme}
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
>
{theme === 'light' ? '🌙' : '☀️'}
</button>
<div className="auth-form-card">
<div className="auth-form-header">
<div className="auth-form-logo">👻</div>

View file

@ -1,4 +1,4 @@
import { ReactNode } from 'react';
import { ReactNode, useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
@ -9,6 +9,19 @@ interface LayoutProps {
export default function Layout({ children }: LayoutProps) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
const saved = localStorage.getItem('theme');
return (saved as 'light' | 'dark') || 'light';
});
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
};
const handleLogout = () => {
logout();
@ -64,7 +77,7 @@ export default function Layout({ children }: LayoutProps) {
.navbar-user {
display: flex;
align-items: center;
gap: 1rem;
gap: 0.75rem;
}
.navbar-email {
@ -72,6 +85,21 @@ export default function Layout({ children }: LayoutProps) {
font-size: 0.875rem;
}
.theme-toggle {
background: var(--background);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 0.5rem;
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
transition: all 0.2s;
}
.theme-toggle:hover {
border-color: var(--primary);
}
.main-content {
flex: 1;
padding: 2rem 1rem;
@ -96,14 +124,23 @@ export default function Layout({ children }: LayoutProps) {
<span>PriceGhost</span>
</Link>
{user && (
<div className="navbar-user">
<span className="navbar-email">{user.email}</span>
<button className="btn btn-secondary" onClick={handleLogout}>
Logout
</button>
</div>
)}
<div className="navbar-user">
<button
className="theme-toggle"
onClick={toggleTheme}
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
>
{theme === 'light' ? '🌙' : '☀️'}
</button>
{user && (
<>
<span className="navbar-email">{user.email}</span>
<button className="btn btn-secondary" onClick={handleLogout}>
Logout
</button>
</>
)}
</div>
</div>
</nav>

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
LineChart,
Line,
@ -11,6 +11,16 @@ import {
} from 'recharts';
import { PriceHistory } from '../api/client';
const getThemeColors = () => {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
return {
grid: isDark ? '#334155' : '#e2e8f0',
text: isDark ? '#64748b' : '#94a3b8',
tooltip: isDark ? '#1e293b' : 'white',
tooltipBorder: isDark ? '#334155' : '#e2e8f0',
};
};
interface PriceChartProps {
prices: PriceHistory[];
currency: string;
@ -30,6 +40,18 @@ export default function PriceChart({
onRangeChange,
}: PriceChartProps) {
const [selectedRange, setSelectedRange] = useState<number | undefined>(30);
const [themeColors, setThemeColors] = useState(getThemeColors);
useEffect(() => {
const observer = new MutationObserver(() => {
setThemeColors(getThemeColors());
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
});
return () => observer.disconnect();
}, []);
const handleRangeChange = (days: number | undefined) => {
setSelectedRange(days);
@ -197,16 +219,16 @@ export default function PriceChart({
<div className="price-chart-container">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<CartesianGrid strokeDasharray="3 3" stroke={themeColors.grid} />
<XAxis
dataKey="date"
tickFormatter={formatDate}
stroke="#94a3b8"
stroke={themeColors.text}
fontSize={12}
/>
<YAxis
tickFormatter={formatPrice}
stroke="#94a3b8"
stroke={themeColors.text}
fontSize={12}
domain={['auto', 'auto']}
/>
@ -222,8 +244,8 @@ export default function PriceChart({
})
}
contentStyle={{
background: 'white',
border: '1px solid #e2e8f0',
background: themeColors.tooltip,
border: `1px solid ${themeColors.tooltipBorder}`,
borderRadius: '0.5rem',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
}}

View file

@ -16,6 +16,24 @@
--border: #e2e8f0;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
--chart-grid: #e2e8f0;
--chart-text: #94a3b8;
}
[data-theme="dark"] {
--primary: #818cf8;
--primary-dark: #6366f1;
--secondary: #34d399;
--danger: #f87171;
--background: #0f172a;
--surface: #1e293b;
--text: #f1f5f9;
--text-muted: #94a3b8;
--border: #334155;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3);
--chart-grid: #334155;
--chart-text: #64748b;
}
body {
@ -69,6 +87,8 @@ input, button {
border: 1px solid var(--border);
border-radius: 0.5rem;
font-size: 1rem;
background-color: var(--surface);
color: var(--text);
transition: border-color 0.2s, box-shadow 0.2s;
}
@ -154,12 +174,24 @@ input, button {
border: 1px solid #fecaca;
}
[data-theme="dark"] .alert-error {
background-color: #450a0a;
color: #fca5a5;
border: 1px solid #7f1d1d;
}
.alert-success {
background-color: #f0fdf4;
color: #166534;
border: 1px solid #bbf7d0;
}
[data-theme="dark"] .alert-success {
background-color: #052e16;
color: #86efac;
border: 1px solid #166534;
}
/* Loading spinner */
.spinner {
display: inline-block;