Vibed it... :(

This commit is contained in:
2025-08-09 14:34:48 +01:00
commit 5cf478feab
41 changed files with 23512 additions and 0 deletions

145
frontend/src/App.css Normal file
View File

@@ -0,0 +1,145 @@
.App {
text-align: center;
min-height: 100vh;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Global loading styles */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
min-height: 100vh;
color: #666;
background: #f8f9fa;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #2196F3;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Global button styles */
.btn {
padding: 10px 16px;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: #2196F3;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #1976D2;
}
.btn-secondary {
background: #f5f5f5;
color: #333;
border-color: #ddd;
}
.btn-secondary:hover:not(:disabled) {
background: #e0e0e0;
}
.btn-success {
background: #4CAF50;
color: white;
}
.btn-success:hover:not(:disabled) {
background: #45a049;
}
.btn-danger {
background: #f44336;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #d32f2f;
}
/* Reset some default styles */
* {
box-sizing: border-box;
}
body {
margin: 0;
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: #f8f9fa;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@@ -0,0 +1,9 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

79
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,79 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext';
import Dashboard from './pages/Dashboard';
import Login from './pages/Login';
import Register from './pages/Register';
import './App.css';
// Protected Route component
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="loading-container">
<div className="loading-spinner"></div>
<p>Loading...</p>
</div>
);
}
return user ? <>{children}</> : <Navigate to="/login" />;
};
// Public Route component (redirect to dashboard if already logged in)
const PublicRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="loading-container">
<div className="loading-spinner"></div>
<p>Loading...</p>
</div>
);
}
return user ? <Navigate to="/" /> : <>{children}</>;
};
function App() {
return (
<AuthProvider>
<Router>
<div className="App">
<Routes>
<Route
path="/"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/login"
element={
<PublicRoute>
<Login />
</PublicRoute>
}
/>
<Route
path="/register"
element={
<PublicRoute>
<Register />
</PublicRoute>
}
/>
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</div>
</Router>
</AuthProvider>
);
}
export default App;

View File

@@ -0,0 +1,172 @@
.recipe-card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
cursor: pointer;
border: 2px solid transparent;
}
.recipe-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.recipe-card.selected {
border-color: #4CAF50;
box-shadow: 0 4px 16px rgba(76, 175, 80, 0.3);
}
.recipe-image-container {
position: relative;
height: 200px;
overflow: hidden;
}
.recipe-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.recipe-card:hover .recipe-image {
transform: scale(1.05);
}
.recipe-badges {
position: absolute;
top: 12px;
right: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.difficulty-badge,
.category-badge {
padding: 4px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
color: white;
text-transform: capitalize;
backdrop-filter: blur(4px);
}
.recipe-content {
padding: 16px;
}
.recipe-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 8px 0;
color: #333;
line-height: 1.3;
}
.recipe-description {
color: #666;
font-size: 0.9rem;
line-height: 1.4;
margin: 0 0 16px 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.recipe-meta {
display: flex;
justify-content: space-between;
margin-bottom: 16px;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
}
.meta-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.meta-label {
font-size: 0.75rem;
color: #666;
font-weight: 500;
}
.meta-value {
font-size: 0.9rem;
font-weight: 600;
color: #333;
}
.recipe-actions {
display: flex;
gap: 8px;
}
.btn {
flex: 1;
padding: 10px 16px;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-primary {
background: #2196F3;
color: white;
}
.btn-primary:hover {
background: #1976D2;
}
.btn-secondary {
background: #f5f5f5;
color: #333;
border: 1px solid #ddd;
}
.btn-secondary:hover {
background: #e0e0e0;
}
.btn-success {
background: #4CAF50;
color: white;
}
.btn-success:hover {
background: #45a049;
}
@media (max-width: 768px) {
.recipe-meta {
flex-direction: column;
gap: 8px;
}
.meta-item {
flex-direction: row;
justify-content: space-between;
}
.recipe-actions {
flex-direction: column;
}
}

View File

@@ -0,0 +1,106 @@
import React from 'react';
import { Recipe } from '../services/api';
import './RecipeCard.css';
interface RecipeCardProps {
recipe: Recipe;
onAddToSelection: (recipeId: string) => void;
onViewDetails: (recipe: Recipe) => void;
isSelected?: boolean;
selectedQuantity?: number;
}
const RecipeCard: React.FC<RecipeCardProps> = ({
recipe,
onAddToSelection,
onViewDetails,
isSelected = false,
selectedQuantity = 0,
}) => {
const getDifficultyColor = (difficulty: string) => {
switch (difficulty) {
case 'easy': return '#4CAF50';
case 'medium': return '#FF9800';
case 'hard': return '#F44336';
default: return '#757575';
}
};
const getCategoryColor = (category: string) => {
switch (category) {
case 'breakfast': return '#FFE082';
case 'lunch': return '#81C784';
case 'dinner': return '#64B5F6';
case 'dessert': return '#F48FB1';
case 'snack': return '#FFB74D';
case 'appetizer': return '#A1C181';
default: return '#E0E0E0';
}
};
return (
<div className={`recipe-card ${isSelected ? 'selected' : ''}`}>
<div className="recipe-image-container">
<img
src={recipe.imageUrl || '/placeholder-recipe.jpg'}
alt={recipe.title}
className="recipe-image"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-recipe.jpg';
}}
/>
<div className="recipe-badges">
<span
className="difficulty-badge"
style={{ backgroundColor: getDifficultyColor(recipe.difficulty) }}
>
{recipe.difficulty}
</span>
<span
className="category-badge"
style={{ backgroundColor: getCategoryColor(recipe.category) }}
>
{recipe.category}
</span>
</div>
</div>
<div className="recipe-content">
<h3 className="recipe-title">{recipe.title}</h3>
<p className="recipe-description">{recipe.description}</p>
<div className="recipe-meta">
<div className="meta-item">
<span className="meta-label">Prep:</span>
<span className="meta-value">{recipe.prepTime}min</span>
</div>
<div className="meta-item">
<span className="meta-label">Cook:</span>
<span className="meta-value">{recipe.cookTime}min</span>
</div>
<div className="meta-item">
<span className="meta-label">Serves:</span>
<span className="meta-value">{recipe.servings}</span>
</div>
</div>
<div className="recipe-actions">
<button
className="btn btn-secondary"
onClick={() => onViewDetails(recipe)}
>
View Details
</button>
<button
className={`btn ${isSelected ? 'btn-success' : 'btn-primary'}`}
onClick={() => onAddToSelection(recipe._id)}
>
{isSelected ? `Added (${selectedQuantity})` : 'Add to Menu'}
</button>
</div>
</div>
</div>
);
};
export default RecipeCard;

