edit recipe
This commit is contained in:
parent
798e879863
commit
8b52d31dcf
10 changed files with 389 additions and 217 deletions
|
|
@ -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() {
|
|||
<Route path="/" element={<Index />} />
|
||||
<Route path="/recipe/:id" element={<RecipePage />} />
|
||||
<Route path="/add-recipe" element={<AddRecipe />} />
|
||||
<Route path="/edit-recipe/:id" element={<EditRecipe />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/recipe-ingredients" element={<RecipeIngredients />} />
|
||||
<Route path="/recipe-steps" element={<RecipeSteps />} />
|
||||
|
|
|
|||
152
frontend/src/components/RecipeForm.tsx
Normal file
152
frontend/src/components/RecipeForm.tsx
Normal file
|
|
@ -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<void>;
|
||||
initialData?: Recipe;
|
||||
}
|
||||
|
||||
const RecipeForm: React.FC<RecipeFormProps> = ({ onSubmit, initialData }) => {
|
||||
const [ingredients, setIngredients] = useState<string[]>([]);
|
||||
const [steps, setSteps] = useState<Step[]>([]);
|
||||
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 (
|
||||
<form onSubmit={handleSubmit} className="add-recipe-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
className="recipe-name mb-4 p-2 border border-[var(--color-primaryBorder)] rounded w-full"
|
||||
value={recipeName}
|
||||
onChange={(e) => setRecipeName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cuisine"
|
||||
className="recipe-cusine mb-4 p-2 border border-[var(--color-primaryBorder)] rounded w-full"
|
||||
value={recipeCuisine}
|
||||
onChange={(e) => setRecipeCuisine(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Author"
|
||||
className="recipe-cusine mb-4 p-2 border border-[var(--color-primaryBorder)] rounded w-full"
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="prepTime"
|
||||
className="mr-2 font-bold text-[var(--color-secondaryTextDark)]"
|
||||
>
|
||||
Prep Time:
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="prep time in minutes"
|
||||
className="recipe-cusine p-2 border border-[var(--color-primaryBorder)] rounded w-24"
|
||||
value={prepMinutes}
|
||||
onChange={(e) => setPrepMinutes(parseInt(e.target.value))}
|
||||
/>
|
||||
<span className="ml-2 text-[var(--color-secondaryTextDark)]">
|
||||
minutes
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="cookTime"
|
||||
className="mr-2 font-bold text-[var(--color-secondaryTextDark)]"
|
||||
>
|
||||
Cook Time:
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="cook time in minutes"
|
||||
className="recipe-cusine p-2 border border-[var(--color-primaryBorder)] rounded w-24"
|
||||
value={cookMinutes}
|
||||
onChange={(e) => setCookMinutes(parseInt(e.target.value))}
|
||||
/>
|
||||
<span className="ml-2 text-[var(--color-secondaryTextDark)]">
|
||||
minutes
|
||||
</span>
|
||||
</div>
|
||||
<StarRating
|
||||
rating={stars}
|
||||
onRatingChange={(newRating: number) => setStars(newRating)}
|
||||
/>
|
||||
</div>
|
||||
<AddBulkIngredients ingredients={ingredients} onChange={setIngredients} />
|
||||
<AddBulkSteps steps={steps} onChange={setSteps} />
|
||||
<button
|
||||
type="submit"
|
||||
className="ar-button bg-[var(--color-buttonBg)] text-[var(--color-textLight)] py-2 px-4 rounded hover:bg-[var(--color-buttonBgHover)]"
|
||||
>
|
||||
{initialData ? "Save" : "Add"} Recipe
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecipeForm;
|
||||
|
|
@ -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<number | null>(null);
|
||||
const [ingredients, setIngredients] = useState<string[]>([]);
|
||||
const [steps, setSteps] = useState<Step[]>([]);
|
||||
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 (
|
||||
<div className="add-recipe-card page-outer">
|
||||
<form onSubmit={addRecipeForm} className="add-recipe-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
className="recipe-name mb-4 p-2 border border-[var(--color-primaryBorder)] rounded w-full"
|
||||
value={recipeName}
|
||||
maxLength={35}
|
||||
onChange={(e) => setRecipeName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cuisine"
|
||||
className="recipe-cusine mb-4 p-2 border border-[var(--color-primaryBorder)] rounded w-full"
|
||||
value={recipeCuisine}
|
||||
maxLength={15}
|
||||
onChange={(e) => setRecipeCuisine(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Author"
|
||||
className="recipe-cusine mb-4 p-2 border border-[var(--color-primaryBorder)] rounded w-full"
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="prepTime"
|
||||
className="mr-2 font-bold text-[var(--color-secondaryTextDark)]"
|
||||
>
|
||||
Prep Time:
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="prep time in minutes"
|
||||
className="recipe-cusine p-2 border border-[var(--color-primaryBorder)] rounded w-24"
|
||||
value={prepMinutes}
|
||||
onChange={(e) => setPrepMinutes(parseInt(e.target.value))}
|
||||
/>
|
||||
<span className="ml-2 text-[var(--color-secondaryTextDark)]">
|
||||
minutes
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="cookTime"
|
||||
className="mr-2 font-bold text-[var(--color-secondaryTextDark)]"
|
||||
>
|
||||
Cook Time:
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="cook time in minutes"
|
||||
className="recipe-cusine p-2 border border-[var(--color-primaryBorder)] rounded w-24"
|
||||
value={cookMinutes}
|
||||
onChange={(e) => setCookMinutes(parseInt(e.target.value))}
|
||||
/>
|
||||
<span className="ml-2 text-[var(--color-secondaryTextDark)]">
|
||||
minutes
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<StarRating
|
||||
rating={stars}
|
||||
onRatingChange={(newRating: number) => setStars(newRating)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="mb-4 flex items-center cursor-pointer">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showBulkForm}
|
||||
onChange={(e) => setShowBulkForm(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<div>
|
||||
<AddBulkIngredients
|
||||
ingredients={ingredients}
|
||||
onChange={setIngredients}
|
||||
/>
|
||||
</div>
|
||||
{/*<ul className="mb-4">
|
||||
{ingredients.map((ing, index) => (
|
||||
<li key={index} className="text-gray-700 flex items-start mb-2">
|
||||
<span>{ing}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>*/}
|
||||
<div>
|
||||
<AddBulkSteps steps={steps} onChange={setSteps} />
|
||||
</div>
|
||||
{/*<ul className="mb-4">
|
||||
{steps.map((step) => (
|
||||
<li key={step.step_number} className="text-gray-700 flex items-start mb-2">
|
||||
<span>{`${step.step_number}. ${step.instruction}`}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>*/}
|
||||
<button
|
||||
type="submit"
|
||||
className="ar-button bg-[var(--color-buttonBg)] text-[var(--color-textLight)] py-2 px-4 rounded hover:bg-[var(--color-buttonBgHover)]"
|
||||
>
|
||||
submit
|
||||
</button>
|
||||
</form>
|
||||
<RecipeForm onSubmit={addRecipeForm} />
|
||||
{showDemoModal && (
|
||||
<DemoModal
|
||||
isOpen={showDemoModal}
|
||||
|
|
@ -196,4 +32,5 @@ function AddRecipe() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddRecipe;
|
||||
|
|
|
|||
50
frontend/src/pages/EditRecipe.tsx
Normal file
50
frontend/src/pages/EditRecipe.tsx
Normal file
|
|
@ -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<Recipe>();
|
||||
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 (
|
||||
<div className="add-recipe-card page-outer">
|
||||
{recipe && (
|
||||
<RecipeForm onSubmit={updateRecipeForm} initialData={recipe} />
|
||||
)}
|
||||
{showDemoModal && (
|
||||
<DemoModal
|
||||
isOpen={showDemoModal}
|
||||
onClose={() => setShowDemoModal(false)}
|
||||
closeModal={() => setShowDemoModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditRecipe;
|
||||
|
|
@ -111,21 +111,37 @@ function RecipePage() {
|
|||
<div className="border-b-2 border-[var(--color-primaryBorder)] pb-4">
|
||||
<div className="recipe-card">
|
||||
<div className="flex relative justify-between">
|
||||
<button
|
||||
onClick={() => { }}
|
||||
className="invisible ar-button py-1 px-1 rounded self-start"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
<div className="invisible-buttons">
|
||||
<button
|
||||
onClick={() => {}}
|
||||
className="invisible ar-button py-1 px-1 rounded mr-2 self-start"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {}}
|
||||
className="invisible ar-button py-1 px-1 rounded self-start"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
<h3 className="text-center max-w-lg px-4 text-2xl lg:text-3xl font-bold text-[var(--color-textDark)]">
|
||||
{recipe.details.name}
|
||||
</h3>
|
||||
<button
|
||||
onClick={openModal}
|
||||
className="ar-button bg-[var(--color-buttonBg)] text-[var(--color-textLight)] py-1 px-1 rounded hover:bg-[var(--color-buttonBgHover)] self-start"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
<div className="modify-buttons">
|
||||
<button
|
||||
onClick={() => navigate(`/edit-recipe/${recipe.details.id}`)}
|
||||
className="ar-button bg-[var(--color-buttonBg)] text-[var(--color-textLight)] py-1 px-1 rounded hover:bg-[var(--color-buttonBgHover)] mr-2 self-start"
|
||||
>
|
||||
🔧
|
||||
</button>
|
||||
<button
|
||||
onClick={openModal}
|
||||
className="ar-button bg-[var(--color-buttonBg)] text-[var(--color-textLight)] py-1 px-1 rounded hover:bg-[var(--color-buttonBgHover)] self-start"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<p className="text-[var(--color-textDark)] italic text-lg">
|
||||
|
|
@ -146,7 +162,10 @@ function RecipePage() {
|
|||
</h4>
|
||||
<ul className="space-y-2">
|
||||
{recipe.ingredients.map((ingredient: string, index) => (
|
||||
<li key={index} className="text-[var(--color-secondaryTextDark)] flex items-start">
|
||||
<li
|
||||
key={index}
|
||||
className="text-[var(--color-secondaryTextDark)] flex items-start"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 bg-[var(--color-buttonBg)] rounded-full mt-2 mr-3 flex-shrink-0"></span>
|
||||
<span className="font-medium text-left">{ingredient}</span>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue