feat: add dark mode toggle with theme persistence
All checks were successful
Basil CI/CD Pipeline / Code Linting (push) Successful in 58s
Basil CI/CD Pipeline / Web Tests (push) Successful in 1m5s
Basil CI/CD Pipeline / Shared Package Tests (push) Successful in 1m0s
Basil CI/CD Pipeline / Security Scanning (push) Successful in 1m6s
Basil CI/CD Pipeline / API Tests (push) Successful in 2m44s
Basil CI/CD Pipeline / Build All Packages (push) Successful in 3m9s
Basil CI/CD Pipeline / E2E Tests (push) Has been skipped
Basil CI/CD Pipeline / Build & Push Docker Images (push) Successful in 5m5s
Basil CI/CD Pipeline / Trigger Deployment (push) Successful in 11s
All checks were successful
Basil CI/CD Pipeline / Code Linting (push) Successful in 58s
Basil CI/CD Pipeline / Web Tests (push) Successful in 1m5s
Basil CI/CD Pipeline / Shared Package Tests (push) Successful in 1m0s
Basil CI/CD Pipeline / Security Scanning (push) Successful in 1m6s
Basil CI/CD Pipeline / API Tests (push) Successful in 2m44s
Basil CI/CD Pipeline / Build All Packages (push) Successful in 3m9s
Basil CI/CD Pipeline / E2E Tests (push) Has been skipped
Basil CI/CD Pipeline / Build & Push Docker Images (push) Successful in 5m5s
Basil CI/CD Pipeline / Trigger Deployment (push) Successful in 11s
Implemented a simple dark mode toggle that persists user preference across sessions. Changes: - Add CSS custom properties for light and dark themes in App.css - Create ThemeContext for global theme state management - Create ThemeToggle component with moon/sun icons - Update all color references to use CSS variables for theme support - Add localStorage persistence for theme preference - Include smooth transitions between themes The toggle appears in the header next to the user menu and allows instant switching between light and dark modes. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,37 @@
|
||||
:root {
|
||||
/* Light mode colors */
|
||||
--bg-primary: #f5f5f5;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-tertiary: #f9f9f9;
|
||||
--text-primary: #333333;
|
||||
--text-secondary: #666666;
|
||||
--text-tertiary: #999999;
|
||||
--brand-primary: #2d5016;
|
||||
--brand-secondary: #3d6821;
|
||||
--brand-hover: #1f3710;
|
||||
--border-color: #ddd;
|
||||
--border-light: #eee;
|
||||
--shadow: rgba(0,0,0,0.1);
|
||||
--shadow-hover: rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
/* Dark mode colors */
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #2d2d2d;
|
||||
--bg-tertiary: #242424;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #b0b0b0;
|
||||
--text-tertiary: #808080;
|
||||
--brand-primary: #4a7c2d;
|
||||
--brand-secondary: #5a8c3d;
|
||||
--brand-hover: #3d6821;
|
||||
--border-color: #404040;
|
||||
--border-light: #333333;
|
||||
--shadow: rgba(0,0,0,0.3);
|
||||
--shadow-hover: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
@@ -8,7 +42,9 @@ body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #f5f5f5;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
.app {
|
||||
@@ -25,10 +61,10 @@ body {
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: #2d5016;
|
||||
background-color: var(--brand-primary);
|
||||
color: white;
|
||||
padding: 1rem 0;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
box-shadow: 0 2px 4px var(--shadow);
|
||||
}
|
||||
|
||||
.header .container {
|
||||
@@ -73,7 +109,7 @@ nav a:hover {
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: #2d5016;
|
||||
background-color: var(--brand-primary);
|
||||
color: white;
|
||||
padding: 1rem 0;
|
||||
text-align: center;
|
||||
@@ -88,17 +124,17 @@ nav a:hover {
|
||||
}
|
||||
|
||||
.recipe-card {
|
||||
background: white;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
transition: transform 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.recipe-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
box-shadow: 0 4px 12px var(--shadow-hover);
|
||||
}
|
||||
|
||||
.recipe-card img {
|
||||
@@ -113,19 +149,19 @@ nav a:hover {
|
||||
|
||||
.recipe-card h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #2d5016;
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.recipe-card p {
|
||||
color: #666;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.recipe-detail {
|
||||
background: white;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
}
|
||||
|
||||
.recipe-actions {
|
||||
@@ -151,7 +187,7 @@ nav a:hover {
|
||||
}
|
||||
|
||||
.recipe-detail h2 {
|
||||
color: #2d5016;
|
||||
color: var(--brand-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@@ -159,7 +195,7 @@ nav a:hover {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
color: #666;
|
||||
color: var(--text-secondary);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -186,7 +222,7 @@ nav a:hover {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
background-color: #2d5016;
|
||||
background-color: var(--brand-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
@@ -194,7 +230,7 @@ nav a:hover {
|
||||
}
|
||||
|
||||
.servings-adjuster button:hover:not(:disabled) {
|
||||
background-color: #3d6821;
|
||||
background-color: var(--brand-secondary);
|
||||
}
|
||||
|
||||
.servings-adjuster button:disabled {
|
||||
@@ -259,9 +295,9 @@ nav a:hover {
|
||||
.recipe-section {
|
||||
margin-bottom: 3rem;
|
||||
padding: 1.5rem;
|
||||
background-color: #f9f9f9;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #2d5016;
|
||||
border-left: 4px solid var(--brand-primary);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
@@ -274,12 +310,12 @@ nav a:hover {
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
color: #2d5016;
|
||||
color: var(--brand-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-timing {
|
||||
background-color: #2d5016;
|
||||
background-color: var(--brand-primary);
|
||||
color: white;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 4px;
|
||||
@@ -288,13 +324,13 @@ nav a:hover {
|
||||
}
|
||||
|
||||
.instruction-timing {
|
||||
color: #2d5016;
|
||||
color: var(--brand-primary);
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.recipe-section h4 {
|
||||
color: #2d5016;
|
||||
color: var(--brand-primary);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
@@ -304,7 +340,7 @@ nav a:hover {
|
||||
}
|
||||
|
||||
.ingredients h3, .instructions h3 {
|
||||
color: #2d5016;
|
||||
color: var(--brand-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@@ -315,7 +351,7 @@ nav a:hover {
|
||||
|
||||
.ingredients li {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.instructions ol {
|
||||
@@ -335,16 +371,18 @@ nav a:hover {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
@@ -353,7 +391,7 @@ nav a:hover {
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #2d5016;
|
||||
background-color: var(--brand-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
@@ -364,7 +402,7 @@ button {
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #1f3710;
|
||||
background-color: var(--brand-hover);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
@@ -383,15 +421,15 @@ button:disabled {
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Recipe Form Styles */
|
||||
.recipe-form {
|
||||
background: white;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
@@ -417,9 +455,9 @@ button:disabled {
|
||||
}
|
||||
|
||||
.section-form {
|
||||
background-color: #f9f9f9;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #2d5016;
|
||||
border-left: 4px solid var(--brand-primary);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
@@ -433,17 +471,17 @@ button:disabled {
|
||||
|
||||
.section-form-header h4 {
|
||||
margin: 0;
|
||||
color: #2d5016;
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.subsection {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #ddd;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.subsection h5 {
|
||||
color: #2d5016;
|
||||
color: var(--brand-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@@ -467,7 +505,7 @@ button:disabled {
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
align-items: flex-start;
|
||||
background-color: white;
|
||||
background-color: var(--bg-secondary);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 2px solid transparent;
|
||||
@@ -475,20 +513,20 @@ button:disabled {
|
||||
}
|
||||
|
||||
.instruction-row:hover {
|
||||
border-color: #e0e0e0;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
border-color: var(--border-color);
|
||||
box-shadow: 0 2px 4px var(--shadow);
|
||||
}
|
||||
|
||||
.instruction-row.dragging {
|
||||
opacity: 0.5;
|
||||
background-color: #f5f5f5;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
border-color: #2d5016;
|
||||
background-color: var(--bg-primary);
|
||||
box-shadow: 0 4px 8px var(--shadow-hover);
|
||||
border-color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.instruction-drag-handle {
|
||||
cursor: grab;
|
||||
color: #999;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -498,7 +536,7 @@ button:disabled {
|
||||
}
|
||||
|
||||
.instruction-drag-handle:hover {
|
||||
color: #2d5016;
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.instruction-drag-handle:active {
|
||||
@@ -508,7 +546,7 @@ button:disabled {
|
||||
.instruction-number {
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
background-color: #2d5016;
|
||||
background-color: var(--brand-primary);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
@@ -527,9 +565,11 @@ button:disabled {
|
||||
|
||||
.instruction-timing-input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@@ -586,7 +626,7 @@ button:disabled {
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 2px solid #eee;
|
||||
border-top: 2px solid var(--border-light);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
@@ -596,9 +636,9 @@ button:disabled {
|
||||
/* Image Upload Styles */
|
||||
.image-upload-section {
|
||||
padding: 1.5rem;
|
||||
background-color: #f9f9f9;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.current-image {
|
||||
@@ -705,7 +745,7 @@ button:disabled {
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
background-color: #f5f5f5;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import UserMenu from './components/UserMenu';
|
||||
import ThemeToggle from './components/ThemeToggle';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import AuthCallback from './pages/AuthCallback';
|
||||
@@ -19,20 +21,24 @@ import './App.css';
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<AuthProvider>
|
||||
<div className="app">
|
||||
<header className="header">
|
||||
<div className="container">
|
||||
<h1 className="logo"><Link to="/">🌿 Basil</Link></h1>
|
||||
<nav>
|
||||
<Link to="/">Cookbooks</Link>
|
||||
<Link to="/recipes">All Recipes</Link>
|
||||
<Link to="/recipes/new">New Recipe</Link>
|
||||
<Link to="/recipes/import">Import Recipe</Link>
|
||||
</nav>
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<div className="app">
|
||||
<header className="header">
|
||||
<div className="container">
|
||||
<h1 className="logo"><Link to="/">🌿 Basil</Link></h1>
|
||||
<nav>
|
||||
<Link to="/">Cookbooks</Link>
|
||||
<Link to="/recipes">All Recipes</Link>
|
||||
<Link to="/recipes/new">New Recipe</Link>
|
||||
<Link to="/recipes/import">Import Recipe</Link>
|
||||
</nav>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<ThemeToggle />
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="main">
|
||||
<div className="container">
|
||||
@@ -62,7 +68,8 @@ function App() {
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</AuthProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
24
packages/web/src/components/ThemeToggle.tsx
Normal file
24
packages/web/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import '../styles/ThemeToggle.css';
|
||||
|
||||
function ThemeToggle() {
|
||||
try {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="theme-toggle"
|
||||
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||
>
|
||||
{theme === 'light' ? '🌙' : '☀️'}
|
||||
</button>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('ThemeToggle error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default ThemeToggle;
|
||||
53
packages/web/src/contexts/ThemeContext.tsx
Normal file
53
packages/web/src/contexts/ThemeContext.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
const THEME_STORAGE_KEY = 'basil_theme';
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
// Load theme from localStorage safely
|
||||
try {
|
||||
const saved = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
return (saved === 'dark' ? 'dark' : 'light') as Theme;
|
||||
} catch (e) {
|
||||
return 'light';
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Apply theme to document
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
// Save to localStorage safely
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||
} catch (e) {
|
||||
console.warn('Failed to save theme preference:', e);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prev => prev === 'light' ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
23
packages/web/src/styles/ThemeToggle.css
Normal file
23
packages/web/src/styles/ThemeToggle.css
Normal file
@@ -0,0 +1,23 @@
|
||||
.theme-toggle {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
padding: 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.theme-toggle:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
Reference in New Issue
Block a user