added filter and tag for ingredients

This commit is contained in:
2025-10-25 22:13:55 +02:00
parent b8046c87b9
commit cc28aa9a8e
5 changed files with 914 additions and 29 deletions

View File

@@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"mealprep/models" "mealprep/models"
"strings"
"time" "time"
_ "github.com/mattn/go-sqlite3" _ "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); 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) -- Meals table (user-isolated)
CREATE TABLE IF NOT EXISTS meals ( CREATE TABLE IF NOT EXISTS meals (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -248,7 +269,14 @@ func DeleteIngredient(userID, ingredientID int) error {
"DELETE FROM ingredients WHERE id = ? AND user_id = ?", "DELETE FROM ingredients WHERE id = ? AND user_id = ?",
ingredientID, userID, ingredientID, userID,
) )
return err if err != nil {
return err
}
// Clean up unused tags
CleanupUnusedTags(userID)
return nil
} }
// Meal operations (user-isolated) // Meal operations (user-isolated)
@@ -454,3 +482,240 @@ func GetGroceryList(userID int) ([]models.GroceryItem, error) {
} }
return items, nil 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
}

View File

@@ -12,7 +12,38 @@ import (
// IngredientsHandler handles the ingredients page // IngredientsHandler handles the ingredients page
func IngredientsHandler(w http.ResponseWriter, r *http.Request) { func IngredientsHandler(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r) 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 { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@@ -25,30 +56,136 @@ func IngredientsHandler(w http.ResponseWriter, r *http.Request) {
<form hx-post="/ingredients" hx-target="#ingredients-list" hx-swap="beforeend" class="add-form"> <form hx-post="/ingredients" hx-target="#ingredients-list" hx-swap="beforeend" class="add-form">
<input type="text" name="name" placeholder="Ingredient name" required /> <input type="text" name="name" placeholder="Ingredient name" required />
<input type="text" name="unit" placeholder="Unit (e.g., grams, cups)" required /> <input type="text" name="unit" placeholder="Unit (e.g., grams, cups)" required />
<input type="text" name="tags" placeholder="Tags (comma-separated)" />
<button type="submit">Add Ingredient</button> <button type="submit">Add Ingredient</button>
</form> </form>
<div id="ingredients-list" class="items-list"> <div class="search-section">
{{range .}} <h3>Filter by Tags</h3>
<div class="item" id="ingredient-{{.ID}}"> <div class="tag-filter">
<span class="item-name">{{.Name}}</span> {{range .AllTags}}
<span class="item-unit">({{.Unit}})</span> <button class="tag-filter-btn"
<button onclick="toggleTagFilter('{{.Name}}')">
hx-delete="/ingredients/{{.ID}}" {{.Name}}
hx-target="#ingredient-{{.ID}}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this ingredient?"
class="delete-btn">
Delete
</button> </button>
{{end}}
</div>
<div id="active-filters" class="active-filters"></div>
<button onclick="clearFilters()" class="clear-filters-btn">Clear Filters</button>
</div>
<div id="ingredients-list" class="items-list">
{{range .Ingredients}}
<div class="item" id="ingredient-{{.ID}}">
<div class="item-content">
<span class="item-name">{{.Name}}</span>
<span class="item-unit">({{.Unit}})</span>
<div class="item-tags">
{{range .Tags}}
<span class="tag">{{.Name}}</span>
{{end}}
</div>
</div>
<div class="item-actions">
<button
hx-get="/ingredients/{{.ID}}/tags"
hx-target="#tag-modal-content"
hx-swap="innerHTML"
onclick="document.getElementById('tag-modal').style.display='block'"
class="edit-tags-btn">
Edit Tags
</button>
<button
hx-delete="/ingredients/{{.ID}}"
hx-target="#ingredient-{{.ID}}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this ingredient?"
class="delete-btn">
Delete
</button>
</div>
</div> </div>
{{end}} {{end}}
</div> </div>
</div> </div>
<!-- Tag Edit Modal -->
<div id="tag-modal" class="modal">
<div class="modal-content">
<span class="close" onclick="document.getElementById('tag-modal').style.display='none'">&times;</span>
<div id="tag-modal-content">
<!-- Content loaded via HTMX -->
</div>
</div>
</div>
<script>
let activeFilters = new Set();
// Listen for tag changes and refresh ingredients
document.body.addEventListener('refreshIngredients', function() {
// Close the modal first
document.getElementById('tag-modal').style.display='none';
// Then refresh the page
htmx.ajax('GET', '/ingredients', {target: '#ingredients-content', swap: 'innerHTML'});
});
function toggleTagFilter(tagName) {
if (activeFilters.has(tagName)) {
activeFilters.delete(tagName);
} else {
activeFilters.add(tagName);
}
updateFilters();
}
function clearFilters() {
activeFilters.clear();
updateFilters();
}
function updateFilters() {
// Update active filters display
const activeFiltersDiv = document.getElementById('active-filters');
if (activeFilters.size > 0) {
activeFiltersDiv.innerHTML = 'Active filters: ' + Array.from(activeFilters).join(', ');
} else {
activeFiltersDiv.innerHTML = '';
}
// Update filter button states
document.querySelectorAll('.tag-filter-btn').forEach(btn => {
if (activeFilters.has(btn.textContent.trim())) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
// Reload ingredients with filters
const url = activeFilters.size > 0
? '/ingredients?tags=' + Array.from(activeFilters).join(',')
: '/ingredients';
htmx.ajax('GET', url, {target: '#ingredients-content', swap: 'innerHTML'});
}
</script>
` `
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 := template.Must(template.New("ingredients").Parse(tmpl))
t.Execute(w, ingredients) t.Execute(w, data)
} }
// AddIngredientHandler handles adding a new ingredient // AddIngredientHandler handles adding a new ingredient
@@ -62,6 +199,7 @@ func AddIngredientHandler(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(r.FormValue("name")) name := strings.TrimSpace(r.FormValue("name"))
unit := strings.TrimSpace(r.FormValue("unit")) unit := strings.TrimSpace(r.FormValue("unit"))
tagsStr := strings.TrimSpace(r.FormValue("tags"))
if name == "" || unit == "" { if name == "" || unit == "" {
http.Error(w, "Name and unit are required", http.StatusBadRequest) http.Error(w, "Name and unit are required", http.StatusBadRequest)
@@ -74,18 +212,54 @@ func AddIngredientHandler(w http.ResponseWriter, r *http.Request) {
return 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 := ` tmpl := `
<div class="item" id="ingredient-{{.ID}}"> <div class="item" id="ingredient-{{.ID}}">
<span class="item-name">{{.Name}}</span> <div class="item-content">
<span class="item-unit">({{.Unit}})</span> <span class="item-name">{{.Name}}</span>
<button <span class="item-unit">({{.Unit}})</span>
hx-delete="/ingredients/{{.ID}}" <div class="item-tags">
hx-target="#ingredient-{{.ID}}" {{range .Tags}}
hx-swap="outerHTML" <span class="tag">{{.}}</span>
hx-confirm="Are you sure you want to delete this ingredient?" {{end}}
class="delete-btn"> </div>
Delete </div>
</button> <div class="item-actions">
<button
hx-get="/ingredients/{{.ID}}/tags"
hx-target="#tag-modal-content"
hx-swap="innerHTML"
onclick="document.getElementById('tag-modal').style.display='block'"
class="edit-tags-btn">
Edit Tags
</button>
<button
hx-delete="/ingredients/{{.ID}}"
hx-target="#ingredient-{{.ID}}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this ingredient?"
class="delete-btn">
Delete
</button>
</div>
</div> </div>
` `
@@ -93,7 +267,8 @@ func AddIngredientHandler(w http.ResponseWriter, r *http.Request) {
ID int64 ID int64
Name string Name string
Unit string Unit string
}{id, name, unit} Tags []string
}{id, name, unit, tags}
t := template.Must(template.New("ingredient").Parse(tmpl)) t := template.Must(template.New("ingredient").Parse(tmpl))
t.Execute(w, data) t.Execute(w, data)
@@ -117,3 +292,224 @@ func DeleteIngredientHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) 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 := `
<h3>Edit Tags</h3>
<div class="tag-edit-section">
<h4>Current Tags</h4>
<div id="current-tags-{{.IngredientID}}">
{{range .CurrentTags}}
<span class="tag">
{{.Name}}
<button
hx-delete="/ingredients/{{$.IngredientID}}/tags/{{.ID}}"
hx-target="#current-tags-{{$.IngredientID}}"
hx-swap="innerHTML"
class="tag-remove">
×
</button>
</span>
{{end}}
</div>
<h4>Add Tags</h4>
<form hx-post="/ingredients/{{.IngredientID}}/tags"
hx-target="#current-tags-{{.IngredientID}}"
hx-swap="innerHTML"
class="add-tag-form">
<input type="text" name="tag_name" placeholder="New tag name" list="existing-tags" required />
<datalist id="existing-tags">
{{range .AllTags}}
{{if not (index $.CurrentTagIDs .ID)}}
<option value="{{.Name}}">
{{end}}
{{end}}
</datalist>
<button type="submit">Add Tag</button>
</form>
</div>
`
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}}
<span class="tag">
{{.Name}}
<button
hx-delete="/ingredients/{{$.IngredientID}}/tags/{{.ID}}"
hx-target="#current-tags-{{$.IngredientID}}"
hx-swap="innerHTML"
class="tag-remove">
×
</button>
</span>
{{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)
}

21
main.go
View File

@@ -63,10 +63,25 @@ func main() {
} }
})) }))
http.HandleFunc("/ingredients/", auth.RequireAuth(func(w http.ResponseWriter, r *http.Request) { http.HandleFunc("/ingredients/", auth.RequireAuth(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" { path := r.URL.Path
handlers.DeleteIngredientHandler(w, r) 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 { } 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)
}
} }
})) }))

View File

@@ -24,6 +24,20 @@ type Ingredient struct {
UserID int `json:"user_id"` UserID int `json:"user_id"`
Name string `json:"name"` Name string `json:"name"`
Unit string `json:"unit"` // e.g., "grams", "ml", "cups", "pieces", "tbsp" 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 // Meal represents a meal recipe

View File

@@ -460,6 +460,201 @@ button:hover {
font-style: italic; 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 */ /* Authentication Pages */
.auth-container { .auth-container {
min-height: 100vh; min-height: 100vh;