add feature to edit recipes

This commit is contained in:
2025-08-10 14:38:04 +01:00
parent 85138a6575
commit 7c0907a109
7 changed files with 592 additions and 30 deletions

View File

@@ -160,19 +160,27 @@ router.post('/', authenticateToken, async (req, res) => {
}
});
// Update recipe
router.put('/:id', async (req, res) => {
// Update recipe (protected route)
router.put('/:id', authenticateToken, async (req, res) => {
try {
const recipe = await Recipe.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true, runValidators: true }
);
if (!recipe) {
const recipeId = req.params.id;
// Find the recipe first to preserve original creator
const existingRecipe = await Recipe.findById(recipeId);
if (!existingRecipe) {
return res.status(404).json({ error: 'Recipe not found' });
}
res.json(recipe);
// Update the recipe while preserving the createdBy field
const updatedRecipe = await Recipe.findByIdAndUpdate(
recipeId,
{ ...req.body, createdBy: existingRecipe.createdBy }, // Preserve original creator
{ new: true, runValidators: true }
);
res.json(updatedRecipe);
} catch (error) {
console.error('Error updating recipe:', error);
res.status(400).json({ error: error.message });
}
});
@@ -182,17 +190,12 @@ router.delete('/:id', authenticateToken, async (req, res) => {
try {
const recipeId = req.params.id;
// Find the recipe first to check ownership
// Find the recipe first to verify it exists
const recipe = await Recipe.findById(recipeId);
if (!recipe) {
return res.status(404).json({ error: 'Recipe not found' });
}
// Check if user owns the recipe (only recipe creator can delete)
if (recipe.createdBy.toString() !== req.userId) {
return res.status(403).json({ error: 'You can only delete recipes you created' });
}
// Delete the recipe
await Recipe.findByIdAndDelete(recipeId);

View File

@@ -5,6 +5,7 @@ import Dashboard from './pages/Dashboard';
import Login from './pages/Login';
import Register from './pages/Register';
import CreateRecipe from './pages/CreateRecipe';
import EditRecipe from './pages/EditRecipe';
import './App.css';
// Protected Route component
@@ -110,6 +111,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/edit-recipe/:id"
element={
<ProtectedRoute>
<EditRecipe />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</div>

View File

@@ -24,6 +24,7 @@ const IngredientAutocomplete: React.FC<IngredientAutocompleteProps> = ({
const [showSuggestions, setShowSuggestions] = useState(false);
const [loading, setLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [hasUserInteracted, setHasUserInteracted] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const suggestionsRef = useRef<HTMLDivElement>(null);
@@ -49,7 +50,8 @@ const IngredientAutocomplete: React.FC<IngredientAutocompleteProps> = ({
const response = await api.get(`/recipes/ingredients/suggestions?q=${encodeURIComponent(query)}`);
const suggestionData = response.data as IngredientSuggestion[];
setSuggestions(suggestionData);
setShowSuggestions(suggestionData.length > 0);
// Only show suggestions if user has interacted with the component
setShowSuggestions(hasUserInteracted && suggestionData.length > 0);
setSelectedIndex(-1);
} catch (error) {
console.error('Error fetching ingredient suggestions:', error);
@@ -74,6 +76,7 @@ const IngredientAutocomplete: React.FC<IngredientAutocompleteProps> = ({
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setHasUserInteracted(true);
onChange(newValue);
};
@@ -122,7 +125,9 @@ const IngredientAutocomplete: React.FC<IngredientAutocompleteProps> = ({
};
const handleFocus = () => {
if (value && suggestions.length > 0) {
setHasUserInteracted(true);
// Show suggestions if we have them and user has interacted
if (suggestions.length > 0) {
setShowSuggestions(true);
}
};

View File

@@ -231,6 +231,35 @@
justify-content: space-between;
}
.modal-action-buttons {
display: flex;
gap: 8px;
align-items: center;
}
.btn-edit {
background: #6c757d;
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-edit:hover:not(:disabled) {
background: #5a6268;
transform: translateY(-1px);
}
.btn-edit:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-delete {
background: #dc3545;
color: white;

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Recipe, recipesAPI } from '../services/api';
import { useAuth } from '../context/AuthContext';
import './RecipeModal.css';
@@ -21,6 +22,7 @@ const RecipeModal: React.FC<RecipeModalProps> = ({
selectedQuantity,
}) => {
const { user } = useAuth();
const navigate = useNavigate();
const [isDeleting, setIsDeleting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const handleBackdropClick = (e: React.MouseEvent) => {
@@ -52,8 +54,15 @@ const RecipeModal: React.FC<RecipeModalProps> = ({
setShowDeleteConfirm(false);
};
// Check if current user is the creator of this recipe
const canDelete = user && recipe.createdBy === user.id;
const handleEditClick = () => {
// Navigate to edit page with recipe data
navigate(`/edit-recipe/${recipe._id}`, { state: { recipe } });
onClose();
};
// Any logged-in user can edit or delete recipes
const canEdit = !!user;
const canDelete = !!user;
const getDifficultyColor = (difficulty: string) => {
switch (difficulty) {
@@ -164,6 +173,15 @@ const RecipeModal: React.FC<RecipeModalProps> = ({
<div className="modal-footer">
<div className="modal-footer-buttons">
<div className="modal-action-buttons">
{canEdit && (
<button
className="btn btn-secondary btn-edit"
onClick={handleEditClick}
>
Edit Recipe
</button>
)}
{canDelete && (
<button
className="btn btn-danger btn-delete"
@@ -173,6 +191,7 @@ const RecipeModal: React.FC<RecipeModalProps> = ({
{isDeleting ? 'Deleting...' : 'Delete Recipe'}
</button>
)}
</div>
<button
className={`btn ${isSelected ? 'btn-success' : 'btn-primary'} btn-large`}
onClick={() => onAddToSelection(recipe._id)}

View File

@@ -27,6 +27,7 @@ const UnitAutocomplete: React.FC<UnitAutocompleteProps> = ({
const [showSuggestions, setShowSuggestions] = useState(false);
const [loading, setLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [hasUserInteracted, setHasUserInteracted] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const suggestionsRef = useRef<HTMLDivElement>(null);
@@ -52,7 +53,8 @@ const UnitAutocomplete: React.FC<UnitAutocompleteProps> = ({
const response = await api.get(`/recipes/ingredients/units?ingredient=${encodeURIComponent(ingredientName)}`);
const unitData = response.data as UnitSuggestion[];
setSuggestions(unitData);
setShowSuggestions(unitData.length > 0);
// Only show suggestions if user has interacted with the component
setShowSuggestions(hasUserInteracted && unitData.length > 0);
setSelectedIndex(-1);
} catch (error) {
console.error('Error fetching unit suggestions:', error);
@@ -77,6 +79,7 @@ const UnitAutocomplete: React.FC<UnitAutocompleteProps> = ({
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setHasUserInteracted(true);
onChange(newValue);
};
@@ -125,7 +128,9 @@ const UnitAutocomplete: React.FC<UnitAutocompleteProps> = ({
};
const handleFocus = () => {
if (ingredient && suggestions.length > 0) {
setHasUserInteracted(true);
// Show suggestions if we have them and user has interacted
if (suggestions.length > 0) {
setShowSuggestions(true);
}
};

View File

@@ -0,0 +1,492 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { Recipe, recipesAPI } from '../services/api';
import IngredientAutocomplete from '../components/IngredientAutocomplete';
import UnitAutocomplete from '../components/UnitAutocomplete';
import './CreateRecipe.css'; // Reuse the same styles
interface Ingredient {
name: string;
amount: number;
unit: string;
}
interface Instruction {
step: number;
description: string;
}
interface RecipeFormData {
title: string;
description: string;
ingredients: Ingredient[];
instructions: Instruction[];
servings: number;
prepTime: number;
cookTime: number;
category: 'breakfast' | 'lunch' | 'dinner' | 'dessert' | 'snack' | 'appetizer';
difficulty: 'easy' | 'medium' | 'hard';
imageUrl: string;
}
const EditRecipe: React.FC = () => {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const location = useLocation();
const { user } = useAuth();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [fetchingRecipe, setFetchingRecipe] = useState(true);
const [formData, setFormData] = useState<RecipeFormData>({
title: '',
description: '',
ingredients: [{ name: '', amount: 0, unit: '' }],
instructions: [{ step: 1, description: '' }],
servings: 4,
prepTime: 15,
cookTime: 30,
category: 'dinner',
difficulty: 'medium',
imageUrl: ''
});
useEffect(() => {
const loadRecipe = async () => {
try {
setFetchingRecipe(true);
// Try to get recipe from location state first (passed from modal)
if (location.state?.recipe) {
const recipe = location.state.recipe as Recipe;
populateForm(recipe);
} else if (id) {
// Fallback: fetch recipe by ID
const response = await recipesAPI.getById(id);
const recipe = response.data as Recipe;
populateForm(recipe);
} else {
setError('No recipe data available');
return;
}
} catch (error: any) {
setError(error.response?.data?.error || 'Failed to load recipe');
} finally {
setFetchingRecipe(false);
}
};
loadRecipe();
}, [id, location.state]);
const populateForm = (recipe: Recipe) => {
setFormData({
title: recipe.title,
description: recipe.description,
ingredients: recipe.ingredients.length > 0 ? recipe.ingredients : [{ name: '', amount: 0, unit: '' }],
instructions: recipe.instructions.length > 0 ? recipe.instructions : [{ step: 1, description: '' }],
servings: recipe.servings,
prepTime: recipe.prepTime,
cookTime: recipe.cookTime,
category: recipe.category,
difficulty: recipe.difficulty,
imageUrl: recipe.imageUrl || ''
});
};
const handleInputChange = (field: keyof RecipeFormData, value: any) => {
setFormData(prev => ({
...prev,
[field]: value
}));
// Clear messages when user starts editing
if (error) setError(null);
if (success) setSuccess(null);
};
const updateIngredient = (index: number, field: keyof Ingredient, value: string | number) => {
const updatedIngredients = [...formData.ingredients];
updatedIngredients[index] = {
...updatedIngredients[index],
[field]: value
};
setFormData(prev => ({
...prev,
ingredients: updatedIngredients
}));
};
const addIngredient = () => {
setFormData(prev => ({
...prev,
ingredients: [...prev.ingredients, { name: '', amount: 0, unit: '' }]
}));
};
const removeIngredient = (index: number) => {
if (formData.ingredients.length > 1) {
setFormData(prev => ({
...prev,
ingredients: prev.ingredients.filter((_, i) => i !== index)
}));
}
};
const updateInstruction = (index: number, value: string) => {
const updatedInstructions = [...formData.instructions];
updatedInstructions[index] = {
...updatedInstructions[index],
description: value
};
setFormData(prev => ({
...prev,
instructions: updatedInstructions
}));
};
const addInstruction = () => {
setFormData(prev => ({
...prev,
instructions: [...prev.instructions, { step: prev.instructions.length + 1, description: '' }]
}));
};
const removeInstruction = (index: number) => {
if (formData.instructions.length > 1) {
const updatedInstructions = formData.instructions
.filter((_, i) => i !== index)
.map((instruction, i) => ({ ...instruction, step: i + 1 }));
setFormData(prev => ({
...prev,
instructions: updatedInstructions
}));
}
};
const validateForm = (): string | null => {
if (!formData.title.trim()) return 'Recipe title is required';
if (!formData.description.trim()) return 'Recipe description is required';
if (formData.ingredients.some(ing => !ing.name.trim() || ing.amount <= 0 || !ing.unit.trim())) {
return 'All ingredients must have a name, amount, and unit';
}
if (formData.instructions.some(inst => !inst.description.trim())) {
return 'All instruction steps must have a description';
}
if (formData.servings < 1) return 'Servings must be at least 1';
if (formData.prepTime < 0) return 'Prep time cannot be negative';
if (formData.cookTime < 0) return 'Cook time cannot be negative';
return null;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationError = validateForm();
if (validationError) {
setError(validationError);
return;
}
setLoading(true);
setError(null);
setSuccess(null);
try {
if (!id) {
setError('Recipe ID is missing');
return;
}
await recipesAPI.update(id, formData);
setSuccess('Recipe updated successfully!');
// Redirect back to dashboard after a short delay
setTimeout(() => {
navigate('/dashboard');
}, 2000);
} catch (error: any) {
setError(error.response?.data?.error || 'Failed to update recipe');
} finally {
setLoading(false);
}
};
const handleCancel = () => {
navigate('/dashboard');
};
if (fetchingRecipe) {
return (
<div className="create-recipe-container">
<div className="loading-container">
<div className="loading-spinner"></div>
<p>Loading recipe...</p>
</div>
</div>
);
}
if (error && !formData.title) {
return (
<div className="create-recipe-container">
<div className="error-container">
<p className="error-message">{error}</p>
<button className="btn btn-secondary" onClick={handleCancel}>
Back to Dashboard
</button>
</div>
</div>
);
}
return (
<div className="create-recipe-container">
<div className="create-recipe-header">
<h1>Edit Recipe</h1>
<p>Update your recipe details</p>
</div>
<form onSubmit={handleSubmit} className="create-recipe-form">
{/* Basic Information */}
<div className="form-section">
<h2>Basic Information</h2>
<div className="form-group">
<label htmlFor="title">Recipe Title *</label>
<input
id="title"
type="text"
value={formData.title}
onChange={(e) => handleInputChange('title', e.target.value)}
placeholder="Enter recipe title"
required
/>
</div>
<div className="form-group">
<label htmlFor="description">Description *</label>
<textarea
id="description"
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
placeholder="Describe your recipe"
rows={3}
required
/>
</div>
<div className="form-group">
<label htmlFor="imageUrl">Image URL (optional)</label>
<input
id="imageUrl"
type="url"
value={formData.imageUrl}
onChange={(e) => handleInputChange('imageUrl', e.target.value)}
placeholder="https://example.com/image.jpg"
/>
</div>
</div>
{/* Recipe Details */}
<div className="form-section">
<h2>Recipe Details</h2>
<div className="form-row">
<div className="form-group">
<label htmlFor="servings">Servings *</label>
<input
id="servings"
type="number"
min="1"
value={formData.servings}
onChange={(e) => handleInputChange('servings', parseInt(e.target.value) || 1)}
required
/>
</div>
<div className="form-group">
<label htmlFor="prepTime">Prep Time (minutes) *</label>
<input
id="prepTime"
type="number"
min="0"
value={formData.prepTime}
onChange={(e) => handleInputChange('prepTime', parseInt(e.target.value) || 0)}
required
/>
</div>
<div className="form-group">
<label htmlFor="cookTime">Cook Time (minutes) *</label>
<input
id="cookTime"
type="number"
min="0"
value={formData.cookTime}
onChange={(e) => handleInputChange('cookTime', parseInt(e.target.value) || 0)}
required
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="category">Category *</label>
<select
id="category"
value={formData.category}
onChange={(e) => handleInputChange('category', e.target.value)}
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"
value={formData.difficulty}
onChange={(e) => handleInputChange('difficulty', e.target.value)}
required
>
<option value="easy">Easy</option>
<option value="medium">Medium</option>
<option value="hard">Hard</option>
</select>
</div>
</div>
</div>
{/* Ingredients */}
<div className="form-section">
<h2>Ingredients</h2>
{formData.ingredients.map((ingredient, index) => (
<div key={index} className="ingredient-row">
<div className="form-group ingredient-name-group">
<IngredientAutocomplete
value={ingredient.name}
onChange={(value) => updateIngredient(index, 'name', value)}
placeholder="Ingredient name"
/>
</div>
<div className="form-group">
<input
type="number"
step="0.1"
min="0"
placeholder="Amount"
value={ingredient.amount || ''}
onChange={(e) => updateIngredient(index, 'amount', parseFloat(e.target.value) || 0)}
/>
</div>
<div className="form-group">
<UnitAutocomplete
value={ingredient.unit}
onChange={(value) => updateIngredient(index, 'unit', value)}
ingredient={ingredient.name}
placeholder="Unit (cups, tsp, etc.)"
/>
</div>
<button
type="button"
className="btn btn-danger btn-small"
onClick={() => removeIngredient(index)}
disabled={formData.ingredients.length === 1}
>
Remove
</button>
</div>
))}
<button
type="button"
className="btn btn-secondary"
onClick={addIngredient}
>
Add Ingredient
</button>
</div>
{/* Instructions */}
<div className="form-section">
<h2>Instructions</h2>
{formData.instructions.map((instruction, index) => (
<div key={index} className="instruction-row">
<div className="instruction-number">{instruction.step}</div>
<div className="form-group instruction-input-group">
<textarea
placeholder="Describe this step"
value={instruction.description}
onChange={(e) => updateInstruction(index, e.target.value)}
rows={2}
/>
</div>
<button
type="button"
className="btn btn-danger btn-small"
onClick={() => removeInstruction(index)}
disabled={formData.instructions.length === 1}
>
Remove
</button>
</div>
))}
<button
type="button"
className="btn btn-secondary"
onClick={addInstruction}
>
Add Step
</button>
</div>
{/* Messages */}
{error && (
<div className="error-message">
{error}
</div>
)}
{success && (
<div className="success-message">
{success}
</div>
)}
{/* Submit Buttons */}
<div className="form-actions">
<button
type="button"
className="btn btn-secondary"
onClick={handleCancel}
disabled={loading}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
disabled={loading}
>
{loading ? 'Updating Recipe...' : 'Update Recipe'}
</button>
</div>
</form>
</div>
);
};
export default EditRecipe;