package router import ( "bookhoard/internal/database" "bookhoard/internal/handlers" "bookhoard/templates" "bytes" "log" "net/http" "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) } // Otherwise return JSON (API clients) return cfg.MediaHandler.SearchMediaItems(c) }) // Facet-value listings for the filter panel (faceted-navigation // Batch C): /api/media-items/facets?facet=X&library_id=... protected.GET("/media-items/facets", cfg.MediaHandler.GetFacets) 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 filters via the SHARED parser (handlers.ParseMediaSearchParams) // — the HTMX grid partial and the JSON endpoint can never disagree on // which facet params exist or how they parse. (This branch previously // dropped publisher_filter and narrator_filter entirely — the shared // parser fixes that too.) params := handlers.ParseMediaSearchParams(c) params.UserID = user.ID limit := params.Limit offset := params.Offset libraryID := c.QueryParam("library_id") // Family-lens gate: an explicit library_id outside the requester's // visible set is a 403 (same rule as the JSON branch). if params.LibraryID.Valid { _, ids, err := cfg.LibraryService.VisibleLibrarySet(c.Request().Context(), user.ID) if err != nil { log.Printf("VisibleLibrarySet failed: %v", err) return c.HTML(http.StatusInternalServerError, `
Search error
`) } visible := false for _, id := range ids { if id == params.LibraryID { visible = true break } } if !visible { return c.HTML(http.StatusForbidden, `
Library not visible
`) } } // 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 := handlers.BookCardsFromUnified(results) // Stamp active conflict flags so cards route the play action correctly bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, user.ID, bookInfoList) // Render using BooksGrid template (or BookPickerGrid for collection picker) var buf bytes.Buffer if c.QueryParam("show_checkbox") == "true" { err = templates.BookPickerGrid(bookInfoList).Render(c.Request().Context(), &buf) } else { 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()) }