(FEAT): Finally working on the create page.
This is a big build, but the last one! Lots of validation is done and most of the inputs are completed. What remains are the complex elements: tags, ingredients and instructions. Once those are done, we can start working on the backend and making sure everything is wired together properly.
This commit is contained in:
parent
031df19b44
commit
1acc3792c5
@ -1,6 +1,101 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Banner from "../components/Banner";
|
||||
import { isRecipeMeal } from "../types/recipe";
|
||||
|
||||
interface CreateRecipeForm {
|
||||
title: string;
|
||||
description: string;
|
||||
tagInput: string; // current tag being typed
|
||||
tags: string[]; // all tags
|
||||
prepTime: number | "";
|
||||
cookTime: number | "";
|
||||
servingSize: number | "";
|
||||
category: string;
|
||||
difficulty: string; // We use this as a number...
|
||||
ingredients: { name: string; quantity: string }[];
|
||||
instructions: string[];
|
||||
image: File | null;
|
||||
};
|
||||
|
||||
interface CreateRecipeFormToggles {
|
||||
title: boolean;
|
||||
description: boolean;
|
||||
prepTime: boolean;
|
||||
cookTime: boolean;
|
||||
servingSize: boolean;
|
||||
category: boolean;
|
||||
difficulty: boolean;
|
||||
ingredients: boolean;
|
||||
instructions: 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";
|
||||
|
||||
export default function Create() {
|
||||
// FORM STATE
|
||||
const [inputs, setInputs] = useState<CreateRecipeForm>({
|
||||
title: "",
|
||||
description: "",
|
||||
tagInput: "",
|
||||
tags: [],
|
||||
prepTime: "",
|
||||
cookTime: "",
|
||||
servingSize: "",
|
||||
category: "",
|
||||
difficulty: "",
|
||||
ingredients: [{ name: "", quantity: "" }],
|
||||
instructions: [""],
|
||||
image: null,
|
||||
});
|
||||
|
||||
// VALIDATION STATE
|
||||
const [validation, setValidation] = useState<CreateRecipeFormToggles>({
|
||||
title: true,
|
||||
description: true,
|
||||
prepTime: true,
|
||||
cookTime: true,
|
||||
servingSize: true,
|
||||
category: true,
|
||||
difficulty: true,
|
||||
ingredients: true,
|
||||
instructions: true,
|
||||
});
|
||||
const [dirty, setDirty] = useState<CreateRecipeFormToggles>({
|
||||
title: false,
|
||||
description: false,
|
||||
prepTime: false,
|
||||
cookTime: false,
|
||||
servingSize: false,
|
||||
category: false,
|
||||
difficulty: false,
|
||||
ingredients: false,
|
||||
instructions: false,
|
||||
});
|
||||
const [isFormValid, setIsFormValid] = useState<boolean>(false);
|
||||
|
||||
// Validate the dirty inputs, one at a time
|
||||
const validate = () => {
|
||||
const state = { ...validation };
|
||||
state.title = dirty.title ? (inputs.title.length >= 1 && inputs.title.length <= 128) : true;
|
||||
state.description = dirty.description ? (inputs.description.length >= 1 && inputs.description.length <= 1000) : true;
|
||||
|
||||
state.prepTime = dirty.prepTime ? (inputs.prepTime !== "" && Number(inputs.prepTime) >= 0 && Number(inputs.prepTime) <= 120) : true;
|
||||
state.cookTime = dirty.cookTime ? (inputs.cookTime !== "" && Number(inputs.cookTime) >= 0 && Number(inputs.cookTime) <= 120) : true;
|
||||
state.servingSize = dirty.servingSize ? (inputs.servingSize !== "" && Number(inputs.servingSize) >= 1 && Number(inputs.servingSize) <= 16) : true;
|
||||
|
||||
state.category = dirty.category ? (inputs.category !== "" && isRecipeMeal(inputs.category)) : true;
|
||||
state.difficulty = dirty.difficulty ? (inputs.difficulty !== "" && Number(inputs.difficulty) >= 1 && Number(inputs.difficulty) <= 5) : true;
|
||||
|
||||
setValidation(state);
|
||||
}
|
||||
|
||||
|
||||
// HANDLERS
|
||||
// TODO: Only needed if we use the form element
|
||||
const keyDownHandler = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
@ -9,6 +104,32 @@ export default function Create() {
|
||||
return true;
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setInputs(prev => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
setDirty(prev => ({
|
||||
...prev,
|
||||
[name]: true,
|
||||
}));
|
||||
};
|
||||
|
||||
// EFFECTS
|
||||
useEffect(() => {
|
||||
// Execute validation every time inputs change
|
||||
validate();
|
||||
console.log("@inputs", inputs);
|
||||
}, [inputs]);
|
||||
|
||||
useEffect(() => {
|
||||
// The form is only valid when every item has been touched, and every item is valid!
|
||||
const allValid = Object.values(validation).every(x => x === true);
|
||||
const allDirty = Object.values(dirty).every(x => x === true);
|
||||
setIsFormValid(allValid && allDirty);
|
||||
}, [validation, dirty]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Banner content="Create Your Masterpiece" />
|
||||
@ -22,7 +143,8 @@ export default function Create() {
|
||||
button to
|
||||
share your masterpiece!
|
||||
</p>
|
||||
<form>
|
||||
<div>
|
||||
{/* Title Input */}
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="title" className="text-sm">
|
||||
Recipe Title
|
||||
@ -33,18 +155,22 @@ export default function Create() {
|
||||
</p>
|
||||
<input
|
||||
onKeyDown={keyDownHandler}
|
||||
className="peer 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 invalid:border-red-500"
|
||||
className={`${!validation.title ? "border-red-500" : ""} ${INPUT_CLASSES}`}
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
value={inputs.title}
|
||||
onChange={handleChange}
|
||||
required
|
||||
maxLength={128}
|
||||
minLength={1}
|
||||
placeholder="e.g., Classic Chicken Curry"
|
||||
/>
|
||||
<p className="hidden peer-invalid:block text-xs text-red-500 my-1">Please enter a title. Between 1-128 characters.</p>
|
||||
{!validation.title && (
|
||||
<p className="text-xs text-red-500 my-1">Please enter a title. Between 1-128 characters.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description Input */}
|
||||
<div className="flex flex-col my-4">
|
||||
<label htmlFor="description" className="text-sm">
|
||||
Description
|
||||
@ -54,20 +180,24 @@ export default function Create() {
|
||||
Please provide a description for your recipe. This can be short and sweet or long and detailed!
|
||||
</p>
|
||||
<textarea
|
||||
className="peer 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 resize-none shadow-sm invalid:border-red-500"
|
||||
id="description"
|
||||
className={`${!validation.description ? "border-red-500" : ""} ${INPUT_CLASSES} min-h-32`}
|
||||
name="description"
|
||||
value={inputs.description}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
required
|
||||
maxLength={1024}
|
||||
minLength={1}
|
||||
placeholder="A brief description of your delicious recipe..."
|
||||
></textarea>
|
||||
<p className="hidden peer-invalid:block text-xs text-red-500 my-1">
|
||||
Please enter a description. Between 1-1000 characters.
|
||||
</p>
|
||||
{!validation.description && (
|
||||
<p className="text-xs text-red-500 my-1">
|
||||
Please enter a description. Between 1-1000 characters.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* TODO: Tags Input */}
|
||||
<div className="my-4 flex flex-col gap-x-2">
|
||||
<div className="flex flex-col flex-grow">
|
||||
<label htmlFor="tags" className="text-sm">
|
||||
@ -90,9 +220,11 @@ export default function Create() {
|
||||
</div>
|
||||
<ul id="tag-list" className="my-2 flex gap-1 flex-wrap"></ul>
|
||||
</div>
|
||||
|
||||
{/* Time Input */}
|
||||
<div className="my-4 flex gap-x-2">
|
||||
<div className="flex flex-col flex-grow w-1/3">
|
||||
<label htmlFor="preparation-time" className="text-sm">
|
||||
<label htmlFor="prepTime" className="text-sm">
|
||||
Prep Time
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
@ -101,22 +233,22 @@ export default function Create() {
|
||||
</p>
|
||||
<input
|
||||
onKeyDown={keyDownHandler}
|
||||
className="peer 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 invalid:border-red-500"
|
||||
className={`${!validation.prepTime ? "border-red-500" : ""} ${INPUT_CLASSES}`}
|
||||
type="number"
|
||||
id="preparation-time"
|
||||
name="preparation-time"
|
||||
name="prepTime"
|
||||
value={inputs.prepTime}
|
||||
onChange={handleChange}
|
||||
required
|
||||
min="0"
|
||||
max="120"
|
||||
placeholder="e.g., 20"
|
||||
/>
|
||||
<p className="hidden peer-invalid:block text-xs text-red-500 my-1">
|
||||
Please enter a time (minutes).
|
||||
</p>
|
||||
{!validation.prepTime && (
|
||||
<p className="text-xs text-red-500 my-1"> Please enter a time (minutes). </p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col flex-grow w-1/3">
|
||||
<label htmlFor="cook-time" className="text-sm">
|
||||
<label htmlFor="cookTime" className="text-sm">
|
||||
Cook Time
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
@ -125,22 +257,22 @@ export default function Create() {
|
||||
</p>
|
||||
<input
|
||||
onKeyDown={keyDownHandler}
|
||||
className="peer 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 invalid:border-red-500"
|
||||
className={`${!validation.cookTime ? "border-red-500" : ""} ${INPUT_CLASSES}`}
|
||||
type="number"
|
||||
id="cook-time"
|
||||
name="cook-time"
|
||||
name="cookTime"
|
||||
value={inputs.cookTime}
|
||||
onChange={handleChange}
|
||||
required
|
||||
min="0"
|
||||
max="120"
|
||||
placeholder="e.g., 45"
|
||||
/>
|
||||
<p className="hidden peer-invalid:block text-xs text-red-500 my-1">
|
||||
Please enter a time (minutes).
|
||||
</p>
|
||||
{!validation.cookTime && (
|
||||
<p className="text-xs text-red-500 my-1"> Please enter a time (minutes). </p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col flex-grow w-1/3">
|
||||
<label htmlFor="serving-size" className="text-sm">
|
||||
<label htmlFor="servingSize" className="text-sm">
|
||||
Serving Size
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
@ -149,21 +281,23 @@ export default function Create() {
|
||||
</p>
|
||||
<input
|
||||
onKeyDown={keyDownHandler}
|
||||
className="peer 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 invalid:border-red-500"
|
||||
className={`${!validation.servingSize ? "border-red-500" : ""} ${INPUT_CLASSES}`}
|
||||
type="number"
|
||||
name="servingSize"
|
||||
value={inputs.servingSize}
|
||||
onChange={handleChange}
|
||||
max="16"
|
||||
min="1"
|
||||
required
|
||||
id="serving-size"
|
||||
name="serving-size"
|
||||
placeholder="e.g., 4"
|
||||
/>
|
||||
<p className="hidden peer-invalid:block text-xs text-red-500 my-1">
|
||||
Please enter a serving size.
|
||||
</p>
|
||||
{!validation.servingSize && (
|
||||
<p className="text-xs text-red-500 my-1"> Please enter a serving size. </p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dropdown Inputs */}
|
||||
<div className="my-4 flex gap-x-2">
|
||||
<div className="flex flex-col flex-grow w-1/3">
|
||||
<label htmlFor="category" className="text-sm">
|
||||
@ -174,12 +308,11 @@ export default function Create() {
|
||||
Please provide the meal category.
|
||||
</p>
|
||||
<select
|
||||
id="category"
|
||||
className={`${!validation.category ? "border-red-500" : ""} ${INPUT_CLASSES}`}
|
||||
name="category"
|
||||
value={inputs.category}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="peer border border-gray-300 bg-gray-200 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
|
||||
invalid:border-red-500"
|
||||
>
|
||||
<option value="">Select a category</option>
|
||||
<option value="breakfast">Breakfast</option>
|
||||
@ -190,9 +323,9 @@ export default function Create() {
|
||||
<option value="side">Side</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
<p className="hidden peer-invalid:block text-xs text-red-500 my-1">
|
||||
Please select a category.
|
||||
</p>
|
||||
{!validation.category && (
|
||||
<p className="text-xs text-red-500 my-1"> Please select a category. </p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col flex-grow w-1/3">
|
||||
<label htmlFor="difficulty" className="text-sm">
|
||||
@ -203,12 +336,11 @@ export default function Create() {
|
||||
Please provide a baseline difficulty.
|
||||
</p>
|
||||
<select
|
||||
id="difficulty"
|
||||
className={`${!validation.category ? "border-red-500" : ""} ${INPUT_CLASSES}`}
|
||||
name="difficulty"
|
||||
value={inputs.difficulty}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="peer border border-gray-300 bg-gray-200 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
|
||||
invalid:border-red-500"
|
||||
>
|
||||
<option value="">Select a difficulty</option>
|
||||
<option value="1">Beginner</option>
|
||||
@ -217,11 +349,13 @@ export default function Create() {
|
||||
<option value="4">Challenging</option>
|
||||
<option value="5">Extreme</option>
|
||||
</select>
|
||||
<p className="hidden peer-invalid:block text-xs text-red-500 my-1">
|
||||
Please select a difficulty.
|
||||
</p>
|
||||
{!validation.difficulty && (
|
||||
<p className="text-xs text-red-500 my-1"> Please select a difficulty. </p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TODO: Ingredient Inputs */}
|
||||
<div className="flex flex-col my-4">
|
||||
<label htmlFor="ingredients" className="text-sm">
|
||||
Ingredients
|
||||
@ -243,7 +377,7 @@ export default function Create() {
|
||||
minLength={1}
|
||||
placeholder="Ingredient name (e.g., Chicken Breast)"
|
||||
/>
|
||||
<p className="hidden peer-invalid:block text-xs text-red-500 my-1">
|
||||
<p className="text-xs text-red-500 my-1">
|
||||
Please enter at least one ingredient.
|
||||
</p>
|
||||
</div>
|
||||
@ -260,7 +394,7 @@ export default function Create() {
|
||||
minLength={1}
|
||||
placeholder="Quantity (e.g., 1lb)"
|
||||
/>
|
||||
<p className="hidden peer-invalid:block text-xs text-red-500 my-1">
|
||||
<p className="text-xs text-red-500 my-1">
|
||||
Please provide a quantity.
|
||||
</p>
|
||||
</div>
|
||||
@ -273,6 +407,8 @@ export default function Create() {
|
||||
Add Ingredient
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* TODO: Instructions Inputs */}
|
||||
<div className="flex flex-col my-4">
|
||||
<label htmlFor="instructions" className="text-sm">
|
||||
Instructions
|
||||
@ -293,7 +429,7 @@ export default function Create() {
|
||||
minLength={1}
|
||||
placeholder="Step 1: Describe this step..."
|
||||
></textarea>
|
||||
<p className="hidden peer-invalid:block text-xs text-red-500 my-1">
|
||||
<p className="text-xs text-red-500 my-1">
|
||||
Please enter at least one step.
|
||||
</p>
|
||||
</div>
|
||||
@ -304,6 +440,8 @@ export default function Create() {
|
||||
Add Instruction Step
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* TODO: Images Input */}
|
||||
<div className="flex flex-col my-4">
|
||||
<label htmlFor="image" className="text-sm">
|
||||
Recipe Image
|
||||
@ -319,14 +457,11 @@ export default function Create() {
|
||||
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>
|
||||
<p id="response" className="hidden"></p>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full mt-8 bg-gradient-to-r from-blue-200 to-purple-200 py-2 rounded-lg text-lg cursor-pointer shadow-md"
|
||||
>
|
||||
|
||||
<button 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 mt-8 py-2 rounded-lg text-lg shadow-md`}>
|
||||
Create Recipe
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -5,7 +5,28 @@ export interface RecipeDuration {
|
||||
Cook: number;
|
||||
}
|
||||
|
||||
export type RecipeMeal = "breakfast" | "lunch" | "dinner" | "dessert" | "snack" | "side" | "other";
|
||||
export type RecipeMeal =
|
||||
"breakfast"
|
||||
| "lunch"
|
||||
| "dinner"
|
||||
| "dessert"
|
||||
| "snack"
|
||||
| "side"
|
||||
| "other";
|
||||
|
||||
const RECIPE_MEALS = [
|
||||
"breakfast",
|
||||
"lunch",
|
||||
"dinner",
|
||||
"dessert",
|
||||
"snack",
|
||||
"side",
|
||||
"other"
|
||||
];
|
||||
|
||||
export function isRecipeMeal(value: string): value is RecipeMeal {
|
||||
return RECIPE_MEALS.includes(value as RecipeMeal);
|
||||
}
|
||||
|
||||
export interface RecipeIngredient {
|
||||
Name: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user