added filter and tag for ingredients
This commit is contained in:
@@ -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) {
|
||||
<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="unit" placeholder="Unit (e.g., grams, cups)" required />
|
||||
<input type="text" name="tags" placeholder="Tags (comma-separated)" />
|
||||
<button type="submit">Add Ingredient</button>
|
||||
</form>
|
||||
|
||||
<div id="ingredients-list" class="items-list">
|
||||
{{range .}}
|
||||
<div class="item" id="ingredient-{{.ID}}">
|
||||
<span class="item-name">{{.Name}}</span>
|
||||
<span class="item-unit">({{.Unit}})</span>
|
||||
<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
|
||||
<div class="search-section">
|
||||
<h3>Filter by Tags</h3>
|
||||
<div class="tag-filter">
|
||||
{{range .AllTags}}
|
||||
<button class="tag-filter-btn"
|
||||
onclick="toggleTagFilter('{{.Name}}')">
|
||||
{{.Name}}
|
||||
</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>
|
||||
{{end}}
|
||||
</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'">×</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.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 := `
|
||||
<div class="item" id="ingredient-{{.ID}}">
|
||||
<span class="item-name">{{.Name}}</span>
|
||||
<span class="item-unit">({{.Unit}})</span>
|
||||
<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 class="item-content">
|
||||
<span class="item-name">{{.Name}}</span>
|
||||
<span class="item-unit">({{.Unit}})</span>
|
||||
<div class="item-tags">
|
||||
{{range .Tags}}
|
||||
<span class="tag">{{.}}</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>
|
||||
`
|
||||
|
||||
@@ -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 := `
|
||||
<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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user