diff --git a/backend/src/controllers/recipeController.ts b/backend/src/controllers/recipeController.ts index f0873a0..2966932 100644 --- a/backend/src/controllers/recipeController.ts +++ b/backend/src/controllers/recipeController.ts @@ -50,6 +50,7 @@ export const addRecipe = async (req: Request, res: Response): Promise => { return; } try { + console.log(req.body); const createdRecipe = await model.addRecipe(req.body); res.status(201).json(createdRecipe); } catch (error) { @@ -61,6 +62,27 @@ export const addRecipe = async (req: Request, res: Response): Promise => { } }; +export const updateRecipe = async ( + req: Request, + res: Response, +): Promise => { + console.log(req.body); + const id = parseInt(req.params.id, 10); + if (process.env.NODE_ENV === "demo") { + return; + } + try { + const updatedRecipe = await model.updateRecipe(req.body, id); + res.status(201).json(updatedRecipe); + } catch (error) { + res.status(500).json({ + msg: "Failed to add recipe", + source: "recipeController", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + export const setStars = async (req: Request, res: Response): Promise => { if (process.env.NODE_ENV === "demo") { return; @@ -104,6 +126,7 @@ export default { getAllRecipes, getRecipeById, addRecipe, + updateRecipe, setStars, deleteRecipe, }; diff --git a/backend/src/models/recipeModel.ts b/backend/src/models/recipeModel.ts index dd81ae4..a46f67f 100644 --- a/backend/src/models/recipeModel.ts +++ b/backend/src/models/recipeModel.ts @@ -12,7 +12,7 @@ class RecipeModel { async getAllRecipes(): Promise { try { - logger.info("index page view") + logger.info("index page view"); return await this.prisma.recipes.findMany(); } catch (err) { console.error("Error fetching all recipes:", err); @@ -46,7 +46,10 @@ class RecipeModel { instruction: step.instruction, })), }; - logger.info("recipe page view", { recipe_id: data.details.id, recipe_name: data.details.name }) + logger.info("recipe page view", { + recipe_id: data.details.id, + recipe_name: data.details.name, + }); return data; } catch (err) { console.log("Error finding recipe:", err); @@ -58,25 +61,25 @@ class RecipeModel { } async addRecipe(recipeData: { - name: string; - author: string; - cuisine: string; - stars: number; + details: { + name: string; + author: string; + cuisine: string; + stars: number; + prep_minutes: number; + cook_minutes: number; + }; ingredients: string[]; steps: { [key: string]: string }; - prep_minutes: number; - cook_minutes: number; }): Promise { - const { - name, - author, - cuisine, - stars, - ingredients, - steps, - prep_minutes, - cook_minutes, - } = recipeData; + const name = recipeData.details.name; + const author = recipeData.details.author; + const cuisine = recipeData.details.cuisine; + const stars = recipeData.details.stars; + const prep_minutes = recipeData.details.prep_minutes; + const cook_minutes = recipeData.details.cook_minutes; + const ingredients = recipeData.ingredients; + const steps = recipeData.steps; try { const createdRecipe = await this.prisma.recipes.create({ data: { @@ -90,10 +93,7 @@ class RecipeModel { create: ingredients.map((ing) => ({ raw: ing })), }, recipeSteps: { - create: Object.keys(steps).map((stepNumber) => ({ - step_number: parseInt(stepNumber), - instruction: steps[stepNumber], - })), + create: steps, }, }, }); @@ -112,6 +112,67 @@ class RecipeModel { } } + async updateRecipe( + recipeData: { + details: { + name: string; + author: string; + cuisine: string; + stars: number; + prep_minutes: number; + cook_minutes: number; + }; + ingredients: string[]; + steps: { [key: string]: string }; + }, + id: number, + ): Promise { + const name = recipeData.details.name; + const author = recipeData.details.author; + const cuisine = recipeData.details.cuisine; + const stars = recipeData.details.stars; + const prep_minutes = recipeData.details.prep_minutes; + const cook_minutes = recipeData.details.cook_minutes; + const ingredients = recipeData.ingredients; + const steps = recipeData.steps; + try { + await this.deleteRecipeData(id); + const updatedRecipe = await this.prisma.recipes.update({ + where: { id }, + data: { + name, + author, + cuisine, + prep_minutes, + cook_minutes, + stars, + recipeIngredients: { + create: ingredients.map((ing) => ({ raw: ing })), + }, + recipeSteps: { + create: steps, + }, + }, + }); + if (!updatedRecipe) { + logger.warn(`Recipe with id ${id} cannot be found`); + return null; + } + + logger.info("Updated Recipe", { + id: updatedRecipe.id, + name: updatedRecipe.name, + }); + return updatedRecipe; + } catch (err) { + console.log("Error updating recipe:", err); + logger.error("Error updating recipe", { + message: err instanceof Error ? err.message : "Unknown error", + }); + throw new Error("Failed to update recipe"); + } + } + async setStars(id: number, stars: number): Promise<{ message: string }> { try { await this.prisma.recipes.update({ @@ -130,13 +191,7 @@ class RecipeModel { async deleteRecipe(id: number): Promise<{ message: string }> { try { - await this.prisma.recipe_ingredients.deleteMany({ - where: { recipe_id: id }, - }); - - await this.prisma.recipe_steps.deleteMany({ - where: { recipe_id: id }, - }); + this.deleteRecipeData(id); const deletedRecipe = await this.prisma.recipes.delete({ where: { id }, }); @@ -153,6 +208,25 @@ class RecipeModel { throw new Error(err instanceof Error ? err.message : "Unknown error"); } } + + async deleteRecipeData(id: number): Promise<{ message: string }> { + try { + await this.prisma.recipe_ingredients.deleteMany({ + where: { recipe_id: id }, + }); + + await this.prisma.recipe_steps.deleteMany({ + where: { recipe_id: id }, + }); + return { message: "Recipe data deleted successfully" }; + } catch (err) { + console.error("Error deleting recipe:", err); + logger.error("Error deleting recipe", { + message: err instanceof Error ? err.message : "Unknown error", + }); + throw new Error(err instanceof Error ? err.message : "Unknown error"); + } + } } export default RecipeModel; diff --git a/backend/src/routes/appRoutes.ts b/backend/src/routes/appRoutes.ts index 5888abe..f25e8e3 100644 --- a/backend/src/routes/appRoutes.ts +++ b/backend/src/routes/appRoutes.ts @@ -11,6 +11,8 @@ router.get("/recipe/:id", recipeController.getRecipeById); router.post("/add-recipe", recipeController.addRecipe); +router.post("/update-recipe/:id", recipeController.updateRecipe); + router.post("/set-stars", recipeController.setStars); router.delete("/delete-recipe", recipeController.deleteRecipe); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a15c4a9..392f1df 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,9 +2,10 @@ import "./App.css"; import Index from "./pages/Index.tsx"; import RecipePage from "./pages/RecipePage.tsx"; import AddRecipe from "./pages/AddRecipe.tsx"; -import About from "./pages/About.tsx" -import RecipeIngredients from "./pages/RecipeIngredients.tsx" -import RecipeSteps from "./pages/RecipeSteps.tsx" +import About from "./pages/About.tsx"; +import RecipeIngredients from "./pages/RecipeIngredients.tsx"; +import RecipeSteps from "./pages/RecipeSteps.tsx"; +import EditRecipe from "./pages/EditRecipe.tsx"; import RecipeBookTabs from "./components/RecipeBookTabs.tsx"; import { Routes, Route } from "react-router-dom"; @@ -18,6 +19,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/RecipeForm.tsx b/frontend/src/components/RecipeForm.tsx new file mode 100644 index 0000000..f700482 --- /dev/null +++ b/frontend/src/components/RecipeForm.tsx @@ -0,0 +1,152 @@ +import React, { useEffect, useState } from "react"; +import AddBulkIngredients from "./AddBulkIngredients.tsx"; +import AddBulkSteps from "./AddBulkSteps.tsx"; +import StarRating from "./StarRating.tsx"; +import { isProfane } from "../utils/profanityFilter"; +import { type Recipe } from "../types/Recipe.ts"; + +interface Step { + step_number: number; + instruction: string; +} + +interface RecipeFormProps { + onSubmit: (recipeData: Recipe) => Promise; + initialData?: Recipe; +} + +const RecipeForm: React.FC = ({ onSubmit, initialData }) => { + const [ingredients, setIngredients] = useState([]); + const [steps, setSteps] = useState([]); + const [recipeName, setRecipeName] = useState(""); + const [recipeCuisine, setRecipeCuisine] = useState(""); + const [author, setAuthor] = useState(""); + const [stars, setStars] = useState(0); + const [prepMinutes, setPrepMinutes] = useState(5); + const [cookMinutes, setCookMinutes] = useState(5); + + useEffect(() => { + if (initialData) { + setRecipeName(initialData.details.name || ""); + setRecipeCuisine(initialData.details.cuisine || ""); + setAuthor(initialData.details.author || ""); + setStars(initialData.details.stars || 0); + setPrepMinutes(initialData.details.prep_minutes || 5); + setCookMinutes(initialData.details.cook_minutes || 5); + setIngredients(initialData.ingredients); + setSteps(initialData.steps); + } + }, [initialData]); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if ( + isProfane(recipeName) || + isProfane(recipeCuisine) || + ingredients.some(isProfane) || + steps.some((step) => isProfane(step.instruction)) || + isProfane(author) + ) { + alert("Your submission contains inappropriate language."); + return; + } + + const recipeData = { + details: { + name: recipeName, + cuisine: recipeCuisine.toLowerCase(), + author: author, + prep_minutes: prepMinutes, + cook_minutes: cookMinutes, + stars: stars, + }, + ingredients: ingredients, + steps: steps.map((step) => ({ + id: undefined, + step_number: step.step_number, + instruction: step.instruction, + })), + }; + + await onSubmit(recipeData); + }; + + return ( +
+ setRecipeName(e.target.value)} + /> + setRecipeCuisine(e.target.value)} + /> + setAuthor(e.target.value)} + /> +
+
+ + setPrepMinutes(parseInt(e.target.value))} + /> + + minutes + +
+
+ + setCookMinutes(parseInt(e.target.value))} + /> + + minutes + +
+ setStars(newRating)} + /> +
+ + + + + ); +}; + +export default RecipeForm; diff --git a/frontend/src/pages/AddRecipe.tsx b/frontend/src/pages/AddRecipe.tsx index 37bef30..5e684fe 100644 --- a/frontend/src/pages/AddRecipe.tsx +++ b/frontend/src/pages/AddRecipe.tsx @@ -1,191 +1,27 @@ import React, { useState } from "react"; import { addRecipe } from "../services/frontendApi.js"; import { useNavigate } from "react-router-dom"; -import AddBulkIngredients from "../components/AddBulkIngredients.tsx"; -import AddBulkSteps from "../components/AddBulkSteps.tsx"; -import StarRating from "../components/StarRating.tsx"; +import RecipeForm from "../components/RecipeForm.tsx"; import DemoModal from "../components/DemoModal.tsx"; -import "../css/colorTheme.css"; -import { isProfane } from "../utils/profanityFilter"; - -interface Step { - step_number: number; - instruction: string; -} +import { type Recipe } from "../types/Recipe.ts" function AddRecipe() { - const [newRecipeId, setNewRecipeId] = useState(null); - const [ingredients, setIngredients] = useState([]); - const [steps, setSteps] = useState([]); - const [showBulkForm, setShowBulkForm] = useState(true); - const [recipeName, setRecipeName] = useState(""); - const [recipeCuisine, setRecipeCuisine] = useState(""); - const [author, setAuthor] = useState(""); - const [stars, setStars] = useState(0); - const [prepMinutes, setPrepMinutes] = useState(5); - const [cookMinutes, setCookMinutes] = useState(5); const [showDemoModal, setShowDemoModal] = useState(false); const navigate = useNavigate(); - const addRecipeForm = async (event: React.FormEvent) => { - event.preventDefault(); - if ( - isProfane(recipeName) || - isProfane(recipeCuisine) || - ingredients.some((ingredient) => isProfane(ingredient)) || - steps.some((step) => isProfane(step.instruction)) || - isProfane(author) - ) { - alert("Your submission contains inappropriate language."); - return; - } + const addRecipeForm = async (recipeData: Recipe) => { if (process.env.NODE_ENV === "demo") { setShowDemoModal(true); return; } - const stepsHash = Object.fromEntries( - steps.map((step) => [step.step_number, step.instruction]), - ); - if ( - recipeName && - recipeCuisine && - Object.keys(stepsHash).length > 0 && - ingredients.length > 0 - ) { - const recipeData = { - name: recipeName, - cuisine: recipeCuisine.toLowerCase(), - author: author, - prep_minutes: prepMinutes, - cook_minutes: cookMinutes, - stars: stars, - ingredients: ingredients, - steps: stepsHash, - }; - console.log(recipeData); - const data = await addRecipe(recipeData); - setNewRecipeId(data.id); - } else { - alert("missing required data"); - } - }; - React.useEffect(() => { - if (newRecipeId !== null && newRecipeId !== undefined) { - navigate(`/recipe/${newRecipeId}`); - } - }, [newRecipeId, navigate]); + const data = await addRecipe(recipeData); + navigate(`/recipe/${data.id}`); + }; return (
-
- setRecipeName(e.target.value)} - /> - setRecipeCuisine(e.target.value)} - /> - setAuthor(e.target.value)} - /> -
-
- - setPrepMinutes(parseInt(e.target.value))} - /> - - minutes - -
-
- - setCookMinutes(parseInt(e.target.value))} - /> - - minutes - -
-
- setStars(newRating)} - /> -
-
- -
- -
- {/*
    - {ingredients.map((ing, index) => ( -
  • - {ing} -
  • - ))} -
*/} -
- -
- {/*
    - {steps.map((step) => ( -
  • - {`${step.step_number}. ${step.instruction}`} -
  • - ))} -
*/} - -
+ {showDemoModal && ( ); } + export default AddRecipe; diff --git a/frontend/src/pages/EditRecipe.tsx b/frontend/src/pages/EditRecipe.tsx new file mode 100644 index 0000000..4f10949 --- /dev/null +++ b/frontend/src/pages/EditRecipe.tsx @@ -0,0 +1,50 @@ +import React, { useEffect, useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { getRecipeById, updateRecipe } from "../services/frontendApi.js"; +import RecipeForm from "../components/RecipeForm.tsx"; +import DemoModal from "../components/DemoModal.tsx"; +import { type Recipe } from "../types/Recipe.ts" + +function EditRecipe() { + const { id } = useParams(); + const navigate = useNavigate(); + + const [recipe, setRecipe] = useState(); + const [showDemoModal, setShowDemoModal] = useState(false); + + useEffect(() => { + const fetchRecipe = async () => { + const recipeData = await getRecipeById(id); + setRecipe(recipeData); + }; + + fetchRecipe(); + }, [id]); + + const updateRecipeForm = async (recipeData: Recipe) => { + if (process.env.NODE_ENV === "demo") { + setShowDemoModal(true); + return; + } + + await updateRecipe(id, recipeData); + navigate(`/recipe/${id}`); + }; + + return ( +
+ {recipe && ( + + )} + {showDemoModal && ( + setShowDemoModal(false)} + closeModal={() => setShowDemoModal(false)} + /> + )} +
+ ); +} + +export default EditRecipe; diff --git a/frontend/src/pages/RecipePage.tsx b/frontend/src/pages/RecipePage.tsx index 41a9083..5043b1b 100644 --- a/frontend/src/pages/RecipePage.tsx +++ b/frontend/src/pages/RecipePage.tsx @@ -111,21 +111,37 @@ function RecipePage() {
- +
+ + +

{recipe.details.name}

- +
+ + +

@@ -146,7 +162,10 @@ function RecipePage() {

    {recipe.ingredients.map((ingredient: string, index) => ( -
  • +
  • {ingredient}
  • diff --git a/frontend/src/services/frontendApi.js b/frontend/src/services/frontendApi.js index eeb08ee..16b5a7d 100644 --- a/frontend/src/services/frontendApi.js +++ b/frontend/src/services/frontendApi.js @@ -42,6 +42,18 @@ export const addRecipe = async (recipeData) => { return data; }; +export const updateRecipe = async (id, recipeData) => { + console.log("updateRecipe"); + console.log(recipeData); + const response = await fetch(`${baseUrl}/update-recipe/${id}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(recipeData), + }); + const data = await response.json(); + return data; +}; + export const setDBStars = async (id, stars) => { console.log(JSON.stringify({ id: id, stars: stars })); // return diff --git a/frontend/src/types/Recipe.ts b/frontend/src/types/Recipe.ts index eedf2cb..d206645 100644 --- a/frontend/src/types/Recipe.ts +++ b/frontend/src/types/Recipe.ts @@ -1,5 +1,5 @@ interface Step { - id: number; + id?: number; step_number: number; instruction: string; } @@ -17,6 +17,7 @@ interface Recipe { ingredients: string[]; steps: Step[]; } + // smaller Recipe type returned by backend at /recipes for all interface RecipeSmall { id: number;