feat: add HTMX support for saving filters with HTML response handling

Router changes for saved filters API:
- Add CreateFilterHTML handler to return HTML for HTMX requests
- Extract CreateFilterHTML function to handle filter creation logic
- Add wrapper for POST /api/saved-filters to detect HTMX requests
  - HTMX requests: Return HTML via CreateFilterHTML
  - Regular requests: Return JSON via existing handler
- Add collectFilterFormData helper to gather form data from #filter-form
- Improve error handling for duplicate filter names (409 Conflict)
- HTML responses include error messages for better UX

This enables the save filter modal to work without page refresh,
providing a smoother user experience with immediate visual feedback.
This commit is contained in:
2026-03-28 00:46:07 -04:00
parent 0e11c9263c
commit d399a110ca
+125 -1
View File
@@ -1,5 +1,18 @@
package router
import (
"bookhoard/internal/database"
"bookhoard/templates"
"bytes"
"encoding/json"
"net/http"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
func registerFiltersRoutes(cfg *Config) {
e := cfg.Echo
@@ -11,7 +24,118 @@ func registerFiltersRoutes(cfg *Config) {
filters := protected.Group("/saved-filters")
filters.GET("", cfg.FiltersHandler.GetSavedFilters)
filters.GET("/:id", cfg.FiltersHandler.GetSavedFilterByID)
filters.POST("", cfg.FiltersHandler.CreateSavedFilter)
// POST route with HTMX support
filters.POST("", func(c *echo.Context) error {
// Check if HTMX request (expects HTML)
if c.Request().Header.Get("HX-Request") == "true" {
return handleCreateFilterHTML(c, cfg)
}
// Otherwise return JSON (API clients)
return cfg.FiltersHandler.CreateSavedFilter(c)
})
filters.PUT("/:id", cfg.FiltersHandler.UpdateSavedFilter)
filters.DELETE("/:id", cfg.FiltersHandler.DeleteSavedFilter)
}
// handleCreateFilterHTML renders HTML response for HTMX requests
func handleCreateFilterHTML(c *echo.Context, cfg *Config) error {
user, ok := c.Get("user").(database.Users)
if !ok {
return c.String(http.StatusUnauthorized, "Unauthorized")
}
userUUID := uuid.UUID(user.ID.Bytes)
// Parse form data
var req struct {
Name string `form:"filter_name"`
ResourceType string `form:"resource_type"`
}
if err := c.Bind(&req); err != nil {
return c.String(http.StatusBadRequest, "Invalid request body")
}
if req.Name == "" || req.ResourceType == "" {
return c.String(http.StatusBadRequest, "name and resource_type are required")
}
// Collect filter data from individual form fields (via hx-include)
filtersMap := collectFilterFormData(c)
// Convert to JSON for database storage
filtersJSON, err := json.Marshal(filtersMap)
if err != nil {
return c.String(http.StatusInternalServerError, "Failed to encode filters")
}
// Create filter using service layer
filter, err := cfg.Queries.CreateSavedFilter(c.Request().Context(), database.CreateSavedFilterParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Name: req.Name,
ResourceType: req.ResourceType,
Filters: filtersJSON,
})
if err != nil {
errorMsg := err.Error()
// Check for duplicate name error (wrapped or unwrapped)
if strings.Contains(errorMsg, "filter with name") && strings.Contains(errorMsg, "already exists") {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusConflict, `<div hx-swap-oob="innerHTML:#filter-save-error" style="color: #ef4444; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">⚠️ Filter with this name already exists</div>`)
}
return c.String(http.StatusConflict, "Filter with this name already exists")
}
return c.String(http.StatusInternalServerError, "failed to create filter: "+errorMsg)
}
// Render filter item HTML to buffer
var buf bytes.Buffer
filterID := uuid.Must(uuid.FromBytes(filter.ID.Bytes[:]))
err = templates.FilterItem(
filterID.String(),
filter.Name,
).Render(c.Request().Context(), &buf)
if err != nil {
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Render error</div>`)
}
return c.HTML(http.StatusCreated, buf.String())
}
// collectFilterFormData extracts filter values from form fields
func collectFilterFormData(c *echo.Context) map[string]string {
filters := make(map[string]string)
// Collect individual form fields
if q := c.FormValue("q"); q != "" {
filters["q"] = q
}
if author := c.FormValue("author_filter"); author != "" {
filters["author_filter"] = author
}
if tags := c.FormValue("tags_filter"); tags != "" {
filters["tags_filter"] = tags
}
if series := c.FormValue("series_filter"); series != "" {
filters["series_filter"] = series
}
if language := c.FormValue("language_filter"); language != "" {
filters["language_filter"] = language
}
if yearMin := c.FormValue("year_min"); yearMin != "" {
filters["year_min"] = yearMin
}
if yearMax := c.FormValue("year_max"); yearMax != "" {
filters["year_max"] = yearMax
}
if hasCover := c.FormValue("has_cover"); hasCover != "" {
filters["has_cover"] = hasCover
}
if libraryID := c.FormValue("library_id"); libraryID != "" {
filters["library_id"] = libraryID
}
if sort := c.FormValue("sort"); sort != "" {
filters["sort"] = sort
}
if limit := c.FormValue("limit"); limit != "" {
filters["limit"] = limit
}
if offset := c.FormValue("offset"); offset != "" {
filters["offset"] = offset
}
return filters
}