feat(router): implement dual-mode search endpoint (HTML/JSON)

Rewrite /api/media-items/search endpoint to detect HTMX requests and return appropriate response format. The endpoint now checks for HX-Request header and routes to HTML renderer or JSON handler accordingly.

- Check HX-Request header to detect HTMX requests
- Return HTML via BooksGrid template for HTMX requests
- Return JSON for API clients (existing behavior)
- Add handleSearchHTML function for HTML rendering
- Use shared MediaHandler.ExecuteSearch method
- Eliminates previous issue where JSON was rendered in browser
This commit is contained in:
2026-03-27 14:50:36 -04:00
parent b1446f15f8
commit 417685e9a7
+116 -3
View File
@@ -1,13 +1,126 @@
package router
import (
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/internal/services"
"bookhoard/internal/utils"
"bookhoard/templates"
"bytes"
"log"
"net/http"
"strconv"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
func registerSearchRoutes(cfg *Config) {
e := cfg.Echo
// JWT middleware for protected routes
jwtMiddleware := createJWTMiddleware(cfg)
protected := e.Group("/api", jwtMiddleware)
// Search endpoint - returns JSON or HTML based on HX-Request header
protected.GET("/media-items/search", func(c *echo.Context) error {
// Check if HTMX request (expects HTML)
if c.Request().Header.Get("HX-Request") == "true" {
return handleSearchHTML(c, cfg)
}
// Search and query endpoints (all authenticated users)
protected.GET("/media-items/search", cfg.MediaHandler.SearchMediaItems)
// Otherwise return JSON (API clients)
return cfg.MediaHandler.SearchMediaItems(c)
})
protected.POST("/sync/books/query", cfg.MatchingHandler.QueryBooks)
}
// handleSearchHTML renders HTML response for HTMX requests
func handleSearchHTML(c *echo.Context, cfg *Config) error {
user, ok := c.Get("user").(database.Users)
if !ok {
return c.String(http.StatusUnauthorized, "Unauthorized")
}
// Extract query parameters
query := c.QueryParam("q")
libraryID := c.QueryParam("library_id")
limit, _ := strconv.Atoi(c.QueryParam("limit"))
if limit == 0 {
limit = 50
}
offset, _ := strconv.Atoi(c.QueryParam("offset"))
// Validate library_id
var libUUID pgtype.UUID
if libraryID != "" {
lib, err := uuid.Parse(libraryID)
if err != nil {
return c.String(http.StatusBadRequest, "invalid library_id")
}
libUUID = pgtype.UUID{Bytes: lib, Valid: true}
} else {
libUUID = pgtype.UUID{Valid: false}
}
// Extract filters
authorFilter := c.QueryParam("author_filter")
seriesFilter := c.QueryParam("series_filter")
genreFilter := c.QueryParam("genre_filter")
tagsFilter := c.QueryParam("tags_filter")
languageFilter := c.QueryParam("language_filter")
yearMin, _ := strconv.Atoi(c.QueryParam("year_min"))
yearMax, _ := strconv.Atoi(c.QueryParam("year_max"))
hasCover := c.QueryParam("has_cover") == "true"
sortParam := c.QueryParam("sort")
if sortParam == "" {
sortParam = "title ASC"
}
// Build search params
params := services.SearchParams{
UserID: user.ID,
LibraryID: libUUID,
SearchQuery: query,
AuthorFilter: authorFilter,
SeriesFilter: seriesFilter,
GenreFilter: genreFilter,
TagsFilter: tagsFilter,
LanguageFilter: languageFilter,
YearMin: yearMin,
YearMax: yearMax,
HasCover: hasCover,
Sort: sortParam,
Limit: limit,
Offset: offset,
}
// Execute search using handler method
results, totalCount, err := cfg.MediaHandler.ExecuteSearch(c.Request().Context(), params)
if err != nil {
log.Printf("Search error: %v", err)
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Search error</div>`)
}
// Convert to BookInfo
bookInfoList := make([]handlers.BookInfo, len(results))
for i, book := range results {
bookUUID, _ := uuid.FromBytes(book.ID.Bytes[0:16])
bookLibUUID, _ := uuid.FromBytes(book.LibraryID.Bytes[0:16])
bookInfoList[i] = handlers.BookInfo{
MediaItemID: bookUUID.String(),
Title: book.Title,
Author: textToString(book.Author),
CoverImagePath: utils.ResolveMediaURL(pgtype.UUID{Bytes: bookLibUUID, Valid: true}, book.CoverImagePath),
}
}
// Render using BooksGrid template
var buf bytes.Buffer
err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf)
if err != nil {
log.Printf("Template render error: %v", err)
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Render error</div>`)
}
return c.HTML(http.StatusOK, buf.String())
}
// textToString converts pgtype.Text to string
func textToString(t pgtype.Text) string {
if t.Valid {
return t.String
}
return ""
}