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.
296 lines
8.9 KiB
Go
296 lines
8.9 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/services"
|
|
"bookhoard/internal/utils"
|
|
"context"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/labstack/echo/v5"
|
|
)
|
|
|
|
type DashboardHandler struct {
|
|
db *database.Queries
|
|
dashboardService *services.DashboardService
|
|
}
|
|
|
|
func NewDashboardHandler(db *database.Queries) *DashboardHandler {
|
|
return &DashboardHandler{
|
|
db: db,
|
|
dashboardService: services.NewDashboardService(db),
|
|
}
|
|
}
|
|
|
|
func (h *DashboardHandler) GetSections(c *echo.Context) error {
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
|
|
libraryID := c.QueryParam("library_id")
|
|
var libUUID pgtype.UUID
|
|
if libraryID != "" {
|
|
parsed, err := uuid.Parse(libraryID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
|
}
|
|
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
|
}
|
|
|
|
prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
|
|
|
limit := 20
|
|
if prefs.ItemsPerSection.Valid {
|
|
limit = int(prefs.ItemsPerSection.Int32)
|
|
}
|
|
if limitStr := c.QueryParam("limit"); limitStr != "" {
|
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
|
limit = l
|
|
}
|
|
}
|
|
|
|
sections, err := h.dashboardService.GetDashboardSections(
|
|
c.Request().Context(),
|
|
userUUID,
|
|
libUUID,
|
|
limit,
|
|
prefs.CollectionOrder,
|
|
prefs.HiddenCollections,
|
|
)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load dashboard sections"})
|
|
}
|
|
|
|
sectionData := BuildSections(sections, libraryID)
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData})
|
|
}
|
|
|
|
func (h *DashboardHandler) UpdatePreferences(c *echo.Context) error {
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
|
|
var req struct {
|
|
LibraryID string `json:"library_id"`
|
|
HiddenCollections []string `json:"hidden_collections"`
|
|
CollectionOrder []string `json:"collection_order"`
|
|
ItemsPerSection int `json:"items_per_section"`
|
|
}
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
|
}
|
|
|
|
libUUID, err := uuid.Parse(req.LibraryID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
|
}
|
|
|
|
// Sanitize preferences (remove duplicates)
|
|
cleanHidden, cleanOrder := h.dashboardService.SanitizeDashboardPreferences(req.HiddenCollections, req.CollectionOrder)
|
|
req.HiddenCollections = cleanHidden
|
|
req.CollectionOrder = cleanOrder
|
|
|
|
prefs, err := h.dashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
|
HiddenCollections: req.HiddenCollections,
|
|
CollectionOrder: req.CollectionOrder,
|
|
ItemsPerSection: pgtype.Int4{Int32: int32(req.ItemsPerSection), Valid: true},
|
|
})
|
|
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to save preferences"})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, prefs)
|
|
}
|
|
|
|
func (h *DashboardHandler) RestoreSystemCollection(c *echo.Context) error {
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
|
|
var req struct {
|
|
CollectionName string `form:"collection_name" json:"collection_name"`
|
|
ResetType string `form:"reset_type" json:"reset_type"`
|
|
}
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
|
}
|
|
|
|
if req.CollectionName == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "collection_name required"})
|
|
}
|
|
|
|
if req.ResetType == "" {
|
|
req.ResetType = "full"
|
|
}
|
|
|
|
validCollections := map[string]bool{
|
|
"Continue Reading": true,
|
|
"Recently Added": true,
|
|
"Recently Read": true,
|
|
"Not Started": true,
|
|
"Continue Series": true,
|
|
}
|
|
if !validCollections[req.CollectionName] {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid system collection name"})
|
|
}
|
|
|
|
if req.ResetType != "full" && req.ResetType != "keep_books" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "reset_type must be 'full' or 'keep_books'"})
|
|
}
|
|
|
|
err := h.dashboardService.RestoreSystemCollection(c.Request().Context(), userUUID, req.CollectionName, req.ResetType)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to restore system collection"})
|
|
}
|
|
|
|
// Add redirect header for HTMX requests
|
|
if c.Request().Header.Get("HX-Request") == "true" {
|
|
c.Response().Header().Set("HX-Redirect", "/collections")
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{"message": "System collection restored to defaults"})
|
|
}
|
|
|
|
func BuildSections(sections []services.DashboardSection, currentLibraryID string) []SectionData {
|
|
var result []SectionData
|
|
|
|
for _, ds := range sections {
|
|
bookCards := make([]BookInfo, len(ds.Items))
|
|
for i, item := range ds.Items {
|
|
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
|
bookCards[i] = BookInfo{
|
|
MediaItemID: itemUUID.String(),
|
|
Title: item.Title,
|
|
Author: textToString(item.Author),
|
|
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
|
}
|
|
}
|
|
|
|
result = append(result, SectionData{
|
|
ID: ds.CollectionName,
|
|
CollectionID: ds.CollectionID.String(),
|
|
IsSystem: ds.IsSystem,
|
|
Title: ds.Title,
|
|
Description: ds.Description,
|
|
Icon: ds.Icon,
|
|
Items: bookCards,
|
|
ViewAllURL: getViewAllURL(ds.CollectionID.String(), currentLibraryID),
|
|
Priority: ds.Priority,
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// activeConflictSet returns the set of media item IDs (as strings) that have an
|
|
// active (unresolved) progress sync conflict for the given user. A single query
|
|
// is issued; resolved conflicts are filtered out in memory.
|
|
func activeConflictSet(ctx context.Context, db *database.Queries, userID pgtype.UUID) map[string]bool {
|
|
conflicts, err := db.ListSyncConflictsByUser(ctx, userID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
set := make(map[string]bool, len(conflicts))
|
|
for _, c := range conflicts {
|
|
if c.ResolutionStatus.String == "unresolved" {
|
|
set[uuid.UUID(c.MediaItemID.Bytes).String()] = true
|
|
}
|
|
}
|
|
return set
|
|
}
|
|
|
|
// MarkActiveConflicts stamps HasConflict on each book whose media item has an
|
|
// active progress sync conflict for the user. It performs a single query
|
|
// regardless of how many books are passed.
|
|
func MarkActiveConflicts(ctx context.Context, db *database.Queries, userID pgtype.UUID, books []BookInfo) []BookInfo {
|
|
if len(books) == 0 {
|
|
return books
|
|
}
|
|
set := activeConflictSet(ctx, db, userID)
|
|
for i := range books {
|
|
if set[books[i].MediaItemID] {
|
|
books[i].HasConflict = true
|
|
}
|
|
}
|
|
return books
|
|
}
|
|
|
|
// MarkActiveConflictsSections is the section-aware variant of MarkActiveConflicts,
|
|
// used by the dashboard which renders books grouped into sections.
|
|
func MarkActiveConflictsSections(ctx context.Context, db *database.Queries, userID pgtype.UUID, sections []SectionData) []SectionData {
|
|
if len(sections) == 0 {
|
|
return sections
|
|
}
|
|
set := activeConflictSet(ctx, db, userID)
|
|
if len(set) == 0 {
|
|
return sections
|
|
}
|
|
for s := range sections {
|
|
for i := range sections[s].Items {
|
|
if set[sections[s].Items[i].MediaItemID] {
|
|
sections[s].Items[i].HasConflict = true
|
|
}
|
|
}
|
|
}
|
|
return sections
|
|
}
|
|
|
|
func getViewAllURL(collectionID string, libraryID string) string {
|
|
if collectionID != "" {
|
|
if libraryID != "" {
|
|
return "/collections/" + collectionID + "?library_id=" + libraryID
|
|
}
|
|
return "/collections/" + collectionID
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (h *DashboardHandler) GetPreferences(c *echo.Context) error {
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
libraryID := c.QueryParam("library_id")
|
|
|
|
var libUUID pgtype.UUID
|
|
if libraryID != "" {
|
|
parsed, err := uuid.Parse(libraryID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
|
}
|
|
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
|
}
|
|
|
|
prefs, err := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
|
if err != nil {
|
|
// Return default preferences instead of 404 when none exist
|
|
log.Printf("GetDashboardPreferences failed: %v", err)
|
|
prefs = database.UserDashboardPreferences{
|
|
HiddenCollections: []string{},
|
|
CollectionOrder: []string{},
|
|
ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true},
|
|
}
|
|
}
|
|
|
|
// Normalize nil slices to empty arrays for JSON
|
|
hiddenCollections := prefs.HiddenCollections
|
|
if hiddenCollections == nil {
|
|
hiddenCollections = []string{}
|
|
}
|
|
collectionOrder := prefs.CollectionOrder
|
|
if collectionOrder == nil {
|
|
collectionOrder = []string{}
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"hidden_collections": hiddenCollections,
|
|
"collection_order": collectionOrder,
|
|
"items_per_section": prefs.ItemsPerSection.Int32,
|
|
})
|
|
}
|