From cc28aa9a8e16d8349058fb9750e36bbf2a67f531 Mon Sep 17 00:00:00 2001 From: Nathan Lebrun Date: Sat, 25 Oct 2025 22:13:55 +0200 Subject: [PATCH] added filter and tag for ingredients --- database/db.go | 267 +++++++++++++++++++++++- handlers/ingredients.go | 446 +++++++++++++++++++++++++++++++++++++--- main.go | 21 +- models/models.go | 14 ++ static/styles.css | 195 ++++++++++++++++++ 5 files changed, 914 insertions(+), 29 deletions(-) diff --git a/database/db.go b/database/db.go index 4726599..df352df 100644 --- a/database/db.go +++ b/database/db.go @@ -4,6 +4,7 @@ import ( "database/sql" "fmt" "mealprep/models" + "strings" "time" _ "github.com/mattn/go-sqlite3" @@ -71,6 +72,26 @@ func createTables() error { CREATE INDEX IF NOT EXISTS idx_ingredients_user_id ON ingredients(user_id); + -- Tags table (user-isolated) + CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_id, name) + ); + + CREATE INDEX IF NOT EXISTS idx_tags_user_id ON tags(user_id); + + -- Ingredient tags junction table + CREATE TABLE IF NOT EXISTS ingredient_tags ( + ingredient_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + PRIMARY KEY (ingredient_id, tag_id), + FOREIGN KEY (ingredient_id) REFERENCES ingredients(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE + ); + -- Meals table (user-isolated) CREATE TABLE IF NOT EXISTS meals ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -248,7 +269,14 @@ func DeleteIngredient(userID, ingredientID int) error { "DELETE FROM ingredients WHERE id = ? AND user_id = ?", ingredientID, userID, ) - return err + if err != nil { + return err + } + + // Clean up unused tags + CleanupUnusedTags(userID) + + return nil } // Meal operations (user-isolated) @@ -454,3 +482,240 @@ func GetGroceryList(userID int) ([]models.GroceryItem, error) { } return items, nil } + +// Tag operations (user-isolated) + +func GetAllTags(userID int) ([]models.Tag, error) { + rows, err := DB.Query( + "SELECT id, user_id, name FROM tags WHERE user_id = ? ORDER BY name", + userID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var tags []models.Tag + for rows.Next() { + var tag models.Tag + if err := rows.Scan(&tag.ID, &tag.UserID, &tag.Name); err != nil { + return nil, err + } + tags = append(tags, tag) + } + return tags, nil +} + +func GetUsedTags(userID int) ([]models.Tag, error) { + query := ` + SELECT DISTINCT t.id, t.user_id, t.name + FROM tags t + JOIN ingredient_tags it ON t.id = it.tag_id + WHERE t.user_id = ? + ORDER BY t.name + ` + rows, err := DB.Query(query, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var tags []models.Tag + for rows.Next() { + var tag models.Tag + if err := rows.Scan(&tag.ID, &tag.UserID, &tag.Name); err != nil { + return nil, err + } + tags = append(tags, tag) + } + return tags, nil +} + +func CleanupUnusedTags(userID int) error { + // Delete tags that have no ingredient associations + _, err := DB.Exec(` + DELETE FROM tags + WHERE user_id = ? + AND id NOT IN ( + SELECT DISTINCT tag_id FROM ingredient_tags + ) + `, userID) + return err +} + +func AddTag(userID int, name string) (int64, error) { + result, err := DB.Exec( + "INSERT INTO tags (user_id, name) VALUES (?, ?)", + userID, name, + ) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +func GetOrCreateTag(userID int, name string) (int64, error) { + // Try to get existing tag + var tagID int64 + err := DB.QueryRow( + "SELECT id FROM tags WHERE user_id = ? AND name = ?", + userID, name, + ).Scan(&tagID) + + if err == sql.ErrNoRows { + // Tag doesn't exist, create it + return AddTag(userID, name) + } + if err != nil { + return 0, err + } + return tagID, nil +} + +func DeleteTag(userID, tagID int) error { + _, err := DB.Exec( + "DELETE FROM tags WHERE id = ? AND user_id = ?", + tagID, userID, + ) + return err +} + +// Ingredient tag operations + +func GetIngredientTags(userID, ingredientID int) ([]models.Tag, error) { + query := ` + SELECT t.id, t.user_id, t.name + FROM tags t + JOIN ingredient_tags it ON t.id = it.tag_id + JOIN ingredients i ON it.ingredient_id = i.id + WHERE it.ingredient_id = ? AND i.user_id = ? AND t.user_id = ? + ORDER BY t.name + ` + rows, err := DB.Query(query, ingredientID, userID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var tags []models.Tag + for rows.Next() { + var tag models.Tag + if err := rows.Scan(&tag.ID, &tag.UserID, &tag.Name); err != nil { + return nil, err + } + tags = append(tags, tag) + } + return tags, nil +} + +func AddIngredientTag(userID, ingredientID, tagID int) error { + // Verify ingredient and tag belong to user + var count int + err := DB.QueryRow( + "SELECT COUNT(*) FROM ingredients i, tags t WHERE i.id = ? AND t.id = ? AND i.user_id = ? AND t.user_id = ?", + ingredientID, tagID, userID, userID, + ).Scan(&count) + if err != nil || count == 0 { + return fmt.Errorf("ingredient or tag not found or doesn't belong to user") + } + + _, err = DB.Exec( + "INSERT OR IGNORE INTO ingredient_tags (ingredient_id, tag_id) VALUES (?, ?)", + ingredientID, tagID, + ) + return err +} + +func RemoveIngredientTag(userID, ingredientID, tagID int) error { + // Verify ownership + var count int + err := DB.QueryRow( + "SELECT COUNT(*) FROM ingredients WHERE id = ? AND user_id = ?", + ingredientID, userID, + ).Scan(&count) + if err != nil || count == 0 { + return fmt.Errorf("ingredient not found or doesn't belong to user") + } + + _, err = DB.Exec( + "DELETE FROM ingredient_tags WHERE ingredient_id = ? AND tag_id = ?", + ingredientID, tagID, + ) + if err != nil { + return err + } + + // Clean up unused tags + CleanupUnusedTags(userID) + + return nil +} + +func GetIngredientsWithTags(userID int) ([]models.Ingredient, error) { + rows, err := DB.Query( + "SELECT id, user_id, name, unit FROM ingredients WHERE user_id = ? ORDER BY name", + userID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var ingredients []models.Ingredient + for rows.Next() { + var ing models.Ingredient + if err := rows.Scan(&ing.ID, &ing.UserID, &ing.Name, &ing.Unit); err != nil { + return nil, err + } + + // Get tags for this ingredient + ing.Tags, _ = GetIngredientTags(userID, ing.ID) + ingredients = append(ingredients, ing) + } + return ingredients, nil +} + +func SearchIngredientsByTags(userID int, tagNames []string) ([]models.Ingredient, error) { + if len(tagNames) == 0 { + return GetIngredientsWithTags(userID) + } + + // Build query with placeholders for tag names + placeholders := make([]string, len(tagNames)) + args := []interface{}{userID} + for i, name := range tagNames { + placeholders[i] = "?" + args = append(args, name) + } + args = append(args, len(tagNames)) + + query := fmt.Sprintf(` + SELECT DISTINCT i.id, i.user_id, i.name, i.unit + FROM ingredients i + JOIN ingredient_tags it ON i.id = it.ingredient_id + JOIN tags t ON it.tag_id = t.id + WHERE i.user_id = ? AND t.name IN (%s) + GROUP BY i.id + HAVING COUNT(DISTINCT t.id) = ? + ORDER BY i.name + `, strings.Join(placeholders, ",")) + + rows, err := DB.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var ingredients []models.Ingredient + for rows.Next() { + var ing models.Ingredient + if err := rows.Scan(&ing.ID, &ing.UserID, &ing.Name, &ing.Unit); err != nil { + return nil, err + } + + // Get tags for this ingredient + ing.Tags, _ = GetIngredientTags(userID, ing.ID) + ingredients = append(ingredients, ing) + } + return ingredients, nil +} diff --git a/handlers/ingredients.go b/handlers/ingredients.go index 0382a61..5dce095 100644 --- a/handlers/ingredients.go +++ b/handlers/ingredients.go @@ -12,7 +12,38 @@ import ( // IngredientsHandler handles the ingredients page func IngredientsHandler(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r) - ingredients, err := database.GetAllIngredients(userID) + + // Check if there's a search query + searchTags := r.URL.Query().Get("tags") + var ingredients []interface{} + var err error + + if searchTags != "" { + tagNames := strings.Split(searchTags, ",") + for i := range tagNames { + tagNames[i] = strings.TrimSpace(tagNames[i]) + } + ings, err := database.SearchIngredientsByTags(userID, tagNames) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + for _, ing := range ings { + ingredients = append(ingredients, ing) + } + } else { + ings, err := database.GetIngredientsWithTags(userID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + for _, ing := range ings { + ingredients = append(ingredients, ing) + } + } + + // Get all available tags for the filter UI (only tags that are in use) + allTags, err := database.GetUsedTags(userID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -25,30 +56,136 @@ func IngredientsHandler(w http.ResponseWriter, r *http.Request) {
+
-
- {{range .}} -
- {{.Name}} - ({{.Unit}}) - + {{end}} +
+
+ +
+ +
+ {{range .Ingredients}} +
+
+ {{.Name}} + ({{.Unit}}) +
+ {{range .Tags}} + {{.Name}} + {{end}} +
+
+
+ + +
{{end}}
+ + + + + ` + data := struct { + Ingredients []interface{} + AllTags []interface{} + }{ + Ingredients: ingredients, + AllTags: []interface{}{}, + } + + for _, tag := range allTags { + data.AllTags = append(data.AllTags, tag) + } + t := template.Must(template.New("ingredients").Parse(tmpl)) - t.Execute(w, ingredients) + t.Execute(w, data) } // AddIngredientHandler handles adding a new ingredient @@ -62,6 +199,7 @@ func AddIngredientHandler(w http.ResponseWriter, r *http.Request) { name := strings.TrimSpace(r.FormValue("name")) unit := strings.TrimSpace(r.FormValue("unit")) + tagsStr := strings.TrimSpace(r.FormValue("tags")) if name == "" || unit == "" { http.Error(w, "Name and unit are required", http.StatusBadRequest) @@ -74,18 +212,54 @@ func AddIngredientHandler(w http.ResponseWriter, r *http.Request) { return } + // Process tags if provided + var tags []string + if tagsStr != "" { + tagNames := strings.Split(tagsStr, ",") + for _, tagName := range tagNames { + tagName = strings.TrimSpace(tagName) + if tagName != "" { + // Get or create tag + tagID, err := database.GetOrCreateTag(userID, tagName) + if err != nil { + continue // Skip this tag on error + } + // Link tag to ingredient + database.AddIngredientTag(userID, int(id), int(tagID)) + tags = append(tags, tagName) + } + } + } + tmpl := `
- {{.Name}} - ({{.Unit}}) - +
+ {{.Name}} + ({{.Unit}}) +
+ {{range .Tags}} + {{.}} + {{end}} +
+
+
+ + +
` @@ -93,7 +267,8 @@ func AddIngredientHandler(w http.ResponseWriter, r *http.Request) { ID int64 Name string Unit string - }{id, name, unit} + Tags []string + }{id, name, unit, tags} t := template.Must(template.New("ingredient").Parse(tmpl)) t.Execute(w, data) @@ -117,3 +292,224 @@ func DeleteIngredientHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } + +// GetIngredientTagsHandler returns the tag editing UI for an ingredient +func GetIngredientTagsHandler(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r) + + // Extract ingredient ID from path /ingredients/{id}/tags + pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/ingredients/"), "/") + if len(pathParts) < 1 { + http.Error(w, "Invalid path", http.StatusBadRequest) + return + } + + ingredientID, err := strconv.Atoi(pathParts[0]) + if err != nil { + http.Error(w, "Invalid ingredient ID", http.StatusBadRequest) + return + } + + // Get current tags for ingredient + currentTags, err := database.GetIngredientTags(userID, ingredientID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Get all available tags + allTags, err := database.GetAllTags(userID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Build a set of current tag IDs for easy lookup + currentTagIDs := make(map[int]bool) + for _, tag := range currentTags { + currentTagIDs[tag.ID] = true + } + + tmpl := ` +

Edit Tags

+
+

Current Tags

+
+ {{range .CurrentTags}} + + {{.Name}} + + + {{end}} +
+ +

Add Tags

+
+ + + {{range .AllTags}} + {{if not (index $.CurrentTagIDs .ID)}} + + +
+
+ ` + + data := struct { + IngredientID int + CurrentTags []interface{} + AllTags []interface{} + CurrentTagIDs map[int]bool + }{ + IngredientID: ingredientID, + CurrentTags: []interface{}{}, + AllTags: []interface{}{}, + CurrentTagIDs: currentTagIDs, + } + + for _, tag := range currentTags { + data.CurrentTags = append(data.CurrentTags, tag) + } + for _, tag := range allTags { + data.AllTags = append(data.AllTags, tag) + } + + t := template.Must(template.New("ingredient-tags").Parse(tmpl)) + t.Execute(w, data) +} + +// AddIngredientTagHandler adds a tag to an ingredient +func AddIngredientTagHandler(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r) + + // Extract ingredient ID from path + pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/ingredients/"), "/") + if len(pathParts) < 1 { + http.Error(w, "Invalid path", http.StatusBadRequest) + return + } + + ingredientID, err := strconv.Atoi(pathParts[0]) + if err != nil { + http.Error(w, "Invalid ingredient ID", http.StatusBadRequest) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + tagName := strings.TrimSpace(r.FormValue("tag_name")) + if tagName == "" { + http.Error(w, "Tag name is required", http.StatusBadRequest) + return + } + + // Get or create the tag + tagID, err := database.GetOrCreateTag(userID, tagName) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Add tag to ingredient + if err := database.AddIngredientTag(userID, ingredientID, int(tagID)); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Return updated tag list with trigger to refresh page + w.Header().Set("HX-Trigger", "refreshIngredients") + renderCurrentTags(w, userID, ingredientID) +} + +// RemoveIngredientTagHandler removes a tag from an ingredient +func RemoveIngredientTagHandler(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r) + + // Extract ingredient ID and tag ID from path /ingredients/{ingredient_id}/tags/{tag_id} + pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/ingredients/"), "/") + if len(pathParts) < 3 { + http.Error(w, "Invalid path", http.StatusBadRequest) + return + } + + ingredientID, err := strconv.Atoi(pathParts[0]) + if err != nil { + http.Error(w, "Invalid ingredient ID", http.StatusBadRequest) + return + } + + tagID, err := strconv.Atoi(pathParts[2]) + if err != nil { + http.Error(w, "Invalid tag ID", http.StatusBadRequest) + return + } + + // Remove tag from ingredient + if err := database.RemoveIngredientTag(userID, ingredientID, tagID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Return updated tag list with trigger to refresh page + w.Header().Set("HX-Trigger", "refreshIngredients") + renderCurrentTags(w, userID, ingredientID) +} + +// Helper function to render current tags +func renderCurrentTags(w http.ResponseWriter, userID, ingredientID int) { + currentTags, err := database.GetIngredientTags(userID, ingredientID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + tmpl := ` + {{range .Tags}} + + {{.Name}} + + + {{end}} + ` + + type TagWithID struct { + ID int + Name string + } + + data := struct { + Tags []TagWithID + IngredientID int + }{ + Tags: []TagWithID{}, + IngredientID: ingredientID, + } + + for _, tag := range currentTags { + data.Tags = append(data.Tags, TagWithID{ID: tag.ID, Name: tag.Name}) + } + + t := template.Must(template.New("current-tags").Parse(tmpl)) + t.Execute(w, data) +} diff --git a/main.go b/main.go index f4c72b5..1e9a7a9 100644 --- a/main.go +++ b/main.go @@ -63,10 +63,25 @@ func main() { } })) http.HandleFunc("/ingredients/", auth.RequireAuth(func(w http.ResponseWriter, r *http.Request) { - if r.Method == "DELETE" { - handlers.DeleteIngredientHandler(w, r) + path := r.URL.Path + if strings.Contains(path, "/tags") { + // Ingredient tag routes + if r.Method == "GET" { + handlers.GetIngredientTagsHandler(w, r) + } else if r.Method == "POST" { + handlers.AddIngredientTagHandler(w, r) + } else if r.Method == "DELETE" { + handlers.RemoveIngredientTagHandler(w, r) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } } else { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + // Ingredient delete route + if r.Method == "DELETE" { + handlers.DeleteIngredientHandler(w, r) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } } })) diff --git a/models/models.go b/models/models.go index 5f82dd9..21b0403 100644 --- a/models/models.go +++ b/models/models.go @@ -24,6 +24,20 @@ type Ingredient struct { UserID int `json:"user_id"` Name string `json:"name"` Unit string `json:"unit"` // e.g., "grams", "ml", "cups", "pieces", "tbsp" + Tags []Tag `json:"tags,omitempty"` +} + +// Tag represents a tag for categorizing ingredients +type Tag struct { + ID int `json:"id"` + UserID int `json:"user_id"` + Name string `json:"name"` +} + +// IngredientTag represents the many-to-many relationship between ingredients and tags +type IngredientTag struct { + IngredientID int `json:"ingredient_id"` + TagID int `json:"tag_id"` } // Meal represents a meal recipe diff --git a/static/styles.css b/static/styles.css index a90d621..5242a1b 100644 --- a/static/styles.css +++ b/static/styles.css @@ -460,6 +460,201 @@ button:hover { font-style: italic; } +/* Tags */ +.tag { + display: inline-block; + padding: 4px 10px; + background-color: #3498db; + color: white; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + margin-right: 5px; + margin-bottom: 5px; +} + +.item-tags { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 8px; +} + +.item-content { + flex: 1; +} + +.item-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.edit-tags-btn { + background-color: #9b59b6; + padding: 8px 15px; + font-size: 13px; +} + +.edit-tags-btn:hover { + background-color: #8e44ad; +} + +/* Tag Filter Section */ +.search-section { + margin-bottom: 25px; + padding: 20px; + background-color: #f8f9fa; + border-radius: 8px; + border: 1px solid #e0e0e0; +} + +.search-section h3 { + margin-bottom: 15px; + color: #2c3e50; + font-size: 1.1em; +} + +.tag-filter { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 15px; +} + +.tag-filter-btn { + padding: 8px 16px; + background-color: #ecf0f1; + color: #2c3e50; + border: 2px solid #bdc3c7; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; +} + +.tag-filter-btn:hover { + background-color: #bdc3c7; + border-color: #95a5a6; +} + +.tag-filter-btn.active { + background-color: #3498db; + color: white; + border-color: #3498db; +} + +.active-filters { + color: #7f8c8d; + font-size: 14px; + font-style: italic; + min-height: 20px; + margin-bottom: 10px; +} + +.clear-filters-btn { + padding: 8px 16px; + background-color: #e74c3c; + color: white; + font-size: 13px; +} + +.clear-filters-btn:hover { + background-color: #c0392b; +} + +/* Tag Edit Modal */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); +} + +.modal .modal-content { + background-color: white; + margin: 10% auto; + padding: 30px; + border-radius: 10px; + width: 90%; + max-width: 500px; + position: relative; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); +} + +.close { + color: #aaa; + float: right; + font-size: 28px; + font-weight: bold; + cursor: pointer; + line-height: 20px; +} + +.close:hover, +.close:focus { + color: #000; +} + +.tag-edit-section { + margin-top: 20px; +} + +.tag-edit-section h4 { + margin-top: 20px; + margin-bottom: 10px; + color: #2c3e50; +} + +#current-tags-{{.IngredientID}}, +[id^="current-tags-"] { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 20px; + min-height: 40px; + padding: 10px; + background-color: #f8f9fa; + border-radius: 6px; +} + +.tag-remove { + background-color: transparent; + color: white; + padding: 0 5px; + margin-left: 5px; + font-size: 16px; + font-weight: bold; + border: none; + cursor: pointer; + border-radius: 50%; +} + +.tag-remove:hover { + background-color: rgba(0, 0, 0, 0.2); +} + +.add-tag-form { + display: flex; + gap: 10px; + padding: 15px; + background-color: #f8f9fa; + border-radius: 6px; +} + +.add-tag-form input { + flex: 1; +} + +.add-tag-form button { + flex-shrink: 0; +} + /* Authentication Pages */ .auth-container { min-height: 100vh;