View File

@@ -0,0 +1,133 @@
.recipe-list-container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.filters-container {
background: white;
padding: 20px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 24px;
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 16px;
align-items: end;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.filter-group label {
font-weight: 600;
color: #333;
font-size: 0.9rem;
}
.filter-input,
.filter-select {
padding: 10px 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 0.9rem;
transition: border-color 0.2s ease;
}
.filter-input:focus,
.filter-select:focus {
outline: none;
border-color: #2196F3;
}
.recipes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
margin-top: 24px;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #666;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #2196F3;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
background: #fff5f5;
border-radius: 12px;
border: 1px solid #fed7d7;
}
.error-message {
color: #e53e3e;
font-weight: 600;
margin-bottom: 16px;
text-align: center;
}
.no-recipes {
grid-column: 1 / -1;
text-align: center;
padding: 60px 20px;
color: #666;
background: #f8f9fa;
border-radius: 12px;
}
.no-recipes p {
font-size: 1.1rem;
margin: 0;
}
@media (max-width: 768px) {
.filters-container {
grid-template-columns: 1fr;
gap: 12px;
}
.recipes-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.recipe-list-container {
padding: 16px;
}
}
@media (max-width: 480px) {
.filters-container {
padding: 16px;
}
.recipes-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,194 @@
import React, { useState, useEffect } from 'react';
import { Recipe, recipesAPI, selectionsAPI, UserSelection } from '../services/api';
import RecipeCard from './RecipeCard';
import RecipeModal from './RecipeModal';
import './RecipeList.css';
interface RecipeListProps {
onSelectionUpdate?: (selection: UserSelection) => void;
}
const RecipeList: React.FC<RecipeListProps> = ({ onSelectionUpdate }) => {
const [recipes, setRecipes] = useState<Recipe[]>([]);
const [userSelection, setUserSelection] = useState<UserSelection | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedRecipe, setSelectedRecipe] = useState<Recipe | null>(null);
const [filters, setFilters] = useState({
category: '',
difficulty: '',
search: '',
});
useEffect(() => {
fetchRecipes();
fetchUserSelection();
}, [filters]);
const fetchRecipes = async () => {
try {
setLoading(true);
const response = await recipesAPI.getAll(filters);
setRecipes(response.data as Recipe[]);
} catch (error: any) {
setError(error.response?.data?.error || 'Failed to fetch recipes');
} finally {
setLoading(false);
}
};
const fetchUserSelection = async () => {
try {
const response = await selectionsAPI.get();
setUserSelection(response.data as UserSelection);
if (onSelectionUpdate) {
onSelectionUpdate(response.data as UserSelection);
}
} catch (error: any) {
// User might not have any selections yet, which is fine
console.log('No user selections found');
}
};
const handleAddToSelection = async (recipeId: string) => {
try {
const response = await selectionsAPI.addRecipe(recipeId, 1);
setUserSelection(response.data as UserSelection);
if (onSelectionUpdate) {
onSelectionUpdate(response.data as UserSelection);
}
} catch (error: any) {
setError(error.response?.data?.error || 'Failed to add recipe to selection');
}
};
const handleViewDetails = (recipe: Recipe) => {
setSelectedRecipe(recipe);
};
const handleCloseModal = () => {
setSelectedRecipe(null);
};
const handleFilterChange = (filterType: string, value: string) => {
setFilters(prev => ({
...prev,
[filterType]: value,
}));
};
const isRecipeSelected = (recipeId: string) => {
return userSelection?.selectedRecipes.some(
selection => selection.recipeId._id === recipeId
) || false;
};
const getSelectedQuantity = (recipeId: string) => {
const selection = userSelection?.selectedRecipes.find(
selection => selection.recipeId._id === recipeId
);
return selection?.quantity || 0;
};
if (loading) {
return (
<div className="loading-container">
<div className="loading-spinner"></div>
<p>Loading recipes...</p>
</div>
);
}
if (error) {
return (
<div className="error-container">
<p className="error-message">{error}</p>
<button className="btn btn-primary" onClick={fetchRecipes}>
Try Again
</button>
</div>
);
}
return (
<div className="recipe-list-container">
<div className="filters-container">
<div className="filter-group">
<label htmlFor="search">Search Recipes:</label>
<input
id="search"
type="text"
placeholder="Search by title or description..."
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
className="filter-input"
/>
</div>
<div className="filter-group">
<label htmlFor="category">Category:</label>
<select
id="category"
value={filters.category}
onChange={(e) => handleFilterChange('category', e.target.value)}
className="filter-select"
>
<option value="">All Categories</option>
<option value="breakfast">Breakfast</option>
<option value="lunch">Lunch</option>
<option value="dinner">Dinner</option>
<option value="dessert">Dessert</option>
<option value="snack">Snack</option>
<option value="appetizer">Appetizer</option>
</select>
</div>
<div className="filter-group">
<label htmlFor="difficulty">Difficulty:</label>
<select
id="difficulty"
value={filters.difficulty}
onChange={(e) => handleFilterChange('difficulty', e.target.value)}
className="filter-select"
>
<option value="">All Difficulties</option>
<option value="easy">Easy</option>
<option value="medium">Medium</option>
<option value="hard">Hard</option>
</select>
</div>
</div>
<div className="recipes-grid">
{recipes.length === 0 ? (
<div className="no-recipes">
<p>No recipes found matching your criteria.</p>
</div>
) : (
recipes.map((recipe) => (
<RecipeCard
key={recipe._id}
recipe={recipe}
onAddToSelection={handleAddToSelection}
onViewDetails={handleViewDetails}
isSelected={isRecipeSelected(recipe._id)}
selectedQuantity={getSelectedQuantity(recipe._id)}
/>
))
)}
</div>
{selectedRecipe && (
<RecipeModal
recipe={selectedRecipe}
onClose={handleCloseModal}
onAddToSelection={handleAddToSelection}
isSelected={isRecipeSelected(selectedRecipe._id)}
selectedQuantity={getSelectedQuantity(selectedRecipe._id)}
/>
)}
</div>
);
};
export default RecipeList;

