Files
bookhoard/internal/router/search.go
T
john-okeefe 816ee0ec80 fix(ui): wire up collection detail page interactions
The /collections/:id page had several broken features because three
referenced functions (removeBook, toggleBookForRemoval,
filterCollectionBooks) were never defined, and every book card was
wrapped in <a href="/media/..."> so clicking the checkbox or remove
button navigated to the book detail page instead.

Card restructure:
- Remove the <a> wrapper; title and cover are now individual links.
- Checkbox sits in a <label> with expanded click area (p-2 -m-2).
- Checkbox uses Alpine :checked/@change bound to a reactive
  selectedBooks array on the collections component.

Remove (single + bulk):
- Add removeBook(id) and bulkRemove() methods with confirm() dialogs.
- Wire the "Remove Selected" button with :disabled binding and @click.
- Selected-count badge is now Alpine-reactive (x-show/x-text).

Search within collection:
- Add filterCollectionBooks() that filters cards client-side by
  title/author via data-* attributes and @input.

Book picker ("Add Books"):
- Point the HTMX search inputs at the existing /api/media-items/search
  endpoint instead of the non-existent /api/media-items/filtered.
- Add hx-trigger="loadBooks" + hx-get to the grid so loadBooks()
  actually fires an initial request when the picker opens.
- Merge the hidden limit/offset inputs into the #book-picker-filters
  div so hx-include picks them up (was a separate <form id=filter-form>
  that nobody referenced).
- Add show_checkbox mode to handleSearchHTML: when present, render a
  new BookPickerGrid template with clickable, selectable cards instead
  of the reader BookCard.
- Fix bookPicker submit() to location.reload() instead of a non-existent
  reloadCollection HTMX event, and clearFilters() to target text inputs.
2026-08-06 10:40:18 -04:00

138 lines
4.4 KiB
Go

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)
}
// 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 := false
hasCoverValid := false
if _, exists := c.QueryParams()["has_cover"]; exists {
hasCover = c.QueryParam("has_cover") == "true"
hasCoverValid = 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: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid},
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),
}
}
// 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, `<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 ""
}