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 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/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 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: "*";