View File

@@ -0,0 +1,274 @@
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal-content {
background: white;
border-radius: 16px;
max-width: 800px;
width: 100%;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: flex-end;
padding: 16px 20px 0 20px;
}
.close-button {
background: none;
border: none;
font-size: 2rem;
cursor: pointer;
color: #666;
padding: 0;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s ease;
}
.close-button:hover {
background: #f0f0f0;
color: #333;
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 0 20px 20px 20px;
}
.recipe-image-section {
position: relative;
margin-bottom: 24px;
}
.modal-recipe-image {
width: 100%;
height: 300px;
object-fit: cover;
border-radius: 12px;
}
.modal-badges {
position: absolute;
top: 16px;
right: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.difficulty-badge,
.category-badge {
padding: 6px 12px;
border-radius: 16px;
font-size: 0.8rem;
font-weight: 600;
color: white;
text-transform: capitalize;
backdrop-filter: blur(8px);
}
.recipe-details {
display: flex;
flex-direction: column;
gap: 24px;
}
.modal-recipe-title {
font-size: 2rem;
font-weight: 700;
margin: 0;
color: #333;
line-height: 1.2;
}
.modal-recipe-description {
font-size: 1.1rem;
color: #666;
line-height: 1.5;
margin: 0;
}
.recipe-meta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
}
.meta-card {
background: #f8f9fa;
padding: 16px;
border-radius: 12px;
text-align: center;
display: flex;
flex-direction: column;
gap: 8px;
}
.meta-label {
font-size: 0.85rem;
color: #666;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.meta-value {
font-size: 1.2rem;
font-weight: 700;
color: #333;
}
.ingredients-section h3,
.instructions-section h3 {
font-size: 1.4rem;
font-weight: 600;
margin: 0 0 16px 0;
color: #333;
border-bottom: 2px solid #e0e0e0;
padding-bottom: 8px;
}
.ingredients-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 12px;
}
.ingredient-item {
background: #f8f9fa;
padding: 12px 16px;
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
border-left: 4px solid #2196F3;
}
.ingredient-amount {
font-weight: 600;
color: #2196F3;
font-size: 0.9rem;
}
.ingredient-name {
color: #333;
font-weight: 500;
}
.instructions-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.instruction-item {
display: flex;
gap: 16px;
align-items: flex-start;
}
.instruction-step {
background: #2196F3;
color: white;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.9rem;
flex-shrink: 0;
}
.instruction-text {
flex: 1;
color: #333;
line-height: 1.5;
padding-top: 4px;
}
.modal-footer {
padding: 20px;
border-top: 1px solid #e0e0e0;
background: #f8f9fa;
}
.btn-large {
width: 100%;
padding: 16px 24px;
font-size: 1.1rem;
font-weight: 600;
}
@media (max-width: 768px) {
.modal-backdrop {
padding: 10px;
}
.modal-content {
max-height: 95vh;
}
.modal-recipe-image {
height: 200px;
}
.modal-recipe-title {
font-size: 1.5rem;
}
.recipe-meta-grid {
grid-template-columns: repeat(2, 1fr);
}
.ingredients-list {
grid-template-columns: 1fr;
}
.instruction-item {
gap: 12px;
}
}
@media (max-width: 480px) {
.recipe-meta-grid {
grid-template-columns: 1fr;
}
.modal-body {
padding: 0 16px 16px 16px;
}
.modal-footer {
padding: 16px;
}
}

