533 lines
18 KiB
TypeScript
533 lines
18 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import Banner from "../components/Banner";
|
|
import { isRecipeMeal, type RecipeInstruction } from "../types/recipe";
|
|
import InstructionList from "../components/forms/InstructionList";
|
|
import ValidationErrorList from "../components/forms/ValidationErrorList";
|
|
import IngredientSection from "../components/forms/IngredientSection";
|
|
import { Reorder } from "motion/react";
|
|
import IngredientList from "../components/forms/IngredientList";
|
|
import RecipeCreateFormInput from "../components/inputs/RecipeCreateFormInput";
|
|
import RecipeCreateDropdownInput, { type RecipeCreateDropdownOption } from "../components/inputs/RecipeCreateFormDropdown";
|
|
import RecipeCreateFormTextArea from "../components/inputs/RecipeCreateFormTextArea";
|
|
import RecipeCreateFormWrapper from "../components/inputs/RecipeCreateFormWrapper";
|
|
import RecipeCreateFormTagsInputs from "../components/inputs/RecipeCreateFormTagsInput";
|
|
import { useIngredients } from "../hooks/useIngredients";
|
|
import { validateCreateRecipeForm } from "../hooks/validation";
|
|
import { CreateRecipe } from "../services/RecipeService";
|
|
import type { CreateRecipeRequest } from "../types/api/recipe";
|
|
import { isApiError } from "../types/api/error";
|
|
import { useNavigate } from "react-router-dom";
|
|
import ROUTE_CONSTANTS from "../types/routes";
|
|
|
|
// TODO: Move these
|
|
export interface RecipeValidationEntry {
|
|
id: string;
|
|
valid: boolean;
|
|
}
|
|
|
|
export interface CreateRecipeFormEntries {
|
|
title: boolean;
|
|
description: boolean;
|
|
prepTime: boolean;
|
|
cookTime: boolean;
|
|
servingSize: boolean;
|
|
category: boolean;
|
|
difficulty: boolean;
|
|
ingredients: RecipeValidationEntry[];
|
|
instructions: RecipeValidationEntry[];
|
|
// TODO: Image
|
|
}
|
|
|
|
export interface CreateRecipeFormDirtyEntries {
|
|
title: boolean;
|
|
description: boolean;
|
|
prepTime: boolean;
|
|
cookTime: boolean;
|
|
servingSize: boolean;
|
|
category: boolean;
|
|
difficulty: boolean;
|
|
ingredients: Record<string, boolean>;
|
|
instructions: Record<string, boolean>;
|
|
// TODO: Image
|
|
}
|
|
|
|
/**
|
|
* Classes which are applied to all of the input elements.
|
|
*/
|
|
const INPUT_CLASSES = "border border-gray-300 px-4 py-2 rounded-lg focus:outline-none focus:ring-blue-500 focus:ring-2 duration-200 ease-in-out transition-all shadow-sm";
|
|
|
|
/**
|
|
* Options passed to the category dropdown.
|
|
*/
|
|
const CATEGORY_OPTIONS: RecipeCreateDropdownOption[] = [
|
|
{ value: "", name: "Select a category" },
|
|
{ value: "breakfast", name: "Breakfast" },
|
|
{ value: "lunch", name: "Lunch" },
|
|
{ value: "dinner", name: "Dinner" },
|
|
{ value: "dessert", name: "Dessert" },
|
|
{ value: "snack", name: "Snack" },
|
|
{ value: "side", name: "Side" },
|
|
{ value: "other", name: "Other" },
|
|
];
|
|
|
|
/**
|
|
* Options passed to the difficulty dropdown.
|
|
*/
|
|
const DIFFICULTY_OPTIONS: RecipeCreateDropdownOption[] = [
|
|
{ value: "", name: "Select a difficulty" },
|
|
{ value: "1", name: "Beginner" },
|
|
{ value: "2", name: "Easy" },
|
|
{ value: "3", name: "Intermediate" },
|
|
{ value: "4", name: "Challenging" },
|
|
];
|
|
|
|
export default function Create() {
|
|
// Inputs
|
|
const [title, setTitle] = useState<string>("");
|
|
const [description, setDescription] = useState<string>("");
|
|
const [tags, setTags] = useState<string[]>([]);
|
|
const [prepTime, setPrepTime] = useState<string>("");
|
|
const [cookTime, setCookTime] = useState<string>("");
|
|
const [servingSize, setServingSize] = useState<string>("");
|
|
const [category, setCategory] = useState<string>("");
|
|
const [difficulty, setDifficulty] = useState<string>("");
|
|
|
|
const [instructions, setInstructions] = useState<RecipeInstruction[]>([
|
|
{ Id: crypto.randomUUID(), Content: "" }
|
|
]);
|
|
|
|
// Validation State
|
|
const [validation, setValidation] = useState<CreateRecipeFormEntries>({
|
|
title: true,
|
|
description: true,
|
|
prepTime: true,
|
|
cookTime: true,
|
|
servingSize: true,
|
|
category: true,
|
|
difficulty: true,
|
|
ingredients: [],
|
|
instructions: [],
|
|
});
|
|
const [isFormValid, setIsFormValid] = useState<boolean>(false);
|
|
|
|
// Dirty State
|
|
const [dirty, setDirty] = useState<CreateRecipeFormDirtyEntries>({
|
|
title: false,
|
|
description: false,
|
|
prepTime: false,
|
|
cookTime: false,
|
|
servingSize: false,
|
|
category: false,
|
|
difficulty: false,
|
|
ingredients: {},
|
|
instructions: {},
|
|
});
|
|
|
|
const navigate = useNavigate();
|
|
|
|
// Functions
|
|
const createRecipe = async (): Promise<void> => {
|
|
console.log({ title, description, tags, prepTime, cookTime, servingSize, category, difficulty, sections, ingredients, instructions });
|
|
|
|
// Exit if not valid recipe meal
|
|
if (!isRecipeMeal(category)) {
|
|
console.error("[ERROR] Recipe meal is invalid.");
|
|
return;
|
|
}
|
|
|
|
const recipe: CreateRecipeRequest = {
|
|
Title: title,
|
|
Description: description,
|
|
Instructions: instructions,
|
|
Serves: Number(servingSize),
|
|
Difficulty: Number(difficulty),
|
|
Duration: {
|
|
Prep: Number(prepTime),
|
|
Cook: Number(cookTime),
|
|
Total: Number(prepTime) + Number(cookTime)
|
|
},
|
|
Category: category,
|
|
Ingredients: ingredients,
|
|
Sections: sections,
|
|
Tags: tags,
|
|
};
|
|
|
|
|
|
const response = await CreateRecipe(recipe);
|
|
if (isApiError(response)) {
|
|
console.error(response);
|
|
return;
|
|
}
|
|
// TODO: Success toast!
|
|
await navigate(ROUTE_CONSTANTS.Recipe(response.Id));
|
|
};
|
|
|
|
// Import ingredients
|
|
const {
|
|
sections,
|
|
ingredients,
|
|
setSections,
|
|
sectionChange,
|
|
ingredientChange,
|
|
setSectionIngredients,
|
|
addIngredient,
|
|
removeIngredient,
|
|
addSection,
|
|
removeSection,
|
|
} = useIngredients();
|
|
|
|
// Instruction handlers
|
|
const addInstructionHandler = () => {
|
|
setInstructions([...instructions, { Id: crypto.randomUUID(), Content: "" }]);
|
|
}
|
|
|
|
// Dirty handlers
|
|
const markInstructionDirty = (id: string) => {
|
|
setDirty(prev => ({
|
|
...prev,
|
|
instructions: {
|
|
...prev.instructions,
|
|
[id]: true,
|
|
},
|
|
}));
|
|
};
|
|
|
|
const markIngredientDirty = (id: string) => {
|
|
setDirty(prev => ({
|
|
...prev,
|
|
ingredients: {
|
|
...prev.ingredients,
|
|
[id]: true,
|
|
},
|
|
}));
|
|
};
|
|
|
|
const markAllIngredientsDirty = (): Record<string, boolean> => {
|
|
const all: Record<string, boolean> = {};
|
|
for (const ing of ingredients) all[ing.Id] = true;
|
|
return all;
|
|
};
|
|
|
|
const markAllInstructionsDirty = (): Record<string, boolean> => {
|
|
const all: Record<string, boolean> = {};
|
|
for (const instr of instructions) all[instr.Id] = true;
|
|
return all;
|
|
};
|
|
|
|
// HANDLERS
|
|
const submitHandler = () => {
|
|
// If any inputs are not dirty, simply dirty them all and return
|
|
const scalar_dirty = [
|
|
dirty.title,
|
|
dirty.description,
|
|
dirty.prepTime,
|
|
dirty.cookTime,
|
|
dirty.servingSize,
|
|
dirty.category,
|
|
dirty.difficulty,
|
|
];
|
|
const ingredients_dirty = Object.values(dirty.ingredients).every(Boolean);
|
|
const instructions_dirty = Object.values(dirty.instructions).every(Boolean);
|
|
|
|
const all_dirty = scalar_dirty.every(Boolean) && ingredients_dirty && instructions_dirty;
|
|
|
|
if (!all_dirty) {
|
|
setDirty({
|
|
title: true,
|
|
description: true,
|
|
prepTime: true,
|
|
cookTime: true,
|
|
servingSize: true,
|
|
category: true,
|
|
difficulty: true,
|
|
ingredients: markAllIngredientsDirty(),
|
|
instructions: markAllInstructionsDirty(),
|
|
});
|
|
return;
|
|
}
|
|
|
|
void createRecipe();
|
|
}
|
|
|
|
|
|
// EFFECTS
|
|
useEffect(() => {
|
|
// Execute validation every time inputs change
|
|
setValidation(
|
|
validateCreateRecipeForm(
|
|
{ title, description, prepTime, cookTime, servingSize, category, difficulty, ingredients, instructions },
|
|
dirty
|
|
)
|
|
);
|
|
}, [title, description, prepTime, cookTime, servingSize, category, difficulty, instructions, ingredients, dirty]);
|
|
|
|
useEffect(() => {
|
|
// The form is only valid when every item is valid!
|
|
const bools_valid = Object.values(validation).filter(x => typeof x === "boolean").every(x => x);
|
|
const ingredients_valid = validation.ingredients.filter(x => !x.valid).length === 0;
|
|
const instructions_valid = validation.instructions.filter(x => !x.valid).length === 0;
|
|
setIsFormValid(bools_valid && ingredients_valid && instructions_valid);
|
|
}, [validation, dirty]);
|
|
|
|
useEffect(() => {
|
|
console.debug("@validation", validation);
|
|
}, [validation]);
|
|
|
|
useEffect(() => {
|
|
console.debug("@dirty", dirty);
|
|
}, [dirty]);
|
|
|
|
return (
|
|
<>
|
|
<Banner content="Create Your Masterpiece" />
|
|
<div className="mx-4 md:mx-16 my-8">
|
|
<p className="mb-8">
|
|
Welcome to the Recipe Creation Wizard! Simply fill in the details about your culinary creation,
|
|
including the recipe's name, a description, and other specifics like its category, duration,
|
|
and difficulty. Don't forget to dynamically add all your ingredients and instructions using
|
|
the dedicated buttons, and feel free to upload an appealing image. All required fields are
|
|
marked with an <span className="text-red-500">*</span>. Once everything looks perfect, just hit the "Create Recipe"
|
|
button to
|
|
share your masterpiece!
|
|
</p>
|
|
<div>
|
|
{/* Title Input */}
|
|
<RecipeCreateFormInput
|
|
label="Recipe Title"
|
|
name="title"
|
|
desc="Please provide a unique title for your recipe. This is the most important part!"
|
|
placeholder="e.g., Classic Chicken Curry"
|
|
required
|
|
valid={validation.title}
|
|
value={title}
|
|
setValue={setTitle}
|
|
setDirty={setDirty}
|
|
maxLength={128}
|
|
error="Please enter a title. Between 1-128 characters."
|
|
classes={INPUT_CLASSES}
|
|
/>
|
|
|
|
{/* Description Input */}
|
|
<RecipeCreateFormTextArea
|
|
label="Description"
|
|
name="description"
|
|
desc="Please provide a description for your recipe. This can be short and sweet or long and detailed!"
|
|
placeholder="A brief description of your delicious recipe..."
|
|
required
|
|
valid={validation.description}
|
|
value={description}
|
|
setValue={setDescription}
|
|
setDirty={setDirty}
|
|
error="Please enter a description. Between 1-1000 characters."
|
|
rows={4}
|
|
maxLength={1024}
|
|
minLength={1}
|
|
parentClasses="my-4"
|
|
classes={INPUT_CLASSES}
|
|
/>
|
|
|
|
{/* Tag Input */}
|
|
<RecipeCreateFormTagsInputs
|
|
tags={tags}
|
|
setTags={setTags}
|
|
classes={INPUT_CLASSES}
|
|
/>
|
|
|
|
{/* Time Input */}
|
|
<div className="my-4 flex gap-x-2">
|
|
<RecipeCreateFormInput
|
|
label="Prep Time"
|
|
name="prepTime"
|
|
type="number"
|
|
desc="Please provide the estimated prep time (minutes)."
|
|
placeholder="e.g., 20"
|
|
required
|
|
valid={validation.prepTime}
|
|
value={prepTime}
|
|
setValue={setPrepTime}
|
|
setDirty={setDirty}
|
|
error="Please enter a time (minutes)."
|
|
parentClasses="flex-grow w-1/3"
|
|
min="0"
|
|
max="300"
|
|
classes={INPUT_CLASSES}
|
|
/>
|
|
|
|
<RecipeCreateFormInput
|
|
label="Cook Time"
|
|
name="cookTime"
|
|
type="number"
|
|
desc="Please provide the estimated cook time (minutes)."
|
|
placeholder="e.g., 45"
|
|
required
|
|
valid={validation.cookTime}
|
|
value={cookTime}
|
|
setValue={setCookTime}
|
|
setDirty={setDirty}
|
|
error="Please enter a time (minutes)."
|
|
parentClasses="flex-grow w-1/3"
|
|
min="0"
|
|
max="300"
|
|
classes={INPUT_CLASSES}
|
|
/>
|
|
|
|
<RecipeCreateFormInput
|
|
label="Serving Size"
|
|
name="servingSize"
|
|
type="number"
|
|
desc="Please provide the estimated serving size."
|
|
placeholder="e.g., 4"
|
|
required
|
|
valid={validation.servingSize}
|
|
value={servingSize}
|
|
setValue={setServingSize}
|
|
setDirty={setDirty}
|
|
error="Please enter a serving size."
|
|
parentClasses="flex-grow w-1/3"
|
|
min="1"
|
|
max="16"
|
|
classes={INPUT_CLASSES}
|
|
/>
|
|
</div>
|
|
|
|
{/* Dropdown Inputs */}
|
|
<div className="my-4 flex gap-x-2">
|
|
<RecipeCreateDropdownInput
|
|
label="Category"
|
|
name="category"
|
|
desc="Please provide the meal category."
|
|
required
|
|
valid={validation.category}
|
|
value={category}
|
|
setValue={setCategory}
|
|
setDirty={setDirty}
|
|
options={CATEGORY_OPTIONS}
|
|
error="Please select a category."
|
|
parentClasses="flex-grow w-1/3"
|
|
classes={INPUT_CLASSES}
|
|
/>
|
|
|
|
<RecipeCreateDropdownInput
|
|
label="Difficulty"
|
|
name="difficulty"
|
|
desc="Please provide a baseline difficulty."
|
|
required
|
|
valid={validation.difficulty}
|
|
value={difficulty}
|
|
setValue={setDifficulty}
|
|
setDirty={setDirty}
|
|
options={DIFFICULTY_OPTIONS}
|
|
error="Please select a difficulty."
|
|
parentClasses="flex-grow w-1/3"
|
|
classes={INPUT_CLASSES}
|
|
/>
|
|
</div>
|
|
|
|
{/* Ingredient Inputs */}
|
|
<RecipeCreateFormWrapper
|
|
label="Ingredients"
|
|
name="ingredients"
|
|
desc="Please provide a list of ingredients and their quantities. Ingredients can be grouped together by item using the group headers. If only a single group is defined, the name will be ignored and they will be displayed without a heading."
|
|
required
|
|
parentClasses="my-4"
|
|
>
|
|
<Reorder.Group
|
|
axis="y"
|
|
values={sections}
|
|
onReorder={setSections}
|
|
className="animate-none"
|
|
>
|
|
{sections.map((section, i) => (
|
|
<IngredientSection
|
|
key={section.Id}
|
|
section={section}
|
|
onChange={sectionChange}
|
|
removeIngredientSectionHandler={removeSection}
|
|
allowDelete={sections.length > 1}
|
|
>
|
|
<IngredientList
|
|
classes={INPUT_CLASSES}
|
|
section={section}
|
|
ingredients={ingredients}
|
|
setSectionIngredients={setSectionIngredients}
|
|
ingredientChangeHandler={ingredientChange}
|
|
removeIngredientHandler={removeIngredient}
|
|
validList={validation.ingredients}
|
|
dirtyList={dirty.ingredients}
|
|
markDirty={markIngredientDirty}
|
|
/>
|
|
<button
|
|
onClick={() => addIngredient(section.Id)}
|
|
className="w-fit px-3 py-1.5 text-blue-800 font-semibold text-xs md:text-sm rounded-lg hover:bg-gray-100 duration-300 cursor-pointer"
|
|
>
|
|
Add ingredient
|
|
</button>
|
|
<button
|
|
onClick={() => addSection(i)}
|
|
className="w-fit px-3 py-1.5 text-blue-800 font-semibold text-xs md:text-sm rounded-lg hover:bg-gray-100 duration-300 cursor-pointer"
|
|
>
|
|
Add group header
|
|
</button>
|
|
</IngredientSection>
|
|
))}
|
|
</Reorder.Group>
|
|
</RecipeCreateFormWrapper>
|
|
|
|
{/* Instruction Inputs */}
|
|
<RecipeCreateFormWrapper
|
|
label="Instructions"
|
|
name="instructions"
|
|
desc="Please provide a list of instructions. You do not need to include step number, they will be added automatically!"
|
|
required
|
|
parentClasses="my-4"
|
|
>
|
|
<>
|
|
<InstructionList
|
|
instructions={instructions}
|
|
setInstructions={setInstructions}
|
|
validList={validation.instructions}
|
|
dirtyList={dirty.instructions}
|
|
markDirty={markInstructionDirty}
|
|
/>
|
|
<button
|
|
onClick={addInstructionHandler}
|
|
className="w-fit px-3 py-1.5 text-blue-800 font-semibold text-xs md:text-sm rounded-lg hover:bg-gray-100 duration-300 cursor-pointer"
|
|
>
|
|
Add Instruction Step
|
|
</button>
|
|
</>
|
|
</RecipeCreateFormWrapper>
|
|
|
|
{/* TODO: Images Input */}
|
|
<div className="flex flex-col my-4">
|
|
<label htmlFor="image" className="text-sm">
|
|
Recipe Image
|
|
</label>
|
|
<p className="text-xs pt-1 pb-2 text-gray-700">
|
|
Please provide an image of your creation. This is optional but is a nice touch!
|
|
</p>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
name="image"
|
|
id="image"
|
|
className="my-2 block w-full text-sm text-placeholder file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:bg-blue-100 file:text-blue-700 cursor-pointer"
|
|
/>
|
|
</div>
|
|
|
|
{/* Display the reason for the invalidation */}
|
|
<ValidationErrorList validation={validation} />
|
|
|
|
<button
|
|
onClick={submitHandler}
|
|
disabled={!isFormValid}
|
|
className={`${isFormValid ? "bg-gradient-to-r from-blue-200 to-purple-200 cursor-pointer" : "bg-gray-200 text-gray-500 cursor-not-allowed"} w-full py-2 rounded-lg text-lg shadow-md`}
|
|
>
|
|
Create Recipe
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|