From 417685e9a79da81b4276e9d50ba4688585dc4311 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 27 Mar 2026 14:50:36 -0400 Subject: [PATCH] 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 --- internal/router/search.go | 119 +++++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 3 deletions(-) diff --git a/internal/router/search.go b/internal/router/search.go index 80489cb..2b3eb28 100644 --- a/internal/router/search.go +++ b/internal/router/search.go @@ -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, `
Search error
`) + } + // 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, `
Render error
`) + } + 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 "" +}