The play button on book cards now opens the reader directly, instead of always going to the detail page. Cards with an active progress sync conflict route the play button to the detail page (which hosts the conflict dialogue and resolves before writing progress), so the user is never silently dropped into the reader with an unresolved conflict. Backend: - Add HasConflict to BookInfo and stamp it via ListSyncConflictsByUser (MarkActiveConflicts / MarkActiveConflictsSections) on the dashboard, bookshelf, series, tag, and search result card builders. - Each page issues a single conflict query regardless of card count. BookCard: - Restructure into a detail link (cover + meta) with the play action as a sibling overlay using a pointer-events split: the container passes clicks through to detail while only the circular button routes to the reader. No nested anchors. - On touch devices (hover: none) the play button stays visible. Fix: carousel nav buttons had opacity-0 without pointer-events-none, so they swallowed hover/clicks over book cards on the dashboard. They are now click-through until the carousel is hovered.
134 lines
4.2 KiB
Go
134 lines
4.2 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
|
|
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 ""
|
|
}
|