View File

@@ -0,0 +1,146 @@
import React from 'react';
import { Recipe } from '../services/api';
import './RecipeModal.css';
interface RecipeModalProps {
recipe: Recipe;
onClose: () => void;
onAddToSelection: (recipeId: string) => void;
isSelected: boolean;
selectedQuantity: number;
}
const RecipeModal: React.FC<RecipeModalProps> = ({
recipe,
onClose,
onAddToSelection,
isSelected,
selectedQuantity,
}) => {
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
const getDifficultyColor = (difficulty: string) => {
switch (difficulty) {
case 'easy': return '#4CAF50';
case 'medium': return '#FF9800';
case 'hard': return '#F44336';
default: return '#757575';
}
};
const getCategoryColor = (category: string) => {
switch (category) {
case 'breakfast': return '#FFE082';
case 'lunch': return '#81C784';
case 'dinner': return '#64B5F6';
case 'dessert': return '#F48FB1';
case 'snack': return '#FFB74D';
case 'appetizer': return '#A1C181';
default: return '#E0E0E0';
}
};
return (
<div className="modal-backdrop" onClick={handleBackdropClick}>
<div className="modal-content">
<div className="modal-header">
<button className="close-button" onClick={onClose}>
×
</button>
</div>
<div className="modal-body">
<div className="recipe-image-section">
<img
src={recipe.imageUrl || '/placeholder-recipe.jpg'}
alt={recipe.title}
className="modal-recipe-image"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-recipe.jpg';
}}
/>
<div className="modal-badges">
<span
className="difficulty-badge"
style={{ backgroundColor: getDifficultyColor(recipe.difficulty) }}
>
{recipe.difficulty}
</span>
<span
className="category-badge"
style={{ backgroundColor: getCategoryColor(recipe.category) }}
>
{recipe.category}
</span>
</div>
</div>
<div className="recipe-details">
<h2 className="modal-recipe-title">{recipe.title}</h2>
<p className="modal-recipe-description">{recipe.description}</p>
<div className="recipe-meta-grid">
<div className="meta-card">
<span className="meta-label">Prep Time</span>
<span className="meta-value">{recipe.prepTime} min</span>
</div>
<div className="meta-card">
<span className="meta-label">Cook Time</span>
<span className="meta-value">{recipe.cookTime} min</span>
</div>
<div className="meta-card">
<span className="meta-label">Total Time</span>
<span className="meta-value">{recipe.prepTime + recipe.cookTime} min</span>
</div>
<div className="meta-card">
<span className="meta-label">Servings</span>
<span className="meta-value">{recipe.servings}</span>
</div>
</div>
<div className="ingredients-section">
<h3>Ingredients</h3>
<ul className="ingredients-list">
{recipe.ingredients.map((ingredient, index) => (
<li key={index} className="ingredient-item">
<span className="ingredient-amount">
{ingredient.amount} {ingredient.unit}
</span>
<span className="ingredient-name">{ingredient.name}</span>
</li>
))}
</ul>
</div>
<div className="instructions-section">
<h3>Instructions</h3>
<ol className="instructions-list">
{recipe.instructions.map((instruction) => (
<li key={instruction.step} className="instruction-item">
<div className="instruction-step">{instruction.step}</div>
<div className="instruction-text">{instruction.description}</div>
</li>
))}
</ol>
</div>
</div>
</div>
<div className="modal-footer">
<button
className={`btn ${isSelected ? 'btn-success' : 'btn-primary'} btn-large`}
onClick={() => onAddToSelection(recipe._id)}
>
{isSelected ? `Added to Menu (${selectedQuantity})` : 'Add to Menu'}
</button>
</div>
</div>
</div>
);
};
export default RecipeModal;

View File

