From 1acc3792c5b3de557e8eca910dea1c1a35b22838 Mon Sep 17 00:00:00 2001
From: Hayden Hargreaves
Date: Mon, 1 Dec 2025 21:31:53 -0700
Subject: [PATCH] (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.
---
web/src/pages/Create.tsx | 251 ++++++++++++++++++++++++++++++---------
web/src/types/recipe.ts | 23 +++-
2 files changed, 215 insertions(+), 59 deletions(-)
diff --git a/web/src/pages/Create.tsx b/web/src/pages/Create.tsx
index 19e3266..ff63ae1 100644
--- a/web/src/pages/Create.tsx
+++ b/web/src/pages/Create.tsx
@@ -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({
+ title: "",
+ description: "",
+ tagInput: "",
+ tags: [],
+ prepTime: "",
+ cookTime: "",
+ servingSize: "",
+ category: "",
+ difficulty: "",
+ ingredients: [{ name: "", quantity: "" }],
+ instructions: [""],
+ image: null,
+ });
+
+ // VALIDATION STATE
+ const [validation, setValidation] = useState({
+ title: true,
+ description: true,
+ prepTime: true,
+ cookTime: true,
+ servingSize: true,
+ category: true,
+ difficulty: true,
+ ingredients: true,
+ instructions: true,
+ });
+ const [dirty, setDirty] = useState({
+ title: false,
+ description: false,
+ prepTime: false,
+ cookTime: false,
+ servingSize: false,
+ category: false,
+ difficulty: false,
+ ingredients: false,
+ instructions: false,
+ });
+ const [isFormValid, setIsFormValid] = useState(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) => {
if (e.key === 'Enter') {
e.preventDefault();
@@ -9,6 +104,32 @@ export default function Create() {
return true;
}
+ const handleChange = (e: React.ChangeEvent) => {
+ 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 (
<>
@@ -22,7 +143,8 @@ export default function Create() {
button to
share your masterpiece!
-
+
>
);
diff --git a/web/src/types/recipe.ts b/web/src/types/recipe.ts
index 1f7bd53..5b338e7 100644
--- a/web/src/types/recipe.ts
+++ b/web/src/types/recipe.ts
@@ -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;