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:
+116
-3
@@ -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 ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user