Potion/web/src/pages/Create.tsx
Hayden Hargreaves cbaf34d39c (FIX): Simple fixes that got pointed out last week.
Back to docker fixes.
2026-01-08 21:30:01 -07:00

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>
</>
);
}