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.
142 lines
4.5 KiB
Go
142 lines
4.5 KiB
Go
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
|
|
|
|
// JWT middleware for protected routes
|
|
jwtMiddleware := createJWTMiddleware(cfg)
|
|
protected := e.Group("/api", jwtMiddleware)
|
|
|
|
// Saved filters routes
|
|
filters := protected.Group("/saved-filters")
|
|
filters.GET("", cfg.FiltersHandler.GetSavedFilters)
|
|
filters.GET("/:id", cfg.FiltersHandler.GetSavedFilterByID)
|
|
// 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
|
|
}
|