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}})
-
+
+
+
+
+
`
+ 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}})
-
- Delete
-
+
+
{{.Name}}
+
({{.Unit}})
+
+ {{range .Tags}}
+ {{.}}
+ {{end}}
+
+
+
+
+ Edit Tags
+
+
+ Delete
+
+
`
@@ -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
+
+
+ `
+
+ 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;