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.
138 lines
4.4 KiB
Go
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 ""
|
|
}
|