From c2cc8c81836e79d7f10653b64a880c1d6da65eb5 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Mon, 30 Jun 2025 22:30:11 -0700 Subject: [PATCH 1/3] (FEAT): Wired backend to frontend for recipe creation. Recipe creation is complete! Some minor issues include the "eager-validation." It's better than nothing, but a bit harsh. Also, the redirection and linking to a view page would be nice. Furthermore, tags and images are not implemented yet. Will need that in the future! --- internal/app/handlers/page_handler.go | 6 + internal/app/handlers/recipe_handler.go | 53 +++------ internal/app/service/recipe_service.go | 109 ++++++++++++------ internal/domain/recipe/service.go | 3 +- internal/templates/pages/create.templ | 134 +++++++++++++++++------ internal/templates/pages/create_templ.go | 2 +- web/static/css/tailwind.css | 92 ++++++++++++++++ 7 files changed, 295 insertions(+), 104 deletions(-) diff --git a/internal/app/handlers/page_handler.go b/internal/app/handlers/page_handler.go index 71368e0..99a2bca 100644 --- a/internal/app/handlers/page_handler.go +++ b/internal/app/handlers/page_handler.go @@ -31,6 +31,12 @@ func FavoritesPage(ctx *gin.Context) { } func CreatePage(ctx *gin.Context) { + // If not logged in, direct to the login page + if !domain.IsLoggedIn(ctx) { + ctx.Redirect(http.StatusSeeOther, domain.WEB_LOGIN) + return + } + title := "Potion - Create" page := pages.CreatePage() diff --git a/internal/app/handlers/recipe_handler.go b/internal/app/handlers/recipe_handler.go index ee12ece..fa736dc 100644 --- a/internal/app/handlers/recipe_handler.go +++ b/internal/app/handlers/recipe_handler.go @@ -4,46 +4,27 @@ import ( "net/http" "github.com/gin-gonic/gin" - // domain "github.com/haydenhargreaves/Potion/internal/domain/server" + domain "github.com/haydenhargreaves/Potion/internal/domain/server" ) +const CREATE_SUCCESS_HTML = ` +

+ Success! Your new masterpiece was created! +

+` + +const CREATE_ERROR_HTML = ` +

+ Uh oh! Something went wrong when creating your recipe. Please try again. %s +

