From 7ad710f880d476f34af32aea0eed99a58c9adf57 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Sun, 6 Jul 2025 22:40:15 -0700 Subject: [PATCH] (FEAT): Search is returning recipes, next just need a UI and wire job. Furthermore, not sure how we are going to handle the searching. Maybe a full-text search index? For now, it has been ignored, but the filters seem to be working properly. --- doc/TechnicalSpecification.md | 2 +- internal/app/handlers/recipe_handler.go | 10 + internal/app/server/server.go | 2 +- internal/app/service/recipe_service.go | 44 +++++ internal/domain/recipe/recipe.go | 35 +++- internal/domain/recipe/repository.go | 1 + internal/domain/recipe/service.go | 1 + internal/domain/server/routes.go | 2 + .../database/repository/recipe_repository.go | 174 ++++++++++++++++++ internal/templates/components/dropdowns.templ | 61 +++--- .../templates/components/dropdowns_templ.go | 94 ++++++---- internal/templates/pages/create.templ | 3 +- internal/templates/pages/create_templ.go | 16 +- internal/templates/pages/home.templ | 18 +- internal/templates/pages/home_templ.go | 71 ++++--- web/static/css/tailwind.css | 86 +++++++++ 16 files changed, 520 insertions(+), 100 deletions(-) diff --git a/doc/TechnicalSpecification.md b/doc/TechnicalSpecification.md index 5e15783..c07f639 100644 --- a/doc/TechnicalSpecification.md +++ b/doc/TechnicalSpecification.md @@ -194,7 +194,7 @@ creation process will take place here - [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 + - [x] User should be directed to the view recipe page on a successful creation diff --git a/internal/app/handlers/recipe_handler.go b/internal/app/handlers/recipe_handler.go index 6756423..7854a98 100644 --- a/internal/app/handlers/recipe_handler.go +++ b/internal/app/handlers/recipe_handler.go @@ -34,3 +34,13 @@ func CreateRecipe(ctx *gin.Context) { ctx.Header("HX-Redirect", url) ctx.Status(http.StatusCreated) } + +func SearchRecipes(ctx *gin.Context) { + deps := ctx.MustGet("deps").(*domain.InjectedDependencies) + + recipes, err := deps.RecipeService.SearchRecipes(ctx) + if err != nil { + ctx.JSON(http.StatusOK, gin.H{"error": err.Error()}) + } + ctx.JSON(http.StatusOK, gin.H{"recipes": recipes}) +} diff --git a/internal/app/server/server.go b/internal/app/server/server.go index a234450..b972deb 100644 --- a/internal/app/server/server.go +++ b/internal/app/server/server.go @@ -176,8 +176,8 @@ func (s *Server) Setup() *Server { router_api.GET("/auth/logout", handlers.Logout) // Recipe endpoints - // TODO: This should be post. Temp! router_api.POST("/recipe", handlers.CreateRecipe) + router_api.POST("/recipe/search", handlers.SearchRecipes) return s } diff --git a/internal/app/service/recipe_service.go b/internal/app/service/recipe_service.go index e1f9fe6..4057218 100644 --- a/internal/app/service/recipe_service.go +++ b/internal/app/service/recipe_service.go @@ -129,3 +129,47 @@ func (s *RecipeService) GetRecipe(id int) (*domain.Recipe, error) { return recipe, err } + +// toBits converts an array of stringified numbers into a single summed value +func toBits(arr []string) (bits int) { + for _, x := range arr { + num, _ := strconv.Atoi(x) + bits += num + } + return +} + +// isBitActive returns true when the bit at pos (0 indexed) is true. +func isBitActive(bits, pos int) bool { + return (bits>>pos)&1 == 1 +} + +func (s *RecipeService) SearchRecipes(ctx *gin.Context) ([]domain.Recipe, error) { + // NOTE: How are the filters handled? + // Each input is given a bit value (e.g., 00001 for 1) and will be passed + // back to this handler as an array. The values are then added together + // and will result in a integer which represents bit values. These bits + // can then be passed to the repository and are then parsed to determine + // which filters should be applied. + // Parsing these is simple, for each filter option, use the bitwise and (&) + // operator with the value we expect for the filter. When 1, we can ensure + // the filter is provided. + // A function above (isBitActive) provides an example of testing of testing + // the filter parsing. + + search := ctx.PostForm("search") // string, search query for titles + meal := toBits(ctx.PostFormArray("meal")) + time := toBits(ctx.PostFormArray("time")) + difficulty := toBits(ctx.PostFormArray("difficulty")) + serving := toBits(ctx.PostFormArray("serving")) + + filters := domain.SearchFilters{ + Search: search, + MealType: meal, + Time: time, + Difficulty: difficulty, + ServingSize: serving, + } + + return s.recipeRepository.SearchRecipes(filters) +} diff --git a/internal/domain/recipe/recipe.go b/internal/domain/recipe/recipe.go index f05b36e..1eb61fc 100644 --- a/internal/domain/recipe/recipe.go +++ b/internal/domain/recipe/recipe.go @@ -18,12 +18,34 @@ const ( MealBreakfast RecipeMeal = "breakfast" MealLunch RecipeMeal = "lunch" MealDinner RecipeMeal = "dinner" - MealDessert RecipeMeal = "dessert" + MealDesert RecipeMeal = "dessert" MealSnack RecipeMeal = "snack" MealSide RecipeMeal = "side" MealOther RecipeMeal = "other" ) +// ParseMeal converts an integer value into a meal type (string). Values are 0-indexed. +func ParseMeal(meal int) RecipeMeal { + switch meal { + case 0: + return MealBreakfast + case 1: + return MealLunch + case 2: + return MealDinner + case 3: + return MealDesert + case 4: + return MealSnack + case 5: + return MealSide + case 6: + return MealOther + default: + return MealOther + } +} + // RecipeIngredient is a single ingredients in a recipe. These have JSON tags which allow them // to be marshaled into a JSON array and stored in the database (JSONB). type RecipeIngredient struct { @@ -47,3 +69,14 @@ type Recipe struct { Modified *time.Time // Pointer to allow null Created time.Time } + +// SearchFilters is a model which represents the required filters to complete a recipe search. +// The integer values should be provided as bits and used to parse out individual flags. More +// details can be found in the SearchRecipes service function. +type SearchFilters struct { + Search string + MealType int + Time int + Difficulty int + ServingSize int +} diff --git a/internal/domain/recipe/repository.go b/internal/domain/recipe/repository.go index d8eda0e..f89c026 100644 --- a/internal/domain/recipe/repository.go +++ b/internal/domain/recipe/repository.go @@ -3,4 +3,5 @@ package domain type RecipeRepository interface { CreateRecipe(recipe *Recipe) error GetRecipe(id int) (*Recipe, error) + SearchRecipes(filters SearchFilters) ([]Recipe, error) } diff --git a/internal/domain/recipe/service.go b/internal/domain/recipe/service.go index dc2409b..558f63f 100644 --- a/internal/domain/recipe/service.go +++ b/internal/domain/recipe/service.go @@ -5,4 +5,5 @@ import "github.com/gin-gonic/gin" type RecipeService interface { CreateRecipe(ctx *gin.Context) (*Recipe, error) GetRecipe(id int) (*Recipe, error) + SearchRecipes(ctx *gin.Context) ([]Recipe, error) } diff --git a/internal/domain/server/routes.go b/internal/domain/server/routes.go index 47ac188..41d9aa7 100644 --- a/internal/domain/server/routes.go +++ b/internal/domain/server/routes.go @@ -19,3 +19,5 @@ const WEB_RECIPE = VERSION + WEB + "/recipe/%d" const API_AUTH_LOGIN = VERSION + API + "/auth/login" const API_AUTH_CALLBACK = VERSION + API + "/auth/callback" const API_AUTH_LOGOUT = VERSION + API + "/auth/logout" +const API_CREATE_RECIPE = VERSION + API + "/recipe" +const API_SEARCH_RECIPES = VERSION + API + "/recipe/search" diff --git a/internal/infrastructure/database/repository/recipe_repository.go b/internal/infrastructure/database/repository/recipe_repository.go index cfbf04d..6727448 100644 --- a/internal/infrastructure/database/repository/recipe_repository.go +++ b/internal/infrastructure/database/repository/recipe_repository.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "fmt" + "strings" domain "github.com/haydenhargreaves/Potion/internal/domain/recipe" "github.com/lib/pq" @@ -134,6 +135,8 @@ func (r *RecipeRepository) GetRecipe(id int) (*domain.Recipe, error) { } recipe.Duration = duration + } else { + recipe.Duration = domain.RecipeDuration{} } // Parse ingredient @@ -144,7 +147,178 @@ func (r *RecipeRepository) GetRecipe(id int) (*domain.Recipe, error) { } recipe.Ingredients = ingredients + } else { + recipe.Ingredients = []domain.RecipeIngredient{} } return &recipe, nil } + +// isBitActive returns true when the bit at pos (0 indexed) is true. +func isBitActive(bits, pos int) bool { + return (bits>>pos)&1 == 1 +} + +func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters) ([]domain.Recipe, error) { + tx, err := r.db.Begin() + if err != nil { + tx.Rollback() + return nil, err + } + + // Generate the query + query := "SELECT * FROM recipes" + + // Compute meals type filters (there are 7 bits) + var mealConditions []string + for i := range 7 { + if isBitActive(filters.MealType, i) { + mealConditions = append(mealConditions, fmt.Sprintf("category = '%s'", domain.ParseMeal(i))) + } + } + + // Compute time filters (there are 5 bits) + var timeConditions []string + for i := range 5 { + var cond string + if isBitActive(filters.Time, i) { + switch i { + case 0: + cond = "(duration->>'total')::int < 15" + case 1: + cond = "(duration->>'total')::int BETWEEN 15 AND 30" + case 2: + cond = "(duration->>'total')::int BETWEEN 30 AND 60" + case 3: + cond = "(duration->>'total')::int BETWEEN 60 AND 120" + case 4: + cond = "(duration->>'total')::int > 120" + } + timeConditions = append(timeConditions, cond) + } + } + + // Compute difficulty filters (there are 5 bits) + var difficultyConditions []string + for i := range 5 { + if isBitActive(filters.Difficulty, i) { + cond := fmt.Sprintf("difficulty = '%d'", i+1) + difficultyConditions = append(difficultyConditions, cond) + } + } + + // Compute serving size filters (there are 5 bits) + var servingConditions []string + for i := range 5 { + var cond string + if isBitActive(filters.ServingSize, i) { + switch i { + case 0: + cond = "serves BETWEEN 1 AND 2" + case 1: + cond = "serves BETWEEN 2 AND 4" + case 2: + cond = "serves BETWEEN 4 AND 6" + case 3: + cond = "serves BETWEEN 6 AND 8" + case 4: + cond = "serves > 8" + } + servingConditions = append(servingConditions, cond) + } + } + + // TODO: Title search somehow... + + // Merge condition strings + mealString := fmt.Sprintf("(%s)", strings.Join(mealConditions, " OR ")) + timeString := fmt.Sprintf("(%s)", strings.Join(timeConditions, " OR ")) + difficultyString := fmt.Sprintf("(%s)", strings.Join(difficultyConditions, " OR ")) + servingString := fmt.Sprintf("(%s)", strings.Join(servingConditions, " OR ")) + + // Combine condition strings + var conditions []string + if len(mealConditions) > 0 { + conditions = append(conditions, mealString) + } + if len(timeConditions) > 0 { + conditions = append(conditions, timeString) + } + if len(difficultyConditions) > 0 { + conditions = append(conditions, difficultyString) + } + if len(servingConditions) > 0 { + conditions = append(conditions, servingString) + } + + // Convert and append conditions + if len(conditions) > 0 { + conditionsString := fmt.Sprintf("WHERE %s", strings.Join(conditions, " AND ")) + query = fmt.Sprintf("%s %s;", query, conditionsString) + } else { + query += ";" + } + + // Execute the query + rows, err := tx.Query(query) + if err != nil { + return nil, fmt.Errorf("failed to query recipes: %w", err) + } + defer rows.Close() + + var recipes []domain.Recipe + for rows.Next() { + // Parsed values location + var recipe domain.Recipe + var durationBytes []byte + var ingredientBytes []byte + + if err := rows.Scan( + &recipe.Id, + &recipe.Title, + &recipe.Description, + pq.Array(&recipe.Instructions), + &recipe.Serves, + &recipe.Difficulty, + &durationBytes, + &recipe.Category, + &ingredientBytes, + &recipe.UserId, + &recipe.Modified, + &recipe.Created, + ); err != nil { + return nil, fmt.Errorf("failed to scan recipe row: %w", err) + } + + // Parse duration from bytes + if len(durationBytes) > 0 { + var duration domain.RecipeDuration + if err := json.Unmarshal(durationBytes, &duration); err != nil { + return nil, fmt.Errorf("failed to parse duration for recipe ID %d: %w", recipe.Id, err) + } + recipe.Duration = duration + } else { + recipe.Duration = domain.RecipeDuration{} + } + + // Parse ingredients from bytes + if len(ingredientBytes) > 0 { + var ingredients []domain.RecipeIngredient + if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil { + return nil, fmt.Errorf("failed to parse ingredients for recipe ID %d: %w", recipe.Id, err) + } + recipe.Ingredients = ingredients + } else { + recipe.Ingredients = []domain.RecipeIngredient{} + } + + recipes = append(recipes, recipe) + } + + if err := tx.Commit(); err != nil { + tx.Rollback() + return nil, err + } + + return recipes, nil +} diff --git a/internal/templates/components/dropdowns.templ b/internal/templates/components/dropdowns.templ index 6439394..226efc8 100644 --- a/internal/templates/components/dropdowns.templ +++ b/internal/templates/components/dropdowns.templ @@ -1,12 +1,15 @@ package components -templ dropdownButton(name string) { - +templ dropdownButton(content, name, value string) { + } templ FilterDropdown() { @@ -30,13 +33,13 @@ templ FilterDropdown() { Meal
- @dropdownButton("Breakfast") - @dropdownButton("Lunch") - @dropdownButton("Dinner") - @dropdownButton("Desert") - @dropdownButton("Snack") - @dropdownButton("Side") - @dropdownButton("Other") + @dropdownButton("Breakfast", "meal", "1") + @dropdownButton("Lunch", "meal", "2") + @dropdownButton("Dinner", "meal", "4") + @dropdownButton("Desert", "meal", "8") + @dropdownButton("Snack", "meal", "16") + @dropdownButton("Side", "meal", "32") + @dropdownButton("Other", "meal", "64")
@@ -44,11 +47,11 @@ templ FilterDropdown() { Cook Time
- @dropdownButton("< 15 min") - @dropdownButton("15 to 30 min") - @dropdownButton("30 to 60 min") - @dropdownButton("60 to 120 min") - @dropdownButton("+120 min") + @dropdownButton("< 15 min", "time", "1") + @dropdownButton("15 to 30 min", "time", "2") + @dropdownButton("30 to 60 min", "time", "4") + @dropdownButton("60 to 120 min", "time", "8") + @dropdownButton("+120 min", "time", "16")
@@ -56,11 +59,11 @@ templ FilterDropdown() { Difficulty
- @dropdownButton("Beginner") - @dropdownButton("Easy") - @dropdownButton("Intermediate") - @dropdownButton("Challegening") - @dropdownButton("Extreme") + @dropdownButton("Beginner", "difficulty", "1") + @dropdownButton("Easy", "difficulty", "2") + @dropdownButton("Intermediate", "difficulty", "4") + @dropdownButton("Challenging", "difficulty", "8") + @dropdownButton("Extreme", "difficulty", "16")
@@ -68,11 +71,11 @@ templ FilterDropdown() { Serving Size
- @dropdownButton("1 to 2") - @dropdownButton("1 to 4") - @dropdownButton("4 to 6") - @dropdownButton("6 to 8") - @dropdownButton("8+") + @dropdownButton("1 to 2", "serving", "1") + @dropdownButton("2 to 4", "serving", "2") + @dropdownButton("4 to 6", "serving", "4") + @dropdownButton("6 to 8", "serving", "8") + @dropdownButton("8+", "serving", "16")
diff --git a/internal/templates/components/dropdowns_templ.go b/internal/templates/components/dropdowns_templ.go index dde6499..e6605c4 100644 --- a/internal/templates/components/dropdowns_templ.go +++ b/internal/templates/components/dropdowns_templ.go @@ -8,7 +8,7 @@ package components import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" -func dropdownButton(name string) templ.Component { +func dropdownButton(content, name, value string) 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 { @@ -29,20 +29,46 @@ func dropdownButton(name string) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Please enter at least one step.

") + 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/home.templ b/internal/templates/pages/home.templ index b214922..1b4d6d7 100644 --- a/internal/templates/pages/home.templ +++ b/internal/templates/pages/home.templ @@ -26,15 +26,18 @@ templ introSection() { } templ searchBar() { +

    +
    @filterButton()
    @@ -54,6 +58,7 @@ templ searchBar() { templ filterButton() { ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -98,7 +98,7 @@ func filterButton() templ.Component { templ_7745c5c3_Var3 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -135,7 +135,20 @@ func searchSection() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -147,7 +160,7 @@ func searchSection() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -171,12 +184,12 @@ func highlightSection(liked bool) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var5 := templ.GetChildren(ctx) - if templ_7745c5c3_Var5 == nil { - templ_7745c5c3_Var5 = templ.NopComponent + templ_7745c5c3_Var6 := templ.GetChildren(ctx) + if templ_7745c5c3_Var6 == nil { + templ_7745c5c3_Var6 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -184,7 +197,7 @@ func highlightSection(liked bool) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

    Our 'Recipe of the Week' is the cream of the crop! We handpick it by looking at what recipes our community loves most. This isn't just about how many people view a recipe; it's also about how many times it's been made, liked, reviewed, and its average rating, all combined to find the true fan favorite of the week. It's our way of highlighting the best recipes that truly resonate with our users!

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

    Our 'Recipe of the Week' is the cream of the crop! We handpick it by looking at what recipes our community loves most. This isn't just about how many people view a recipe; it's also about how many times it's been made, liked, reviewed, and its average rating, all combined to find the true fan favorite of the week. It's our way of highlighting the best recipes that truly resonate with our users!

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -192,7 +205,7 @@ func highlightSection(liked bool) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -216,12 +229,12 @@ func listsSection() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var6 := templ.GetChildren(ctx) - if templ_7745c5c3_Var6 == nil { - templ_7745c5c3_Var6 = templ.NopComponent + templ_7745c5c3_Var7 := templ.GetChildren(ctx) + if templ_7745c5c3_Var7 == nil { + templ_7745c5c3_Var7 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -229,7 +242,7 @@ func listsSection() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

    Recently viewed

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

    Recently viewed

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -257,7 +270,7 @@ func listsSection() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

    Make again

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

    Make again

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -285,7 +298,7 @@ func listsSection() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -309,21 +322,21 @@ func ctaSection() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var7 := templ.GetChildren(ctx) - if templ_7745c5c3_Var7 == nil { - templ_7745c5c3_Var7 = templ.NopComponent + templ_7745c5c3_Var8 := templ.GetChildren(ctx) + if templ_7745c5c3_Var8 == nil { + templ_7745c5c3_Var8 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

    Unleash Your Inner Chef!

    Have a unique recipe idea? Want to share your culinary masterpiece with the world? It's time to bring your creations to life!

    Unleash Your Inner Chef!

    Have a unique recipe idea? Want to share your culinary masterpiece with the world? It's time to bring your creations to life!

    Create Your Recipe!
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" class=\"flex items-center justify-center\n bg-gradient-to-r from-blue-400 to-blue-600 text-white\n px-12 py-5 rounded-full shadow-sm hover:shadow-md\n transition-all duration-300 ease-in-out shadow-blue-700\n text-lg md:text-2xl font-bold uppercase tracking-wide\">Create Your Recipe!") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -347,16 +360,16 @@ func HomePage() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var9 := templ.GetChildren(ctx) - if templ_7745c5c3_Var9 == nil { - templ_7745c5c3_Var9 = templ.NopComponent + templ_7745c5c3_Var10 := templ.GetChildren(ctx) + if templ_7745c5c3_Var10 == nil { + templ_7745c5c3_Var10 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = components.Navbar("home").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -380,7 +393,7 @@ func HomePage() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/static/css/tailwind.css b/web/static/css/tailwind.css index 2f9e417..8d6b704 100644 --- a/web/static/css/tailwind.css +++ b/web/static/css/tailwind.css @@ -212,6 +212,17 @@ } } @layer utilities { + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; + } .absolute { position: absolute; } @@ -221,6 +232,9 @@ .static { position: static; } + .top-1 { + top: calc(var(--spacing) * 1); + } .top-1\/2 { top: calc(1/2 * 100%); } @@ -230,6 +244,9 @@ .left-0 { left: calc(var(--spacing) * 0); } + .left-1 { + left: calc(var(--spacing) * 1); + } .left-1\/2 { left: calc(1/2 * 100%); } @@ -323,6 +340,9 @@ .hidden { display: none; } + .inline-block { + display: inline-block; + } .inline-flex { display: inline-flex; } @@ -375,12 +395,18 @@ .h-screen { height: 100vh; } + .w-1 { + width: calc(var(--spacing) * 1); + } .w-1\/3 { width: calc(1/3 * 100%); } .w-1\/4 { width: calc(1/4 * 100%); } + .w-3 { + width: calc(var(--spacing) * 3); + } .w-3\/4 { width: calc(3/4 * 100%); } @@ -393,6 +419,9 @@ .w-5 { width: calc(var(--spacing) * 5); } + .w-9 { + width: calc(var(--spacing) * 9); + } .w-9\/10 { width: calc(9/10 * 100%); } @@ -414,16 +443,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); @@ -431,6 +474,9 @@ .cursor-pointer { cursor: pointer; } + .resize { + resize: both; + } .resize-none { resize: none; } @@ -766,6 +812,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); @@ -844,6 +893,21 @@ .\[-webkit-line-clamp\:4\] { -webkit-line-clamp: 4; } + .peer-checked\:border-blue-600 { + &:is(:where(.peer):checked ~ *) { + border-color: var(--color-blue-600); + } + } + .peer-checked\:bg-blue-600 { + &:is(:where(.peer):checked ~ *) { + background-color: var(--color-blue-600); + } + } + .peer-checked\:text-white { + &:is(:where(.peer):checked ~ *) { + color: var(--color-white); + } + } .peer-invalid\:block { &:is(:where(.peer):invalid ~ *) { display: block; @@ -920,6 +984,13 @@ } } } + .hover\:border-gray-400 { + &:hover { + @media (hover: hover) { + border-color: var(--color-gray-400); + } + } + } .hover\:bg-blue-200 { &:hover { @media (hover: hover) { @@ -978,6 +1049,15 @@ } } } + .checked\:hover\:bg-blue-700 { + &:checked { + &:hover { + @media (hover: hover) { + background-color: var(--color-blue-700); + } + } + } + } .focus\:bg-blue-200 { &:focus { background-color: var(--color-blue-200); @@ -999,6 +1079,12 @@ --tw-ring-color: var(--color-blue-500); } } + .focus\:ring-offset-2 { + &:focus { + --tw-ring-offset-width: 2px; + --tw-ring-offset-shadow: var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + } + } .focus\:outline-hidden { &:focus { --tw-outline-style: none;