@@ -0,0 +1,364 @@
.shopping-list-container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.error-banner {
background: #fee;
border: 1px solid #fcc;
color: #c33;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.error-banner button {
background: none;
border: none;
color: #c33;
font-size: 1.2rem;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
background: #f8f9fa;
border-radius: 12px;
color: #666;
}
.empty-state h2 {
margin: 0 0 12px 0;
color: #333;
}
.empty-state p {
margin: 0;
font-size: 1.1rem;
}
.shopping-list-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 32px;
padding-bottom: 16px;
border-bottom: 2px solid #e0e0e0;
}
.header-info h2 {
margin: 0 0 8px 0;
color: #333;
font-size: 1.8rem;
}
.header-info p {
margin: 0;
color: #666;
font-size: 1rem;
}
.btn-danger {
background: #f44336;
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease;
}
.btn-danger:hover:not(:disabled) {
background: #d32f2f;
}
.btn-danger:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.shopping-list-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
}
.selected-recipes-section h3,
.aggregated-ingredients-section h3 {
font-size: 1.4rem;
font-weight: 600;
margin: 0 0 20px 0;
color: #333;
padding-bottom: 8px;
border-bottom: 2px solid #e0e0e0;
}
.selected-recipes-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.selected-recipe-item {
background: white;
border-radius: 12px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.recipe-info {
display: flex;
gap: 12px;
flex: 1;
align-items: center;
}
.recipe-thumbnail {
width: 60px;
height: 60px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
}
.recipe-details {
flex: 1;
}
.recipe-details h4 {
margin: 0 0 4px 0;
font-size: 1.1rem;
color: #333;
}
.recipe-details p {
margin: 0 0 4px 0;
color: #666;
font-size: 0.9rem;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.recipe-meta {
font-size: 0.8rem;
color: #999;
text-transform: capitalize;
}
.quantity-controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.quantity-controls label {
font-size: 0.8rem;
color: #666;
font-weight: 600;
}
.quantity-input-group {
display: flex;
align-items: center;
gap: 8px;
background: #f5f5f5;
border-radius: 8px;
padding: 4px;
}
.quantity-btn {
background: #2196F3;
color: white;
border: none;
width: 28px;
height: 28px;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
transition: background 0.2s ease;
}
.quantity-btn:hover:not(:disabled) {
background: #1976D2;
}
.quantity-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.quantity-display {
min-width: 24px;
text-align: center;
font-weight: 600;
color: #333;
}
.remove-btn {
background: none;
border: none;
font-size: 1.2rem;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: background 0.2s ease;
}
.remove-btn:hover:not(:disabled) {
background: #ffebee;
}
.remove-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.no-ingredients {
text-align: center;
color: #666;
font-style: italic;
padding: 40px 20px;
background: #f8f9fa;
border-radius: 8px;
}
.ingredients-grid {
display: flex;
flex-direction: column;
gap: 16px;
}
.ingredient-card {
background: white;
border-radius: 12px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-left: 4px solid #4CAF50;
}
.ingredient-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.ingredient-name {
margin: 0;
font-size: 1.1rem;
color: #333;
font-weight: 600;
text-transform: capitalize;
}
.ingredient-total {
font-size: 1rem;
font-weight: 700;
color: #4CAF50;
background: #e8f5e8;
padding: 4px 12px;
border-radius: 16px;
}
.ingredient-breakdown {
border-top: 1px solid #e0e0e0;
padding-top: 12px;
}
.breakdown-label {
font-size: 0.85rem;
color: #666;
font-weight: 600;
margin-bottom: 8px;
display: block;
}
.recipe-breakdown {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.recipe-breakdown-item {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9rem;
padding: 4px 0;
}
.recipe-name {
color: #333;
font-weight: 500;
}
.recipe-amount {
color: #666;
font-size: 0.85rem;
}
@media (max-width: 768px) {
.shopping-list-content {
grid-template-columns: 1fr;
gap: 24px;
}
.shopping-list-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.selected-recipe-item {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.recipe-info {
align-items: flex-start;
}
.quantity-controls {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.shopping-list-container {
padding: 16px;
}
}
@media (max-width: 480px) {
.ingredient-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.recipe-breakdown-item {
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
}

View File

@@ -0,0 +1,193 @@
import React, { useState } from 'react';
import { UserSelection, selectionsAPI } from '../services/api';
import './ShoppingList.css';
interface ShoppingListProps {
userSelection: UserSelection | null;
onSelectionUpdate: (selection: UserSelection) => void;
}
const ShoppingList: React.FC<ShoppingListProps> = ({ userSelection, onSelectionUpdate }) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleQuantityChange = async (recipeId: string, newQuantity: number) => {
try {
setLoading(true);
setError(null);
if (newQuantity <= 0) {
const response = await selectionsAPI.removeRecipe(recipeId);
onSelectionUpdate(response.data);
} else {
const response = await selectionsAPI.updateQuantity(recipeId, newQuantity);
onSelectionUpdate(response.data);
}
} catch (error: any) {
setError(error.response?.data?.error || 'Failed to update quantity');
} finally {
setLoading(false);
}
};
const handleRemoveRecipe = async (recipeId: string) => {
try {
setLoading(true);
setError(null);
const response = await selectionsAPI.removeRecipe(recipeId);
onSelectionUpdate(response.data);
} catch (error: any) {
setError(error.response?.data?.error || 'Failed to remove recipe');
} finally {
setLoading(false);
}
};
const handleClearAll = async () => {
try {
setLoading(true);
setError(null);
const response = await selectionsAPI.clear();
onSelectionUpdate(response.data);
} catch (error: any) {
setError(error.response?.data?.error || 'Failed to clear selections');
} finally {
setLoading(false);
}
};
const getTotalRecipes = () => {
return userSelection?.selectedRecipes.reduce((total, recipe) => total + recipe.quantity, 0) || 0;
};
if (!userSelection || userSelection.selectedRecipes.length === 0) {
return (
<div className="shopping-list-container">
<div className="empty-state">
<h2>Your Menu is Empty</h2>
<p>Start by selecting some recipes to create your shopping list!</p>
</div>
</div>
);
}
return (
<div className="shopping-list-container">
{error && (
<div className="error-banner">
<p>{error}</p>
<button onClick={() => setError(null)}>×</button>
</div>
)}
<div className="shopping-list-header">
<div className="header-info">
<h2>Your Menu & Shopping List</h2>
<p>{userSelection.selectedRecipes.length} recipes {getTotalRecipes()} total servings</p>
</div>
<button
className="btn btn-danger"
onClick={handleClearAll}
disabled={loading}
>
Clear All
</button>
</div>
<div className="shopping-list-content">
<div className="selected-recipes-section">
<h3>Selected Recipes</h3>
<div className="selected-recipes-list">
{userSelection.selectedRecipes.map((selection) => (
<div key={selection.recipeId._id} className="selected-recipe-item">
<div className="recipe-info">
<img
src={selection.recipeId.imageUrl || '/placeholder-recipe.jpg'}
alt={selection.recipeId.title}
className="recipe-thumbnail"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-recipe.jpg';
}}
/>
<div className="recipe-details">
<h4>{selection.recipeId.title}</h4>
<p>{selection.recipeId.description}</p>
<span className="recipe-meta">
{selection.recipeId.category} {selection.recipeId.difficulty}
{selection.recipeId.prepTime + selection.recipeId.cookTime} min
</span>
</div>
</div>
<div className="quantity-controls">
<label>Quantity:</label>
<div className="quantity-input-group">
<button
className="quantity-btn"
onClick={() => handleQuantityChange(selection.recipeId._id, selection.quantity - 1)}
disabled={loading || selection.quantity <= 1}
>
-
</button>
<span className="quantity-display">{selection.quantity}</span>
<button
className="quantity-btn"
onClick={() => handleQuantityChange(selection.recipeId._id, selection.quantity + 1)}
disabled={loading}
>
+
</button>
</div>
<button
className="remove-btn"
onClick={() => handleRemoveRecipe(selection.recipeId._id)}
disabled={loading}
title="Remove recipe"
>
🗑
</button>
</div>
</div>
))}
</div>
</div>
<div className="aggregated-ingredients-section">
<h3>Shopping List</h3>
{userSelection.aggregatedIngredients.length === 0 ? (
<p className="no-ingredients">No ingredients to show.</p>
) : (
<div className="ingredients-grid">
{userSelection.aggregatedIngredients.map((ingredient, index) => (
<div key={index} className="ingredient-card">
<div className="ingredient-header">
<h4 className="ingredient-name">{ingredient.name}</h4>
<span className="ingredient-total">
{ingredient.totalAmount} {ingredient.unit}
</span>
</div>
<div className="ingredient-breakdown">
<span className="breakdown-label">Used in:</span>
<ul className="recipe-breakdown">
{ingredient.recipes.map((recipe, recipeIndex) => (
<li key={recipeIndex} className="recipe-breakdown-item">
<span className="recipe-name">{recipe.recipeTitle}</span>
<span className="recipe-amount">
{recipe.amount} {ingredient.unit} × {recipe.quantity}
</span>
</li>
))}
</ul>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
};
export default ShoppingList;

View File

@@ -0,0 +1,106 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { authAPI, User } from '../services/api';
interface AuthContextType {
user: User | null;
token: string | null;
login: (email: string, password: string) => Promise<void>;
register: (username: string, email: string, password: string) => Promise<void>;
logout: () => void;
loading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const initAuth = async () => {
const savedToken = localStorage.getItem('token');
const savedUser = localStorage.getItem('user');
if (savedToken && savedUser) {
setToken(savedToken);
setUser(JSON.parse(savedUser));
try {
// Verify token is still valid
await authAPI.getProfile();
} catch (error) {
// Token is invalid, clear auth data
localStorage.removeItem('token');
localStorage.removeItem('user');
setToken(null);
setUser(null);
}
}
setLoading(false);
};
initAuth();
}, []);
const login = async (email: string, password: string) => {
try {
const response = await authAPI.login({ email, password });
const { token: newToken, user: newUser } = response.data;
setToken(newToken);
setUser(newUser);
localStorage.setItem('token', newToken);
localStorage.setItem('user', JSON.stringify(newUser));
} catch (error: any) {
throw new Error(error.response?.data?.error || 'Login failed');
}
};
const register = async (username: string, email: string, password: string) => {
try {
const response = await authAPI.register({ username, email, password });
const { token: newToken, user: newUser } = response.data;
setToken(newToken);
setUser(newUser);
localStorage.setItem('token', newToken);
localStorage.setItem('user', JSON.stringify(newUser));
} catch (error: any) {
throw new Error(error.response?.data?.error || 'Registration failed');
}
};
const logout = () => {
setToken(null);
setUser(null);
localStorage.removeItem('token');
localStorage.removeItem('user');
};
const value = {
user,
token,
login,
register,
logout,
loading,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

13
frontend/src/index.css Normal file
View File

@@ -0,0 +1,13 @@
body {
margin: 0;
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;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

19
frontend/src/index.tsx Normal file
View File

@@ -0,0 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

1
frontend/src/logo.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

146
frontend/src/pages/Auth.css Normal file
View File

@@ -0,0 +1,146 @@
.auth-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.auth-card {
background: white;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
padding: 40px;
width: 100%;
max-width: 400px;
}
.auth-header {
text-align: center;
margin-bottom: 32px;
}
.auth-header h1 {
font-size: 2rem;
font-weight: 700;
color: #333;
margin: 0 0 8px 0;
}
.auth-header p {
color: #666;
margin: 0;
font-size: 1rem;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group label {
font-weight: 600;
color: #333;
font-size: 0.9rem;
}
.form-group input {
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
transition: all 0.2s ease;
background: white;
}
.form-group input:focus {
outline: none;
border-color: #2196F3;
box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.1);
}
.form-group input:disabled {
background: #f5f5f5;
cursor: not-allowed;
}
.error-message {
background: #fee;
border: 1px solid #fcc;
color: #c33;
padding: 12px 16px;
border-radius: 8px;
font-size: 0.9rem;
text-align: center;
}
.auth-button {
background: #2196F3;
color: white;
border: none;
padding: 14px 20px;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
margin-top: 8px;
}
.auth-button:hover:not(:disabled) {
background: #1976D2;
transform: translateY(-1px);
}
.auth-button:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
}
.auth-footer {
text-align: center;
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #e0e0e0;
}
.auth-footer p {
color: #666;
margin: 0;
font-size: 0.9rem;
}
.auth-link {
color: #2196F3;
text-decoration: none;
font-weight: 600;
transition: color 0.2s ease;
}
.auth-link:hover {
color: #1976D2;
text-decoration: underline;
}
@media (max-width: 480px) {
.auth-container {
padding: 16px;
}
.auth-card {
padding: 24px;
}
.auth-header h1 {
font-size: 1.5rem;
}
}

View File

@@ -0,0 +1,202 @@
.dashboard {
min-height: 100vh;
background: #f8f9fa;
}
.dashboard-header {
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
display: flex;
flex-direction: column;
gap: 4px;
}
.app-title {
font-size: 2rem;
font-weight: 700;
color: #333;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.welcome-text {
color: #666;
margin: 0;
font-size: 1rem;
}
.header-right {
display: flex;
align-items: center;
gap: 24px;
}
.stats-container {
display: flex;
gap: 20px;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.stat-number {
font-size: 1.5rem;
font-weight: 700;
color: #2196F3;
}
.stat-label {
font-size: 0.8rem;
color: #666;
text-align: center;
}
.logout-button {
background: #f44336;
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.logout-button:hover {
background: #d32f2f;
transform: translateY(-1px);
}
.tab-navigation {
max-width: 1200px;
margin: 0 auto;
display: flex;
border-top: 1px solid #e0e0e0;
}
.tab-button {
flex: 1;
background: none;
border: none;
padding: 16px 24px;
font-size: 1rem;
font-weight: 600;
color: #666;
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 3px solid transparent;
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.tab-button:hover {
color: #333;
background: #f8f9fa;
}
.tab-button.active {
color: #2196F3;
border-bottom-color: #2196F3;
background: #f8f9fa;
}
.tab-badge {
background: #2196F3;
color: white;
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 12px;
min-width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.dashboard-content {
min-height: calc(100vh - 140px);
}
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 16px;
align-items: stretch;
}
.header-left {
text-align: center;
}
.header-right {
justify-content: space-between;
}
.app-title {
font-size: 1.5rem;
}
.stats-container {
gap: 16px;
}
.tab-navigation {
flex-direction: column;
}
.tab-button {
border-bottom: 1px solid #e0e0e0;
border-right: none;
}
.tab-button.active {
border-bottom-color: #e0e0e0;
border-left: 3px solid #2196F3;
}
}
@media (max-width: 480px) {
.header-content {
padding: 16px;
}
.stats-container {
flex-direction: column;
gap: 8px;
}
.stat-item {
flex-direction: row;
gap: 8px;
}
.tab-button {
padding: 12px 16px;
font-size: 0.9rem;
}
}

View File

@@ -0,0 +1,85 @@
import React, { useState } from 'react';
import { useAuth } from '../context/AuthContext';
import RecipeList from '../components/RecipeList';
import ShoppingList from '../components/ShoppingList';
import { UserSelection } from '../services/api';
import './Dashboard.css';
const Dashboard: React.FC = () => {
const { user, logout } = useAuth();
const [userSelection, setUserSelection] = useState<UserSelection | null>(null);
const [activeTab, setActiveTab] = useState<'recipes' | 'shopping'>('recipes');
const handleSelectionUpdate = (selection: UserSelection) => {
setUserSelection(selection);
};
const getSelectedRecipesCount = () => {
return userSelection?.selectedRecipes.length || 0;
};
const getTotalIngredientsCount = () => {
return userSelection?.aggregatedIngredients.length || 0;
};
return (
<div className="dashboard">
<header className="dashboard-header">
<div className="header-content">
<div className="header-left">
<h1 className="app-title">Recipe Manager</h1>
<p className="welcome-text">Welcome back, {user?.username}!</p>
</div>
<div className="header-right">
<div className="stats-container">
<div className="stat-item">
<span className="stat-number">{getSelectedRecipesCount()}</span>
<span className="stat-label">Selected Recipes</span>
</div>
<div className="stat-item">
<span className="stat-number">{getTotalIngredientsCount()}</span>
<span className="stat-label">Ingredients</span>
</div>
</div>
<button className="logout-button" onClick={logout}>
Logout
</button>
</div>
</div>
<nav className="tab-navigation">
<button
className={`tab-button ${activeTab === 'recipes' ? 'active' : ''}`}
onClick={() => setActiveTab('recipes')}
>
Browse Recipes
</button>
<button
className={`tab-button ${activeTab === 'shopping' ? 'active' : ''}`}
onClick={() => setActiveTab('shopping')}
>
My Menu & Shopping List
{getSelectedRecipesCount() > 0 && (
<span className="tab-badge">{getSelectedRecipesCount()}</span>
)}
</button>
</nav>
</header>
<main className="dashboard-content">
{activeTab === 'recipes' ? (
<RecipeList onSelectionUpdate={handleSelectionUpdate} />
) : (
<ShoppingList
userSelection={userSelection}
onSelectionUpdate={handleSelectionUpdate}
/>
)}
</main>
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,98 @@
import React, { useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { useNavigate, Link } from 'react-router-dom';
import './Auth.css';
const Login: React.FC = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password) {
setError('Please fill in all fields');
return;
}
try {
setLoading(true);
setError('');
await login(email, password);
navigate('/');
} catch (error: any) {
setError(error.message);
} finally {
setLoading(false);
}
};
return (
<div className="auth-container">
<div className="auth-card">
<div className="auth-header">
<h1>Welcome Back</h1>
<p>Sign in to your recipe management account</p>
</div>
<form onSubmit={handleSubmit} className="auth-form">
{error && (
<div className="error-message">
{error}
</div>
)}
<div className="form-group">
<label htmlFor="email">Email Address</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
disabled={loading}
required
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
disabled={loading}
required
/>
</div>
<button
type="submit"
className="auth-button"
disabled={loading}
>
{loading ? 'Signing In...' : 'Sign In'}
</button>
</form>
<div className="auth-footer">
<p>
Don't have an account?{' '}
<Link to="/register" className="auth-link">
Sign up here
</Link>
</p>
</div>
</div>
</div>
);
};
export default Login;

View File

@@ -0,0 +1,136 @@
import React, { useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { useNavigate, Link } from 'react-router-dom';
import './Auth.css';
const Register: React.FC = () => {
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const { register } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!username || !email || !password || !confirmPassword) {
setError('Please fill in all fields');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters long');
return;
}
try {
setLoading(true);
setError('');
await register(username, email, password);
navigate('/');
} catch (error: any) {
setError(error.message);
} finally {
setLoading(false);
}
};
return (
<div className="auth-container">
<div className="auth-card">
<div className="auth-header">
<h1>Create Account</h1>
<p>Join us to start managing your recipes</p>
</div>
<form onSubmit={handleSubmit} className="auth-form">
{error && (
<div className="error-message">
{error}
</div>
)}
<div className="form-group">
<label htmlFor="username">Username</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Choose a username"
disabled={loading}
required
/>
</div>
<div className="form-group">
<label htmlFor="email">Email Address</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
disabled={loading}
required
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Create a password (min 6 characters)"
disabled={loading}
required
/>
</div>
<div className="form-group">
<label htmlFor="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm your password"
disabled={loading}
required
/>
</div>
<button
type="submit"
className="auth-button"
disabled={loading}
>
{loading ? 'Creating Account...' : 'Create Account'}
</button>
</form>
<div className="auth-footer">
<p>
Already have an account?{' '}
<Link to="/login" className="auth-link">
Sign in here
</Link>
</p>
</div>
</div>
</div>
);
};
export default Register;

1
frontend/src/react-app-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="react-scripts" />

View File

@@ -0,0 +1,15 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@@ -0,0 +1,143 @@
import axios from 'axios';
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Add auth token to requests
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle auth errors
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export interface Recipe {
_id: string;
title: string;
description: string;
ingredients: Array<{
name: string;
amount: number;
unit: string;
}>;
instructions: Array<{
step: number;
description: string;
}>;
servings: number;
prepTime: number;
cookTime: number;
category: 'breakfast' | 'lunch' | 'dinner' | 'dessert' | 'snack' | 'appetizer';
difficulty: 'easy' | 'medium' | 'hard';
imageUrl: string;
createdAt: string;
}
export interface User {
id: string;
username: string;
email: string;
}
export interface AuthResponse {
token: string;
user: User;
}
export interface ApiResponse<T> {
data: T;
message?: string;
}
export interface UserSelection {
_id: string;
userId: string;
selectedRecipes: Array<{
recipeId: Recipe;
quantity: number;
addedAt: string;
}>;
aggregatedIngredients: Array<{
name: string;
totalAmount: number;
unit: string;
recipes: Array<{
recipeId: string;
recipeTitle: string;
amount: number;
quantity: number;
}>;
}>;
createdAt: string;
updatedAt: string;
}
// Auth API
export const authAPI = {
register: (userData: { username: string; email: string; password: string }) =>
api.post<AuthResponse>('/users/register', userData),
login: (credentials: { email: string; password: string }) =>
api.post<AuthResponse>('/users/login', credentials),
getProfile: () =>
api.get<User>('/users/profile'),
};
// Recipes API
export const recipesAPI = {
getAll: (params?: { category?: string; difficulty?: string; search?: string }) =>
api.get('/recipes', { params }),
getById: (id: string) =>
api.get(`/recipes/${id}`),
create: (recipe: Omit<Recipe, '_id' | 'createdAt'>) =>
api.post('/recipes', recipe),
update: (id: string, recipe: Partial<Recipe>) =>
api.put(`/recipes/${id}`, recipe),
delete: (id: string) =>
api.delete(`/recipes/${id}`),
};
// Selections API
export const selectionsAPI = {
get: () =>
api.get<UserSelection>('/selections'),
addRecipe: (recipeId: string, quantity: number = 1) =>
api.post<UserSelection>('/selections/add', { recipeId, quantity }),
updateQuantity: (recipeId: string, quantity: number) =>
api.put<UserSelection>('/selections/update', { recipeId, quantity }),
removeRecipe: (recipeId: string) =>
api.delete<UserSelection>(`/selections/remove/${recipeId}`),
clear: () =>
api.delete<UserSelection>('/selections/clear'),
};
export default api;

View File

@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';