add feature for users to create and store recipes

This commit is contained in:
2025-08-10 13:52:45 +01:00
parent a53c50b8b4
commit 1d2fa1bf02
7 changed files with 779 additions and 9 deletions

View File

@@ -55,6 +55,11 @@ const recipeSchema = new mongoose.Schema({
type: String, type: String,
default: '' default: ''
}, },
createdBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
createdAt: { createdAt: {
type: Date, type: Date,
default: Date.now default: Date.now

View File

@@ -1,7 +1,26 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const jwt = require('jsonwebtoken');
const Recipe = require('../models/Recipe'); const Recipe = require('../models/Recipe');
// Middleware to authenticate JWT token
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
if (err) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
req.userId = decoded.userId;
next();
});
}
// Get all recipes // Get all recipes
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
@@ -37,10 +56,13 @@ router.get('/:id', async (req, res) => {
} }
}); });
// Create new recipe // Create new recipe (protected route)
router.post('/', async (req, res) => { router.post('/', authenticateToken, async (req, res) => {
try { try {
const recipe = new Recipe(req.body); const recipe = new Recipe({
...req.body,
createdBy: req.userId // Add user ID to track who created the recipe
});
await recipe.save(); await recipe.save();
res.status(201).json(recipe); res.status(201).json(recipe);
} catch (error) { } catch (error) {

View File

@@ -4,6 +4,7 @@ import { AuthProvider, useAuth } from './context/AuthContext';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import Login from './pages/Login'; import Login from './pages/Login';
import Register from './pages/Register'; import Register from './pages/Register';
import CreateRecipe from './pages/CreateRecipe';
import './App.css'; import './App.css';
// Protected Route component // Protected Route component
@@ -101,6 +102,14 @@ function App() {
</PublicRoute> </PublicRoute>
} }
/> />
<Route
path="/create-recipe"
element={
<ProtectedRoute>
<CreateRecipe />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" />} /> <Route path="*" element={<Navigate to="/" />} />
</Routes> </Routes>
</div> </div>

View File

@@ -0,0 +1,325 @@
.create-recipe-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
background: #f8f9fa;
min-height: 100vh;
}
.create-recipe-header {
text-align: center;
margin-bottom: 2rem;
}
.create-recipe-header h1 {
color: #2c3e50;
font-size: 2.5rem;
margin-bottom: 0.5rem;
font-weight: 700;
}
.create-recipe-header p {
color: #6c757d;
font-size: 1.1rem;
margin: 0;
}
.create-recipe-form {
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.form-section {
margin-bottom: 2.5rem;
}
.form-section h2 {
color: #2c3e50;
font-size: 1.5rem;
margin-bottom: 1.5rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #e9ecef;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #495057;
font-weight: 600;
font-size: 0.9rem;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.75rem;
border: 2px solid #e9ecef;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
background: white;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #FF6B35;
box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.1);
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
/* Ingredients Section */
.ingredient-row {
display: grid;
grid-template-columns: 2fr 1fr 1fr auto;
gap: 0.75rem;
align-items: end;
margin-bottom: 1rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.ingredient-row .form-group {
margin-bottom: 0;
}
.ingredient-row input {
margin-bottom: 0;
}
/* Instructions Section */
.instruction-row {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 1rem;
align-items: start;
margin-bottom: 1rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.step-number {
background: #FF6B35;
color: white;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 0.9rem;
flex-shrink: 0;
margin-top: 0.75rem;
}
.instruction-row .form-group {
margin-bottom: 0;
}
.instruction-row textarea {
margin-bottom: 0;
}
/* Buttons */
.add-btn {
background: #28a745;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.3s ease, transform 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.add-btn:hover {
background: #218838;
transform: translateY(-1px);
}
.remove-btn {
background: #dc3545;
color: white;
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
font-size: 1.2rem;
font-weight: bold;
cursor: pointer;
transition: background-color 0.3s ease, transform 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.remove-btn:hover:not(:disabled) {
background: #c82333;
transform: scale(1.1);
}
.remove-btn:disabled {
background: #6c757d;
cursor: not-allowed;
opacity: 0.5;
}
/* Form Actions */
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 2rem;
padding-top: 2rem;
border-top: 2px solid #e9ecef;
}
.cancel-btn {
background: #6c757d;
color: white;
border: none;
padding: 1rem 2rem;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.3s ease, transform 0.2s ease;
}
.cancel-btn:hover:not(:disabled) {
background: #5a6268;
transform: translateY(-1px);
}
.submit-btn {
background: #FF6B35;
color: white;
border: none;
padding: 1rem 2rem;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.3s ease, transform 0.2s ease;
}
.submit-btn:hover:not(:disabled) {
background: #e55a2b;
transform: translateY(-1px);
}
.submit-btn:disabled,
.cancel-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
/* Messages */
.error-message {
background: #f8d7da;
color: #721c24;
padding: 1rem;
border-radius: 8px;
border: 1px solid #f5c6cb;
margin-bottom: 1.5rem;
font-weight: 500;
}
.success-message {
background: #d4edda;
color: #155724;
padding: 1rem;
border-radius: 8px;
border: 1px solid #c3e6cb;
margin-bottom: 1.5rem;
font-weight: 500;
}
/* Responsive Design */
@media (max-width: 768px) {
.create-recipe-container {
padding: 1rem;
}
.create-recipe-form {
padding: 1.5rem;
}
.create-recipe-header h1 {
font-size: 2rem;
}
.form-row {
grid-template-columns: 1fr;
}
.ingredient-row {
grid-template-columns: 1fr;
gap: 0.5rem;
}
.instruction-row {
grid-template-columns: 1fr;
gap: 0.5rem;
}
.step-number {
align-self: start;
margin-top: 0;
}
.form-actions {
flex-direction: column;
}
.form-actions button {
width: 100%;
}
}
@media (max-width: 480px) {
.create-recipe-container {
padding: 0.5rem;
}
.create-recipe-form {
padding: 1rem;
}
.create-recipe-header h1 {
font-size: 1.75rem;
}
.form-section h2 {
font-size: 1.25rem;
}
}

View File

@@ -0,0 +1,377 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../services/api';
import './CreateRecipe.css';
interface Ingredient {
name: string;
amount: number;
unit: string;
}
interface Instruction {
step: number;
description: string;
}
const CreateRecipe: React.FC = () => {
const navigate = useNavigate();
const { user } = useAuth();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
// Form state
const [formData, setFormData] = useState({
title: '',
description: '',
servings: 4,
prepTime: 0,
cookTime: 0,
category: 'dinner' as 'breakfast' | 'lunch' | 'dinner' | 'dessert' | 'snack' | 'appetizer',
difficulty: 'medium' as 'easy' | 'medium' | 'hard',
imageUrl: ''
});
const [ingredients, setIngredients] = useState<Ingredient[]>([
{ name: '', amount: 0, unit: '' }
]);
const [instructions, setInstructions] = useState<Instruction[]>([
{ step: 1, description: '' }
]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: name === 'servings' || name === 'prepTime' || name === 'cookTime'
? parseInt(value) || 0
: value
}));
};
const addIngredient = () => {
setIngredients(prev => [...prev, { name: '', amount: 0, unit: '' }]);
};
const removeIngredient = (index: number) => {
if (ingredients.length > 1) {
setIngredients(prev => prev.filter((_, i) => i !== index));
}
};
const updateIngredient = (index: number, field: keyof Ingredient, value: string | number) => {
setIngredients(prev => prev.map((ingredient, i) =>
i === index ? { ...ingredient, [field]: value } : ingredient
));
};
const addInstruction = () => {
setInstructions(prev => [...prev, { step: prev.length + 1, description: '' }]);
};
const removeInstruction = (index: number) => {
if (instructions.length > 1) {
setInstructions(prev =>
prev.filter((_, i) => i !== index)
.map((instruction, i) => ({ ...instruction, step: i + 1 }))
);
}
};
const updateInstruction = (index: number, description: string) => {
setInstructions(prev => prev.map((instruction, i) =>
i === index ? { ...instruction, description } : instruction
));
};
const validateForm = () => {
if (!formData.title.trim()) return 'Recipe title is required';
if (!formData.description.trim()) return 'Recipe description is required';
if (formData.prepTime < 0) return 'Prep time must be positive';
if (formData.cookTime < 0) return 'Cook time must be positive';
if (formData.servings < 1) return 'Servings must be at least 1';
const validIngredients = ingredients.filter(ing =>
ing.name.trim() && ing.amount > 0 && ing.unit.trim()
);
if (validIngredients.length === 0) return 'At least one complete ingredient is required';
const validInstructions = instructions.filter(inst => inst.description.trim());
if (validInstructions.length === 0) return 'At least one instruction is required';
return null;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccess('');
const validationError = validateForm();
if (validationError) {
setError(validationError);
return;
}
setLoading(true);
try {
// Filter out empty ingredients and instructions
const validIngredients = ingredients.filter(ing =>
ing.name.trim() && ing.amount > 0 && ing.unit.trim()
);
const validInstructions = instructions.filter(inst => inst.description.trim());
const recipeData = {
...formData,
ingredients: validIngredients,
instructions: validInstructions
};
await api.post('/recipes', recipeData);
setSuccess('Recipe created successfully!');
// Redirect to dashboard after a short delay
setTimeout(() => {
navigate('/');
}, 2000);
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to create recipe');
} finally {
setLoading(false);
}
};
const handleCancel = () => {
navigate('/');
};
return (
<div className="create-recipe-container">
<div className="create-recipe-header">
<h1>Create New Recipe</h1>
<p>Share your culinary creation with the community</p>
</div>
<form onSubmit={handleSubmit} className="create-recipe-form">
{error && <div className="error-message">{error}</div>}
{success && <div className="success-message">{success}</div>}
{/* Basic Information */}
<div className="form-section">
<h2>Basic Information</h2>
<div className="form-group">
<label htmlFor="title">Recipe Title *</label>
<input
type="text"
id="title"
name="title"
value={formData.title}
onChange={handleInputChange}
placeholder="Enter recipe title"
required
/>
</div>
<div className="form-group">
<label htmlFor="description">Description *</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleInputChange}
placeholder="Describe your recipe"
rows={3}
required
/>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="category">Category *</label>
<select
id="category"
name="category"
value={formData.category}
onChange={handleInputChange}
required
>
<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="form-group">
<label htmlFor="difficulty">Difficulty</label>
<select
id="difficulty"
name="difficulty"
value={formData.difficulty}
onChange={handleInputChange}
>
<option value="easy">Easy</option>
<option value="medium">Medium</option>
<option value="hard">Hard</option>
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="servings">Servings</label>
<input
type="number"
id="servings"
name="servings"
value={formData.servings}
onChange={handleInputChange}
min="1"
max="20"
/>
</div>
<div className="form-group">
<label htmlFor="prepTime">Prep Time (minutes)</label>
<input
type="number"
id="prepTime"
name="prepTime"
value={formData.prepTime}
onChange={handleInputChange}
min="0"
/>
</div>
<div className="form-group">
<label htmlFor="cookTime">Cook Time (minutes)</label>
<input
type="number"
id="cookTime"
name="cookTime"
value={formData.cookTime}
onChange={handleInputChange}
min="0"
/>
</div>
</div>
<div className="form-group">
<label htmlFor="imageUrl">Image URL (optional)</label>
<input
type="url"
id="imageUrl"
name="imageUrl"
value={formData.imageUrl}
onChange={handleInputChange}
placeholder="https://example.com/image.jpg"
/>
</div>
</div>
{/* Ingredients */}
<div className="form-section">
<h2>Ingredients</h2>
{ingredients.map((ingredient, index) => (
<div key={index} className="ingredient-row">
<div className="form-group">
<input
type="text"
placeholder="Ingredient name"
value={ingredient.name}
onChange={(e) => updateIngredient(index, 'name', e.target.value)}
/>
</div>
<div className="form-group">
<input
type="number"
placeholder="Amount"
value={ingredient.amount || ''}
onChange={(e) => updateIngredient(index, 'amount', parseFloat(e.target.value) || 0)}
min="0"
step="0.1"
/>
</div>
<div className="form-group">
<input
type="text"
placeholder="Unit (cups, tsp, etc.)"
value={ingredient.unit}
onChange={(e) => updateIngredient(index, 'unit', e.target.value)}
/>
</div>
<button
type="button"
onClick={() => removeIngredient(index)}
className="remove-btn"
disabled={ingredients.length === 1}
>
×
</button>
</div>
))}
<button type="button" onClick={addIngredient} className="add-btn">
+ Add Ingredient
</button>
</div>
{/* Instructions */}
<div className="form-section">
<h2>Instructions</h2>
{instructions.map((instruction, index) => (
<div key={index} className="instruction-row">
<div className="step-number">{instruction.step}</div>
<div className="form-group">
<textarea
placeholder="Describe this step"
value={instruction.description}
onChange={(e) => updateInstruction(index, e.target.value)}
rows={2}
/>
</div>
<button
type="button"
onClick={() => removeInstruction(index)}
className="remove-btn"
disabled={instructions.length === 1}
>
×
</button>
</div>
))}
<button type="button" onClick={addInstruction} className="add-btn">
+ Add Step
</button>
</div>
{/* Form Actions */}
<div className="form-actions">
<button
type="button"
onClick={handleCancel}
className="cancel-btn"
disabled={loading}
>
Cancel
</button>
<button
type="submit"
className="submit-btn"
disabled={loading}
>
{loading ? 'Creating Recipe...' : 'Create Recipe'}
</button>
</div>
</form>
</div>
);
};
export default CreateRecipe;

View File

@@ -73,6 +73,31 @@
text-align: center; text-align: center;
} }
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.create-recipe-button {
background: #FF6B35;
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 6px;
}
.create-recipe-button:hover {
background: #e55a2b;
transform: translateY(-1px);
}
.logout-button { .logout-button {
background: #f44336; background: #f44336;
color: white; color: white;

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import RecipeList from '../components/RecipeList'; import RecipeList from '../components/RecipeList';
import ShoppingList from '../components/ShoppingList'; import ShoppingList from '../components/ShoppingList';
@@ -6,6 +7,7 @@ import { UserSelection } from '../services/api';
import './Dashboard.css'; import './Dashboard.css';
const Dashboard: React.FC = () => { const Dashboard: React.FC = () => {
const navigate = useNavigate();
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const [userSelection, setUserSelection] = useState<UserSelection | null>(null); const [userSelection, setUserSelection] = useState<UserSelection | null>(null);
const [activeTab, setActiveTab] = useState<'recipes' | 'shopping'>('recipes'); const [activeTab, setActiveTab] = useState<'recipes' | 'shopping'>('recipes');
@@ -30,7 +32,6 @@ const Dashboard: React.FC = () => {
<h1 className="app-title">Scoffer</h1> <h1 className="app-title">Scoffer</h1>
<p className="welcome-text">Welcome back, {user?.username}!</p> <p className="welcome-text">Welcome back, {user?.username}!</p>
</div> </div>
<div className="header-right"> <div className="header-right">
<div className="stats-container"> <div className="stats-container">
<div className="stat-item"> <div className="stat-item">
@@ -42,13 +43,19 @@ const Dashboard: React.FC = () => {
<span className="stat-label">Ingredients</span> <span className="stat-label">Ingredients</span>
</div> </div>
</div> </div>
<div className="header-actions">
<button
className="create-recipe-button"
onClick={() => navigate('/create-recipe')}
>
+ Create Recipe
</button>
<button className="logout-button" onClick={logout}> <button className="logout-button" onClick={logout}>
Logout Logout
</button> </button>
</div> </div>
</div> </div>
</div>
<nav className="tab-navigation"> <nav className="tab-navigation">
<button <button
className={`tab-button ${activeTab === 'recipes' ? 'active' : ''}`} className={`tab-button ${activeTab === 'recipes' ? 'active' : ''}`}