mirror of
https://github.com/clucraft/PriceGhost.git
synced 2026-05-03 04:42:46 +02:00
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:
parent
a2b0c2cc65
commit
4ef61517e3
5 changed files with 165 additions and 22 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue