add feature to delete recipes when logged in
This commit is contained in:
@@ -177,15 +177,62 @@ router.put('/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete recipe
|
// Delete recipe (protected route)
|
||||||
router.delete('/:id', async (req, res) => {
|
router.delete('/:id', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const recipe = await Recipe.findByIdAndDelete(req.params.id);
|
const recipeId = req.params.id;
|
||||||
|
|
||||||
|
// Find the recipe first to check ownership
|
||||||
|
const recipe = await Recipe.findById(recipeId);
|
||||||
if (!recipe) {
|
if (!recipe) {
|
||||||
return res.status(404).json({ error: 'Recipe not found' });
|
return res.status(404).json({ error: 'Recipe not found' });
|
||||||
}
|
}
|
||||||
res.json({ message: 'Recipe deleted successfully' });
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Remove recipe from all user selections
|
||||||
|
const UserSelection = require('../models/UserSelection');
|
||||||
|
await UserSelection.updateMany(
|
||||||
|
{ 'selectedRecipes.recipeId': recipeId },
|
||||||
|
{
|
||||||
|
$pull: {
|
||||||
|
selectedRecipes: { recipeId: recipeId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also clean up aggregated ingredients that reference this recipe
|
||||||
|
await UserSelection.updateMany(
|
||||||
|
{ 'aggregatedIngredients.recipes.recipeId': recipeId },
|
||||||
|
{
|
||||||
|
$pull: {
|
||||||
|
'aggregatedIngredients.$[].recipes': { recipeId: recipeId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove empty aggregated ingredients (those with no recipes left)
|
||||||
|
await UserSelection.updateMany(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
$pull: {
|
||||||
|
aggregatedIngredients: { recipes: { $size: 0 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Recipe deleted successfully and removed from all user menus',
|
||||||
|
deletedRecipeId: recipeId
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error deleting recipe:', error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ services:
|
|||||||
- "${MONGO_EXPRESS_PORT:-8081}:8081"
|
- "${MONGO_EXPRESS_PORT:-8081}:8081"
|
||||||
environment:
|
environment:
|
||||||
ME_CONFIG_MONGODB_URL: mongodb://mongodb:27017/
|
ME_CONFIG_MONGODB_URL: mongodb://mongodb:27017/
|
||||||
ME_CONFIG_BASICAUTH: false
|
ME_CONFIG_BASICAUTH: "false"
|
||||||
depends_on:
|
depends_on:
|
||||||
mongodb:
|
mongodb:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -52,13 +52,37 @@ const RecipeList: React.FC<RecipeListProps> = ({ onSelectionUpdate }) => {
|
|||||||
|
|
||||||
const handleAddToSelection = async (recipeId: string) => {
|
const handleAddToSelection = async (recipeId: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await selectionsAPI.addRecipe(recipeId, 1);
|
const response = await selectionsAPI.addRecipe(recipeId);
|
||||||
setUserSelection(response.data as UserSelection);
|
setUserSelection(response.data as UserSelection);
|
||||||
if (onSelectionUpdate) {
|
if (onSelectionUpdate) {
|
||||||
onSelectionUpdate(response.data as UserSelection);
|
onSelectionUpdate(response.data as UserSelection);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setError(error.response?.data?.error || 'Failed to add recipe to selection');
|
console.error('Error adding recipe to selection:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRecipeDeleted = (deletedRecipeId: string) => {
|
||||||
|
// Remove the deleted recipe from the recipes list
|
||||||
|
setRecipes(prevRecipes => prevRecipes.filter(recipe => recipe._id !== deletedRecipeId));
|
||||||
|
|
||||||
|
// Close the modal if the deleted recipe was being viewed
|
||||||
|
if (selectedRecipe && selectedRecipe._id === deletedRecipeId) {
|
||||||
|
setSelectedRecipe(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user selection to remove the deleted recipe (this should already be handled by backend)
|
||||||
|
if (userSelection) {
|
||||||
|
const updatedSelection = {
|
||||||
|
...userSelection,
|
||||||
|
selectedRecipes: userSelection.selectedRecipes.filter(
|
||||||
|
selection => selection.recipeId._id !== deletedRecipeId
|
||||||
|
)
|
||||||
|
};
|
||||||
|
setUserSelection(updatedSelection);
|
||||||
|
if (onSelectionUpdate) {
|
||||||
|
onSelectionUpdate(updatedSelection);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -183,6 +207,7 @@ const RecipeList: React.FC<RecipeListProps> = ({ onSelectionUpdate }) => {
|
|||||||
recipe={selectedRecipe}
|
recipe={selectedRecipe}
|
||||||
onClose={handleCloseModal}
|
onClose={handleCloseModal}
|
||||||
onAddToSelection={handleAddToSelection}
|
onAddToSelection={handleAddToSelection}
|
||||||
|
onRecipeDeleted={handleRecipeDeleted}
|
||||||
isSelected={isRecipeSelected(selectedRecipe._id)}
|
isSelected={isRecipeSelected(selectedRecipe._id)}
|
||||||
selectedQuantity={getSelectedQuantity(selectedRecipe._id)}
|
selectedQuantity={getSelectedQuantity(selectedRecipe._id)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -218,8 +218,121 @@
|
|||||||
|
|
||||||
.modal-footer {
|
.modal-footer {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-top: 1px solid #e0e0e0;
|
border-top: 1px solid #eee;
|
||||||
background: #f8f9fa;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete {
|
||||||
|
background: #dc3545;
|
||||||
|
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-delete:hover:not(:disabled) {
|
||||||
|
background: #c82333;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-confirm-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1100;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-confirm-dialog {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-confirm-dialog h3 {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
color: #dc3545;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-confirm-dialog p {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-warning {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-style: italic;
|
||||||
|
margin-bottom: 20px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-confirm-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-confirm-buttons .btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-confirm-buttons .btn-secondary {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-confirm-buttons .btn-secondary:hover:not(:disabled) {
|
||||||
|
background: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-confirm-buttons .btn-danger {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-confirm-buttons .btn-danger:hover:not(:disabled) {
|
||||||
|
background: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-confirm-buttons .btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-large {
|
.btn-large {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Recipe } from '../services/api';
|
import { Recipe, recipesAPI } from '../services/api';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
import './RecipeModal.css';
|
import './RecipeModal.css';
|
||||||
|
|
||||||
interface RecipeModalProps {
|
interface RecipeModalProps {
|
||||||
recipe: Recipe;
|
recipe: Recipe;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onAddToSelection: (recipeId: string) => void;
|
onAddToSelection: (recipeId: string) => void;
|
||||||
|
onRecipeDeleted?: (recipeId: string) => void;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
selectedQuantity: number;
|
selectedQuantity: number;
|
||||||
}
|
}
|
||||||
@@ -14,15 +16,45 @@ const RecipeModal: React.FC<RecipeModalProps> = ({
|
|||||||
recipe,
|
recipe,
|
||||||
onClose,
|
onClose,
|
||||||
onAddToSelection,
|
onAddToSelection,
|
||||||
|
onRecipeDeleted,
|
||||||
isSelected,
|
isSelected,
|
||||||
selectedQuantity,
|
selectedQuantity,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||||
if (e.target === e.currentTarget) {
|
if (e.target === e.currentTarget) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = () => {
|
||||||
|
setShowDeleteConfirm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await recipesAPI.delete(recipe._id);
|
||||||
|
onRecipeDeleted?.(recipe._id);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting recipe:', error);
|
||||||
|
alert('Failed to delete recipe. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCancel = () => {
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if current user is the creator of this recipe
|
||||||
|
const canDelete = user && recipe.createdBy === user.id;
|
||||||
|
|
||||||
const getDifficultyColor = (difficulty: string) => {
|
const getDifficultyColor = (difficulty: string) => {
|
||||||
switch (difficulty) {
|
switch (difficulty) {
|
||||||
case 'easy': return '#4CAF50';
|
case 'easy': return '#4CAF50';
|
||||||
@@ -131,13 +163,53 @@ const RecipeModal: React.FC<RecipeModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button
|
<div className="modal-footer-buttons">
|
||||||
className={`btn ${isSelected ? 'btn-success' : 'btn-primary'} btn-large`}
|
{canDelete && (
|
||||||
onClick={() => onAddToSelection(recipe._id)}
|
<button
|
||||||
>
|
className="btn btn-danger btn-delete"
|
||||||
{isSelected ? `Added to Menu (${selectedQuantity})` : 'Add to Menu'}
|
onClick={handleDeleteClick}
|
||||||
</button>
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? 'Deleting...' : 'Delete Recipe'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
{showDeleteConfirm && (
|
||||||
|
<div className="delete-confirm-overlay">
|
||||||
|
<div className="delete-confirm-dialog">
|
||||||
|
<h3>Delete Recipe</h3>
|
||||||
|
<p>Are you sure you want to delete "{recipe.title}"?</p>
|
||||||
|
<p className="delete-warning">
|
||||||
|
This action cannot be undone. The recipe will be removed from all users' menus.
|
||||||
|
</p>
|
||||||
|
<div className="delete-confirm-buttons">
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={handleDeleteCancel}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? 'Deleting...' : 'Delete Recipe'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export interface Recipe {
|
|||||||
difficulty: 'easy' | 'medium' | 'hard';
|
difficulty: 'easy' | 'medium' | 'hard';
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
createdBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
|
|||||||
Reference in New Issue
Block a user