diff --git a/backend/models/Recipe.js b/backend/models/Recipe.js index a512c55..00ae517 100644 --- a/backend/models/Recipe.js +++ b/backend/models/Recipe.js @@ -55,6 +55,11 @@ const recipeSchema = new mongoose.Schema({ type: String, default: '' }, + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, createdAt: { type: Date, default: Date.now diff --git a/backend/routes/recipes.js b/backend/routes/recipes.js index 50b88ca..47f212f 100644 --- a/backend/routes/recipes.js +++ b/backend/routes/recipes.js @@ -1,7 +1,26 @@ const express = require('express'); const router = express.Router(); +const jwt = require('jsonwebtoken'); 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 router.get('/', async (req, res) => { try { @@ -37,10 +56,13 @@ router.get('/:id', async (req, res) => { } }); -// Create new recipe -router.post('/', async (req, res) => { +// Create new recipe (protected route) +router.post('/', authenticateToken, async (req, res) => { 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(); res.status(201).json(recipe); } catch (error) { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8d5b45f..a8b0078 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { AuthProvider, useAuth } from './context/AuthContext'; import Dashboard from './pages/Dashboard'; import Login from './pages/Login'; import Register from './pages/Register'; +import CreateRecipe from './pages/CreateRecipe'; import './App.css'; // Protected Route component @@ -101,6 +102,14 @@ function App() { } /> + + + + } + /> } /> diff --git a/frontend/src/pages/CreateRecipe.css b/frontend/src/pages/CreateRecipe.css new file mode 100644 index 0000000..1b5fa2f --- /dev/null +++ b/frontend/src/pages/CreateRecipe.css @@ -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; + } +} diff --git a/frontend/src/pages/CreateRecipe.tsx b/frontend/src/pages/CreateRecipe.tsx new file mode 100644 index 0000000..bb28a64 --- /dev/null +++ b/frontend/src/pages/CreateRecipe.tsx @@ -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([ + { name: '', amount: 0, unit: '' } + ]); + + const [instructions, setInstructions] = useState([ + { step: 1, description: '' } + ]); + + const handleInputChange = (e: React.ChangeEvent) => { + 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 ( +
+
+

Create New Recipe

+

Share your culinary creation with the community

+
+ +
+ {error &&
{error}
} + {success &&
{success}
} + + {/* Basic Information */} +
+

Basic Information

+ +
+ + +
+ +
+ +