+` + func CreateRecipe(ctx *gin.Context) { - // deps := ctx.MustGet("deps").(*domain.InjectedDependencies) - - title := ctx.PostForm("title") - description := ctx.PostForm("description") - preparation := ctx.PostForm("preparation-time") - cook := ctx.PostForm("cook-time") - serving := ctx.PostForm("serving-size") - category := ctx.PostForm("category") - difficulty := ctx.PostForm("difficulty") - ingredients := ctx.PostFormArray("ingredients") - quantity := ctx.PostFormArray("quantity") - instructions := ctx.PostFormArray("instructions") - tags := ctx.PostForm("tags") // this is a list of strings split with a comma (,) - - // Have to get the image differently - image, err := ctx.FormFile("image") + deps := ctx.MustGet("deps").(*domain.InjectedDependencies) + _, err := deps.RecipeService.CreateRecipe(ctx) if err != nil { - ctx.JSON(http.StatusOK, gin.H{"error": err.Error()}) + ctx.String(http.StatusOK, CREATE_ERROR_HTML, err.Error()) return } - - ctx.JSON(http.StatusOK, gin.H{ - "title": title, - "description": description, - "cook time": cook, - "preparation time": preparation, - "serving size": serving, - "category": category, - "difficulty": difficulty, - "ingredients": ingredients, - "quantity": quantity, - "instructions": instructions, - "tags": tags, - "image": image.Filename, - }) - - // deps.RecipeService.CreateRecipe(ctx) - // ctx.JSON(http.StatusCreated, gin.H{"recipe": recipe}) + ctx.String(http.StatusOK, CREATE_SUCCESS_HTML) } diff --git a/internal/app/service/recipe_service.go b/internal/app/service/recipe_service.go index dcfa557..b0d302b 100644 --- a/internal/app/service/recipe_service.go +++ b/internal/app/service/recipe_service.go @@ -1,11 +1,16 @@ package service import ( + "errors" + "fmt" "net/http" + "strconv" + "strings" "time" "github.com/gin-gonic/gin" domain "github.com/haydenhargreaves/Potion/internal/domain/recipe" + domainServer "github.com/haydenhargreaves/Potion/internal/domain/server" ) // RecipeService implements the domain.RecipeService defined in the domain module. @@ -22,45 +27,85 @@ func NewRecipeService(recipeRepository domain.RecipeRepository) domain.RecipeSer return &RecipeService{recipeRepository: recipeRepository} } -func (s *RecipeService) CreateRecipe(ctx *gin.Context) domain.Recipe { - // TODO: Implement +func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, error) { + // Ensure user is logged in + if !domainServer.IsLoggedIn(ctx) { + return nil, fmt.Errorf("User is not logged in.") + } + title := ctx.PostForm("title") + description := ctx.PostForm("description") + preparation := ctx.PostForm("preparation-time") + cook := ctx.PostForm("cook-time") + serving := ctx.PostForm("serving-size") + category := ctx.PostForm("category") + difficulty := ctx.PostForm("difficulty") + ingredients := ctx.PostFormArray("ingredients") + quantity := ctx.PostFormArray("quantity") + instructions := ctx.PostFormArray("instructions") + tags := strings.Split(ctx.PostForm("tags"), ",") + userId := ctx.MustGet("userId").(int) + + // Have to get the image differently + image, err := ctx.FormFile("image") + if err != nil && !errors.Is(err, http.ErrMissingFile) { + // Error getting image + } + + // Convert to proper values + servingInt, _ := strconv.Atoi(serving) + difficultyInt, _ := strconv.Atoi(difficulty) + prepInt, _ := strconv.Atoi(preparation) + cookInt, _ := strconv.Atoi(cook) + + var ingredientSlice []domain.RecipeIngredient + for i := range len(ingredients) { + if strings.TrimSpace(ingredients[i]) != "" { + ins := domain.RecipeIngredient{ + Name: ingredients[i], + Quantity: quantity[i], + } + + ingredientSlice = append(ingredientSlice, ins) + } + } + + var instructionSlice []string + for _, ins := range instructions { + if ins != "" { + instructionSlice = append(instructionSlice, ins) + } + } + + // Create the recipe recipe := domain.Recipe{ - Title: "Delicious Go Curry", - Description: "A savory and easy-to-make curry, perfect for weeknights.", - Instructions: []string{ - "Chop all vegetables.", - "Sauté onions and garlic until fragrant.", - "Add curry paste and stir for 1 minute.", - "Add coconut milk and vegetables, simmer until cooked.", - "Serve with rice.", - }, - Serves: 4, - Difficulty: 3, + Title: title, + Description: description, + Instructions: instructionSlice, + Serves: servingInt, + Difficulty: difficultyInt, Duration: domain.RecipeDuration{ - Total: 45, - Prep: 15, - Cook: 30, + Total: prepInt + cookInt, + Prep: prepInt, + Cook: cookInt, }, - Category: domain.MealDinner, // Using our EMeal type. Ensure this matches an updated enum value. - Ingredients: []domain.RecipeIngredient{ - {Name: "Onion", Quantity: "1 large"}, - {Name: "Garlic", Quantity: "3 cloves"}, - {Name: "Curry Paste", Quantity: "2 tbsp"}, - {Name: "Coconut Milk", Quantity: "400ml can"}, - {Name: "Broccoli", Quantity: "1 head"}, - {Name: "Bell Pepper", Quantity: "1 red"}, - {Name: "Rice", Quantity: "As needed"}, - }, - UserId: 3, - Created: time.Now(), + Category: domain.RecipeMeal(category), + Ingredients: ingredientSlice, + UserId: userId, + Created: time.Now(), } if err := s.recipeRepository.CreateRecipe(&recipe); err != nil { - ctx.JSON(http.StatusOK, gin.H{"err": err.Error()}) - return domain.Recipe{} + return &recipe, err } - ctx.JSON(http.StatusCreated, gin.H{"recipe": recipe}) - return recipe + // TODO: Upload the image + if image != nil { + } + + // TODO: Create the tags in the database + if len(tags) > 0 { + } + + return &recipe, nil } diff --git a/internal/domain/recipe/service.go b/internal/domain/recipe/service.go index f11282e..c05649d 100644 --- a/internal/domain/recipe/service.go +++ b/internal/domain/recipe/service.go @@ -3,6 +3,5 @@ package domain import "github.com/gin-gonic/gin" type RecipeService interface { - CreateRecipe(ctx *gin.Context) Recipe + CreateRecipe(ctx *gin.Context) (*Recipe, error) } - diff --git a/internal/templates/pages/create.templ b/internal/templates/pages/create.templ index 73edb4b..25e874c 100644 --- a/internal/templates/pages/create.templ +++ b/internal/templates/pages/create.templ @@ -13,7 +13,6 @@ templ CreatePage() { templ Page() { @components.BannerText("Create Your Masterpiece") -

Welcome to the Recipe Creation Wizard! Simply fill in the details about your culinary creation, @@ -24,7 +23,13 @@ templ Page() { button to share your masterpiece!

-
+
+
+
@@ -84,12 +101,19 @@ templ Page() { +
+
+
@@ -131,7 +167,10 @@ templ Page() { +
@@ -169,22 +217,40 @@ templ Page() {
- - +
+ + +
+
+ + +
- +
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

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 *. Once everything looks perfect, just hit the \"Create Recipe\" button to share your masterpiece!

Please enter a title. Between 1-128 characters.

Please enter a description. Between 1-1000 characters.

    Please enter a time (minutes).

    Please enter a time (minutes).

    Please enter a serving size.

    Please select a category.

    Please select a difficulty.

    Please enter at least one ingredient.

    Please provide a quantity.

    Please enter at least one step.

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/static/css/tailwind.css b/web/static/css/tailwind.css index 8c1bf62..f2a784d 100644 --- a/web/static/css/tailwind.css +++ b/web/static/css/tailwind.css @@ -9,6 +9,10 @@ monospace; --color-red-100: oklch(93.6% 0.032 17.717); --color-red-500: oklch(63.7% 0.237 25.331); + --color-green-100: oklch(96.2% 0.044 156.743); + --color-green-500: oklch(72.3% 0.219 149.579); + --color-green-600: oklch(62.7% 0.194 149.214); + --color-green-700: oklch(52.7% 0.154 150.069); --color-blue-100: oklch(93.2% 0.032 255.585); --color-blue-200: oklch(88.2% 0.059 254.128); --color-blue-300: oklch(80.9% 0.105 251.813); @@ -215,6 +219,9 @@ .static { position: static; } + .top-1 { + top: calc(var(--spacing) * 1); + } .top-1\/2 { top: calc(1/2 * 100%); } @@ -224,6 +231,9 @@ .left-0 { left: calc(var(--spacing) * 0); } + .left-1 { + left: calc(var(--spacing) * 1); + } .left-1\/2 { left: calc(1/2 * 100%); } @@ -242,6 +252,9 @@ .mx-4 { margin-inline: calc(var(--spacing) * 4); } + .my-1 { + margin-block: calc(var(--spacing) * 1); + } .my-2 { margin-block: calc(var(--spacing) * 2); } @@ -338,9 +351,15 @@ .h-screen { height: 100vh; } + .w-1 { + width: calc(var(--spacing) * 1); + } .w-1\/3 { width: calc(1/3 * 100%); } + .w-3 { + width: calc(var(--spacing) * 3); + } .w-3\/4 { width: calc(3/4 * 100%); } @@ -353,6 +372,9 @@ .w-5 { width: calc(var(--spacing) * 5); } + .w-9 { + width: calc(var(--spacing) * 9); + } .w-9\/10 { width: calc(9/10 * 100%); } @@ -374,16 +396,30 @@ .max-w-xl { max-width: var(--container-xl); } + .flex-shrink { + flex-shrink: 1; + } .flex-shrink-0 { flex-shrink: 0; } .flex-grow { flex-grow: 1; } + .border-collapse { + border-collapse: collapse; + } + .-translate-x-1 { + --tw-translate-x: calc(var(--spacing) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } .-translate-x-1\/2 { --tw-translate-x: calc(calc(1/2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); } + .-translate-y-1 { + --tw-translate-y: calc(var(--spacing) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } .-translate-y-1\/2 { --tw-translate-y: calc(calc(1/2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); @@ -391,6 +427,9 @@ .cursor-pointer { cursor: pointer; } + .resize { + resize: both; + } .resize-none { resize: none; } @@ -509,6 +548,12 @@ .bg-gray-200 { background-color: var(--color-gray-200); } + .bg-green-100 { + background-color: var(--color-green-100); + } + .bg-red-100 { + background-color: var(--color-red-100); + } .bg-white { background-color: var(--color-white); } @@ -671,6 +716,15 @@ .text-gray-800 { color: var(--color-gray-800); } + .text-green-500 { + color: var(--color-green-500); + } + .text-green-600 { + color: var(--color-green-600); + } + .text-green-700 { + color: var(--color-green-700); + } .text-red-500 { color: var(--color-red-500); } @@ -680,6 +734,9 @@ .uppercase { text-transform: uppercase; } + .underline { + text-decoration-line: underline; + } .shadow { --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); @@ -758,6 +815,11 @@ .\[-webkit-line-clamp\:4\] { -webkit-line-clamp: 4; } + .peer-invalid\:block { + &:is(:where(.peer):invalid ~ *) { + display: block; + } + } .file\:mr-4 { &::file-selector-button { margin-right: calc(var(--spacing) * 4); @@ -800,6 +862,26 @@ color: var(--color-blue-700); } } + .valid\:my-2 { + &:valid { + margin-block: calc(var(--spacing) * 2); + } + } + .valid\:border-gray-300 { + &:valid { + border-color: var(--color-gray-300); + } + } + .invalid\:mt-2 { + &:invalid { + margin-top: calc(var(--spacing) * 2); + } + } + .invalid\:border-red-500 { + &:invalid { + border-color: var(--color-red-500); + } + } .hover\:cursor-pointer { &:hover { @media (hover: hover) { @@ -1054,6 +1136,16 @@ width: calc(2/7 * 100%); } } + .\[\&\:not\(\:placeholder-shown\)\:invalid\]\:border-red-500 { + &:not(:placeholder-shown):invalid { + border-color: var(--color-red-500); + } + } + .\[\&\:not\(\:placeholder-shown\)\:valid\]\:border-green-500 { + &:not(:placeholder-shown):valid { + border-color: var(--color-green-500); + } + } } @property --tw-translate-x { syntax: "*"; From 519b71f72dd9c373e25b17c9218a7b663be98000 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Tue, 1 Jul 2025 19:49:35 -0700 Subject: [PATCH 2/3] (DOCS): Updated tech specs --- doc/TechnicalSpecification.md | 84 +++++++++++++++++------------------ 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/doc/TechnicalSpecification.md b/doc/TechnicalSpecification.md index bf3949b..5e15783 100644 --- a/doc/TechnicalSpecification.md +++ b/doc/TechnicalSpecification.md @@ -135,50 +135,50 @@ creation process will take place here ##### UI Requirements -- [ ] Create Recipe Heading Banner - - [ ] Large, simple text banner that displays a header - - [ ] Smaller text section that contains some useful information for using the wizard +- [x] Create Recipe Heading Banner + - [x] Large, simple text banner that displays a header + - [x] Smaller text section that contains some useful information for using the wizard -- [ ] Recipe Creation Wizard - - [ ] **Recipe Details:** required - - [ ] **Recipe title:** small text input (1 to 128) - - [ ] **Recipe description:** large text input - - [ ] **Meal category:** pill radio buttons or drop down* - - [ ] **Service size:** numeric input (1 to 16) - - [ ] **Difficulty rating:** star buttons that can be selected (up to 5) - - [ ] **Duration:** prep and cook time, individual numeric inputs - - [ ] **Ingredients:** required - - [ ] **Dynamic ingredient list:** List of each ingredient - - [ ] **Content:** name and quantity (both text inputs) +- [x] Recipe Creation Wizard + - [x] **Recipe Details:** required + - [x] **Recipe title:** small text input (1 to 128) + - [x] **Recipe description:** large text input + - [x] **Meal category:** pill radio buttons or drop down* + - [x] **Service size:** numeric input (1 to 16) + - [x] **Difficulty rating:** star buttons that can be selected (up to 5) + - [x] **Duration:** prep and cook time, individual numeric inputs + - [x] **Ingredients:** required + - [x] **Dynamic ingredient list:** List of each ingredient + - [x] **Content:** name and quantity (both text inputs) - [ ] **Actions:** delete button and **reorder element*** - - [ ] **Add ingredient button:** Simple button to add a blank ingredient row - - [ ] **Instructions:** required - - [ ] **Dynamic instructions list:** Numbered list of each instruction - - [ ] **Content:** number and large text instruction + - [x] **Add ingredient button:** Simple button to add a blank ingredient row + - [x] **Instructions:** required + - [x] **Dynamic instructions list:** Numbered list of each instruction + - [x] **Content:** number and large text instruction - [ ] **Rich text editor?*** - [ ] **Actions:** delete button and **reorder element*** - - [ ] **Add step button:** Simple button to add a blank instruction element - - [ ] **Media & Tags:** optional - - [ ] **Image Upload** - - [ ] Single image selector for the thumbnail image - - [ ] Small image display once one has been upload - - [ ] Remove button to remove the image - - [ ] Replace button to replace the image - - [ ] **Tags** - - [ ] Text input to add tags + - [x] **Add step button:** Simple button to add a blank instruction element + - [x] **Media & Tags:** optional + - [x] **Image Upload** + - [x] Single image selector for the thumbnail image + - [x] Small image display once one has been upload + - [x] Remove button to remove the image + - [x] Replace button to replace the image + - [x] **Tags** + - [x] Text input to add tags - [ ] Using list of existing tags, use a prefill while typing - - [ ] Tags that don't exist will be added - - [ ] Display a small list of added tags - - [ ] Clicking a tag will remove it from the list - - [ ] **Footer & Submit** - - [ ] Save recipe button to complete the form + - [x] Tags that don't exist will be added + - [x] Display a small list of added tags + - [x] Clicking a tag will remove it from the list + - [x] **Footer & Submit** + - [x] Save recipe button to complete the form - [ ] Button is disabled until the minimum required fields are complete* -- [ ] Input Validation - - [ ] Required elements should have a **required indicator** - - [ ] Required elements will be validated input +- [x] Input Validation + - [x] Required elements should have a **required indicator** + - [x] Required elements will be validated input - [ ] **Valid:** Green outline or indicator* maybe a small, green check mark - - [ ] **Invalid:** Red outline or indicator* maybe a red border with error text + - [x] **Invalid:** Red outline or indicator* maybe a red border with error text '*': Not sure yet, still under consideration @@ -187,13 +187,13 @@ creation process will take place here ##### API Requirements -- [ ] Middleware - - [ ] **Authentication:** User must be logged in to access this page - - [ ] **Authentication:** User must be logged in to submit the creation of a recipe (fallback) +- [x] Middleware + - [x] **Authentication:** User must be logged in to access this page + - [x] **Authentication:** User must be logged in to submit the creation of a recipe (fallback) -- [ ] Recipe Creation Wizard - - [ ] Create a new recipe object in the database - - [ ] Recipe should be attached to a user (logged in) +- [x] Recipe Creation Wizard + - [x] Create a new recipe object in the database + - [x] Recipe should be attached to a user (logged in) - [ ] User should be directed to the view recipe page on a successful creation From ad9df203163e6f2d342b88ac6e7930419a655949 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Tue, 1 Jul 2025 20:02:45 -0700 Subject: [PATCH 3/3] (FIX): Using components in the create form. Going to move on from this for now. We need a page to view a recipe! **C**reate is done, now we need **R**ead, **U**pdate and **D**elete. --- internal/templates/pages/recipe.templ | 7 +++++ internal/templates/pages/recipe_templ.go | 40 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 internal/templates/pages/recipe.templ create mode 100644 internal/templates/pages/recipe_templ.go diff --git a/internal/templates/pages/recipe.templ b/internal/templates/pages/recipe.templ new file mode 100644 index 0000000..1a905a9 --- /dev/null +++ b/internal/templates/pages/recipe.templ @@ -0,0 +1,7 @@ +package templates + +templ RecipePage() { +

    + Viewing page +

    +} diff --git a/internal/templates/pages/recipe_templ.go b/internal/templates/pages/recipe_templ.go new file mode 100644 index 0000000..2cd953c --- /dev/null +++ b/internal/templates/pages/recipe_templ.go @@ -0,0 +1,40 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.865 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func RecipePage() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

    Viewing page

    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate