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, }) }