diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 391143b..239386d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( - - - - - + + + + + + + ); } diff --git a/frontend/src/components/AuthForm.tsx b/frontend/src/components/AuthForm.tsx index 8091d47..7a39ec1 100644 --- a/frontend/src/components/AuthForm.tsx +++ b/frontend/src/components/AuthForm.tsx @@ -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); + } `} + +
👻
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 496367b..de7ab11 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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) { PriceGhost - {user && ( -
- {user.email} - -
- )} +
+ + {user && ( + <> + {user.email} + + + )} +
diff --git a/frontend/src/components/PriceChart.tsx b/frontend/src/components/PriceChart.tsx index f0a7ea8..5fb7ab6 100644 --- a/frontend/src/components/PriceChart.tsx +++ b/frontend/src/components/PriceChart.tsx @@ -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(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({
- + @@ -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)', }} diff --git a/frontend/src/index.css b/frontend/src/index.css index 6ce56b3..ac5297f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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;