diff --git a/doc/TechnicalSpecification.md b/doc/TechnicalSpecification.md
index 5e15783..367385a 100644
--- a/doc/TechnicalSpecification.md
+++ b/doc/TechnicalSpecification.md
@@ -78,19 +78,19 @@ well as view lists of recently made recipes, recently viewed and trending recipe
##### API Requirements
-- [ ] Message & Pills Banner
- - [ ] Search all recipes of a specific meal type
+- [ ] ~Message & Pills Banner~
+ - [ ] ~Search all recipes of a specific meal type~
-- [ ] Search bar
- - [ ] Text search on titles based on search query
- - [ ] Text search on tags based on search query
- - [ ] Text search on **meal** based on search query
+- [x] Search bar
+ - [x] Text search on titles based on search query
+ - [x] Text search on tags based on search query
+ - [x] Text search on **meal** based on search query
-- [ ] Filter drop down
- - [ ] Update search to only contain meals from selected filter
- - [ ] Update search to only contain means that meet the time requirement of the selected filter
- - [ ] Update search to only contain meals that meet the difficulty level of the selected filter
- - [ ] Update search to only contain meals that meet the serving size of the selected filter
+- [x] Filter drop down
+ - [x] Update search to only contain meals from selected filter
+ - [x] Update search to only contain means that meet the time requirement of the selected filter
+ - [x] Update search to only contain meals that meet the difficulty level of the selected filter
+ - [x] Update search to only contain meals that meet the serving size of the selected filter
- [ ] Recipe of The Week
- [ ] Fetch the most performing recipe of the last 7 days and all of the required meta data
@@ -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/auth_handler.go b/internal/app/handlers/auth_handler.go
index 6b28357..2da9eff 100644
--- a/internal/app/handlers/auth_handler.go
+++ b/internal/app/handlers/auth_handler.go
@@ -42,7 +42,7 @@ func GoogleCallback(ctx *gin.Context) {
jwt,
int(time.Now().Add(7*24*time.Hour).Sub(time.Now()).Seconds()),
"/",
- "localhost",
+ "", // TODO: Real live domain
false, // TODO: True in prod
true,
)
@@ -60,6 +60,6 @@ func GoogleCallback(ctx *gin.Context) {
// This route will direct the user back to the home page.
func Logout(ctx *gin.Context) {
// TODO: Use same values as the GoogleCallback function
- ctx.SetCookie("jwt_token", "", -1, "/", "localhost", false, true)
+ ctx.SetCookie("jwt_token", "", -1, "/", "", false, true) // TODO: Update settings
ctx.Redirect(http.StatusSeeOther, domain.WEB_HOME)
}
diff --git a/internal/app/handlers/page_handler.go b/internal/app/handlers/page_handler.go
index 1f87c36..3dd8975 100644
--- a/internal/app/handlers/page_handler.go
+++ b/internal/app/handlers/page_handler.go
@@ -1,12 +1,15 @@
package handlers
import (
+ "encoding/json"
"fmt"
"net/http"
"strconv"
+ "github.com/a-h/templ"
"github.com/gin-gonic/gin"
- domain "github.com/haydenhargreaves/Potion/internal/domain/server"
+ domainRecipe "github.com/haydenhargreaves/Potion/internal/domain/recipe"
+ domainServer "github.com/haydenhargreaves/Potion/internal/domain/server"
layouts "github.com/haydenhargreaves/Potion/internal/templates/layouts"
pages "github.com/haydenhargreaves/Potion/internal/templates/pages"
)
@@ -15,64 +18,64 @@ func LoginPage(ctx *gin.Context) {
title := "Potion - Login"
page := pages.LoginPage()
- ctx.HTML(200, "", layouts.AppLayout(title, page))
+ ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
func HomePage(ctx *gin.Context) {
title := "Potion - Home"
page := pages.HomePage()
- ctx.HTML(200, "", layouts.AppLayout(title, page))
+ ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
func FavoritesPage(ctx *gin.Context) {
title := "Potion - Favorites"
page := pages.FavoritesPage()
- ctx.HTML(200, "", layouts.AppLayout(title, page))
+ ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
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)
+ if !domainServer.IsLoggedIn(ctx) {
+ ctx.Redirect(http.StatusSeeOther, domainServer.WEB_LOGIN)
return
}
title := "Potion - Create"
page := pages.CreatePage()
- ctx.HTML(200, "", layouts.AppLayout(title, page))
+ ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
func ProfilePage(ctx *gin.Context) {
// If not logged in, direct to the login page
- if !domain.IsLoggedIn(ctx) {
- ctx.Redirect(http.StatusSeeOther, domain.WEB_LOGIN)
+ if !domainServer.IsLoggedIn(ctx) {
+ ctx.Redirect(http.StatusSeeOther, domainServer.WEB_LOGIN)
return
}
// Else, get the user data
- deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
+ deps := ctx.MustGet("deps").(*domainServer.InjectedDependencies)
user := deps.UserService.GetAuthenicatedUser(ctx)
title := "Potion - Profile"
page := pages.ProfilePage(user)
- ctx.HTML(200, "", layouts.AppLayout(title, page))
+ ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
func ListPage(ctx *gin.Context) {
title := "Potion - Shopping List"
page := pages.ListPage()
- ctx.HTML(200, "", layouts.AppLayout(title, page))
+ ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
// TODO: Figure out how to handle errors, think we just need a simple display.
func RecipePage(ctx *gin.Context) {
// Call recipe service to get via ID
- deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
+ deps := ctx.MustGet("deps").(*domainServer.InjectedDependencies)
id := ctx.Param("id")
// Parse ID
@@ -102,5 +105,26 @@ func RecipePage(ctx *gin.Context) {
title := "Potion - View Recipe"
page := pages.RecipePage(*recipe, *user)
- ctx.HTML(200, "", layouts.AppLayout(title, page))
+ ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
+}
+
+func SearchPage(ctx *gin.Context) {
+ var page templ.Component
+ // Get filters from cookies
+ if bytes, err := ctx.Cookie("search-filters"); err != nil {
+ fmt.Printf("ERROR: Failed to get search-filter cookie. %s\n", err.Error())
+ page = pages.SearchPage(domainRecipe.SearchFilters{}, false)
+ } else {
+ var filters domainRecipe.SearchFilters
+ if err := json.Unmarshal([]byte(bytes), &filters); err != nil {
+ fmt.Printf("ERROR: Failed to unmarshal search-filter cookie. %s\n", err.Error())
+ page = pages.SearchPage(domainRecipe.SearchFilters{}, false)
+ } else {
+ page = pages.SearchPage(filters, true)
+ }
+ }
+
+ title := "Potion - Recipe Search"
+
+ ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
diff --git a/internal/app/handlers/recipe_handler.go b/internal/app/handlers/recipe_handler.go
index 6756423..a256307 100644
--- a/internal/app/handlers/recipe_handler.go
+++ b/internal/app/handlers/recipe_handler.go
@@ -1,19 +1,18 @@
package handlers
import (
+ "encoding/json"
"fmt"
"net/http"
+ "strconv"
+ "time"
"github.com/gin-gonic/gin"
+ domainRecipe "github.com/haydenhargreaves/Potion/internal/domain/recipe"
domain "github.com/haydenhargreaves/Potion/internal/domain/server"
+ templates "github.com/haydenhargreaves/Potion/internal/templates/pages"
)
-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
@@ -34,3 +33,56 @@ func CreateRecipe(ctx *gin.Context) {
ctx.Header("HX-Redirect", url)
ctx.Status(http.StatusCreated)
}
+
+// 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
+}
+
+// TODO: I don't love doing all of this here, but it seems to be the only way to get it to work...
+func SearchRecipes(ctx *gin.Context) {
+ deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
+
+
+ // create filters
+ filters := domainRecipe.SearchFilters{
+ Search: ctx.PostForm("search"), // string, search query for titles
+ MealType: toBits(ctx.PostFormArray("meal")),
+ Time: toBits(ctx.PostFormArray("time")),
+ Difficulty: toBits(ctx.PostFormArray("difficulty")),
+ ServingSize: toBits(ctx.PostFormArray("serving")),
+ }
+
+ // Set the filters into the cookies, so they can be reloaded
+ if bytes, err := json.Marshal(filters); err == nil {
+ ctx.SetCookie(
+ "search-filters",
+ string(bytes),
+ int(time.Now().Add(24 * time.Hour).Sub(time.Now()).Seconds()),
+ "/",
+ "", // TODO: Need an actual domain
+ false, // TODO: True in prod
+ true,
+ )
+ }
+
+ redirect := ctx.PostForm("redirect")
+ if redirect == "true" {
+ ctx.Header("HX-Redirect", domain.WEB_SEARCH)
+ ctx.Status(http.StatusOK)
+ return
+ }
+
+ recipes, err := deps.RecipeService.SearchRecipes(filters)
+ if err != nil {
+ ctx.JSON(http.StatusOK, gin.H{"error": err.Error()})
+ }
+
+ // Render content as the response
+ ctx.Status(200)
+ templates.ResultList(recipes).Render(ctx.Request.Context(), ctx.Writer)
+}
diff --git a/internal/app/handlers/state_handler.go b/internal/app/handlers/state_handler.go
index 274f864..91c2de3 100644
--- a/internal/app/handlers/state_handler.go
+++ b/internal/app/handlers/state_handler.go
@@ -6,11 +6,12 @@ import (
"strings"
"github.com/gin-gonic/gin"
+ domain "github.com/haydenhargreaves/Potion/internal/domain/server"
)
const TAG_HTML = `
>pos)&1 == 1
+}
+
+// SearchRecipes will search the recipe table using the provided filters and return an unbound list
+// of recipes. The filters are fairly complex, they are stored as bit masks. A more details
+// description can be found in the recipe service implementation. Any errors will be bubbled to the
+// caller.
+//
+// TODO: Pagination is required, to provide infinite scroll.
+func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters) ([]domain.Recipe, error) {
+ tx, err := r.db.Begin()
+ if err != nil {
+ tx.Rollback()
+ return nil, err
+ }
+
+ // 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)
+ }
+
+ // Define columns to select. More fields can be added if the full text search is required
+ columns := []string{
+ "id",
+ "title",
+ "description",
+ "instructions",
+ "serves",
+ "difficulty",
+ "duration",
+ "category",
+ "ingredients",
+ "userid",
+ "modified",
+ "created",
+ }
+
+ // Create search vector query
+ var orderBy string = ""
+ if filters.Search != "" {
+ vector_query := strings.ReplaceAll(filters.Search, " ", " | ")
+
+ conditions = append(
+ conditions,
+ fmt.Sprintf("search_vector @@ to_tsquery('english', '%s')", vector_query),
+ )
+
+ template := `
+ ORDER BY
+ ts_rank(search_vector, to_tsquery('english', '%s')) DESC,
+ ts_rank_cd(search_vector, to_tsquery('english', '%s')) DESC
+ `
+ orderBy = fmt.Sprintf(template, vector_query, vector_query)
+ }
+
+ // Generate the query
+ query := fmt.Sprintf("SELECT %s FROM recipes", strings.Join(columns, ","))
+
+ // Convert and append conditions if provided
+ if len(conditions) > 0 {
+ conditionsString := fmt.Sprintf("WHERE %s", strings.Join(conditions, " AND "))
+ query = fmt.Sprintf("%s %s", query, conditionsString)
+ }
+
+ // Append sorting order if exists
+ if len(orderBy) > 0 {
+ query = fmt.Sprintf("%s %s", query, orderBy)
+ }
+
+ // Finish it off with a colon!
+ 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..2940e38 100644
--- a/internal/templates/components/dropdowns.templ
+++ b/internal/templates/components/dropdowns.templ
@@ -1,16 +1,27 @@
package components
-templ dropdownButton(name string) {
-
+import domainRecipe "github.com/haydenhargreaves/Potion/internal/domain/recipe"
+
+// isBitActive returns true when the bit at pos (0 indexed) is true.
+func isBitActive(bits, pos int) bool {
+return (bits>>pos)&1 == 1
}
-templ FilterDropdown() {
-
-