From 0b666f3fdd19e4a3259b481c286fd5eaa5ab4157 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 1 Mar 2026 00:28:54 -0500 Subject: [PATCH] feat: Add collection detail page with /collections/:id route Add comprehensive collection detail page that works for both system collections (continue-reading, recently-added, not-started) and user collections. Backend changes: - Add new /collections/:id route in internal/router/frontend.go - Fetches collection using GetCollection with UUID parameter - Determines collection type from QueryType field - Resolves library_id for system collections - Converts database.MediaItems to handlers.BookInfo for display - Renders CollectionDetail template with collection and books data - Update SectionData struct in internal/handlers/collections.go - Add CollectionID string field for view all links - Update BuildSections() in internal/handlers/dashboard.go - Pass CollectionID to SectionData for proper link generation - Simplify getViewAllURL() in internal/handlers/dashboard.go - Return /collections/{collectionID} instead of /section/{type} - Works uniformly for both system and user collections Frontend changes: - Fix CollectionDetail template in templates/collections.templ - Fix broken div nesting causing compilation error - Add null check for CoverImagePath to prevent broken images - Update aspect ratio to modern aspect-[3/4] syntax - Use responsive widths (w-16 sm:w-20) for mobile/desktop - Improve card layout with horizontal flex structure - Add placeholder image fallback for books without covers - Remove erroneous renderBooks() function call This change aligns with the backend update where system collections are now pre-made user collections in the database with query_type fields. All collections can now use the same CollectionDetail template for a consistent viewing experience. --- internal/handlers/collections.go | 17 +- internal/handlers/dashboard.go | 62 ++- internal/router/frontend.go | 128 ++++++- internal/services/dashboard_service.go | 28 +- templates/collections.templ | 500 ++++++++++++++----------- templates/collections_templ.go | 20 +- 6 files changed, 481 insertions(+), 274 deletions(-) diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index e9011ea..0069ddf 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -75,14 +75,15 @@ type BookInfo struct { } type SectionData struct { - ID string `json:"id"` - IsSystem bool `json:"is_system"` - Title string `json:"title"` - Description string `json:"description"` - Icon string `json:"icon"` - Items []BookInfo `json:"items"` - ViewAllURL string `json:"view_all_url"` - Priority int `json:"priority"` + ID string `json:"id"` + CollectionID string `json:"collection_id"` + IsSystem bool `json:"is_system"` + Title string `json:"title"` + Description string `json:"description"` + Icon string `json:"icon"` + Items []BookInfo `json:"items"` + ViewAllURL string `json:"view_all_url"` + Priority int `json:"priority"` } func (h *CollectionHandler) CreateCollection(c echo.Context) error { diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index 2f53b82..50838cf 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -40,6 +40,9 @@ func (h *DashboardHandler) GetSections(c echo.Context) error { 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 @@ -83,6 +86,11 @@ func (h *DashboardHandler) UpdatePreferences(c echo.Context) error { 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}, @@ -157,29 +165,49 @@ func BuildSections(sections []services.DashboardSection) []SectionData { } result = append(result, SectionData{ - ID: ds.CollectionName, - IsSystem: ds.IsSystem, - Title: ds.Title, - Description: ds.Description, - Icon: ds.Icon, - Items: bookCards, - ViewAllURL: getViewAllURL(ds.CollectionName, ds.QueryType), - Priority: ds.Priority, + 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()), + Priority: ds.Priority, }) } return result } -func getViewAllURL(key, queryType string) string { - urls := map[string]string{ - "continue-reading": "/section/continue-reading", - "recently-added": "/section/recently-added", - "recently-read": "/history", - "not-started": "/section/not-started", - } - if url, exists := urls[queryType]; exists { - return url +func getViewAllURL(collectionID string) string { + if collectionID != "" { + 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") + + // ✅ Add validation + if libraryID == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"}) + } + + libUUID, err := uuid.Parse(libraryID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"}) + } + prefs, err := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "Preferences not found"}) + } + return c.JSON(http.StatusOK, map[string]interface{}{ + "hidden_collections": prefs.HiddenCollections, + "collection_order": prefs.CollectionOrder, + "items_per_section": prefs.ItemsPerSection.Int32, + }) +} diff --git a/internal/router/frontend.go b/internal/router/frontend.go index c990327..d55c911 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -10,6 +10,7 @@ import ( "bookhoard/internal/database" "bookhoard/internal/handlers" "bookhoard/internal/services" + "bookhoard/internal/utils" "bookhoard/templates" "github.com/golang-jwt/jwt/v5" @@ -137,27 +138,38 @@ func registerFrontendRoutes(cfg *Config) { libUUID, _ := uuid.Parse(libraryID) userUUID, _ := uuid.Parse(user.ID) - prefs, _ := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID) - + prefs, err := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID) + if err != nil { + log.Printf("GetDashboardPreferences failed: %v", err) + prefs = database.UserDashboardPreferences{ + HiddenCollections: []string{}, + CollectionOrder: []string{}, + ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true}, + } + } limit := 20 if prefs.ItemsPerSection.Int32 > 0 { limit = int(prefs.ItemsPerSection.Int32) } - var sections []services.DashboardSection - sections, err = cfg.DashboardService.GetDashboardSections( + + // Get ALL sections (unfiltered) for the modal + allSections, err := cfg.DashboardService.GetDashboardSections( c.Request().Context(), userUUID, libUUID, limit, prefs.CollectionOrder, - prefs.HiddenCollections, + []string{}, // No filtering - get all sections ) if err != nil { log.Printf("Dashboard sections query failed: %v", err) - sections = []services.DashboardSection{} + allSections = []services.DashboardSection{} errorMsg = "Error loading dashboard" } + // Get only visible sections for the dashboard display + visibleSections := cfg.DashboardService.FilterHiddenCollections(allSections, prefs.HiddenCollections) + userUUID2, _ := uuid.Parse(user.ID) libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID2)) if err != nil { @@ -179,10 +191,11 @@ func registerFrontendRoutes(cfg *Config) { } } - sectionData := handlers.BuildSections(sections) + sectionData := handlers.BuildSections(visibleSections) + allSectionsData := handlers.BuildSections(allSections) var buf bytes.Buffer - err = templates.Dashboard(user, sectionData, libData, libraryID, errorMsg).Render(c.Request().Context(), &buf) + err = templates.Dashboard(user, sectionData, allSectionsData, libData, libraryID, prefs.HiddenCollections, limit, errorMsg).Render(c.Request().Context(), &buf) if err != nil { return err } @@ -225,6 +238,105 @@ func registerFrontendRoutes(cfg *Config) { return c.HTML(http.StatusOK, buf.String()) }) + // Collection detail page (works for both system and user collections) + frontendProtected.GET("/collections/:id", func(c echo.Context) error { + user, err := getTemplateUserWithTheme(c, cfg) + if err != nil { + return renderErrorPage(c, "Error loading user", "user_load_error") + } + // Parse collection ID from URL + collectionID := c.Param("id") + collUUID, err := uuid.Parse(collectionID) + if err != nil { + return renderErrorPage(c, "Invalid collection ID", "invalid_id") + } + // Fetch collection details + collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true}) + if err != nil { + if err.Error() == "no rows in result set" { + return renderErrorPage(c, "Collection not found", "not_found") + } + return renderErrorPage(c, "Error loading collection", "collection_load_error") + } + // Fetch books in collection + userUUID, _ := uuid.Parse(user.ID) + var books []handlers.BookInfo + + if collection.QueryType.Valid && collection.QueryType.String != "" { + // System collection - use query type + // System collection - need library_id for system collections + // Get library_id from query param or default to user's first library + libraryID := c.QueryParam("library_id") + if libraryID == "" { + libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID)) + if err == nil && len(libraries) > 0 { + libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16]) + libraryID = libUUID.String() + } + } + + libUUID, _ := uuid.Parse(libraryID) + dashboardSvc := services.NewDashboardService(cfg.Queries) + sections, err := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{}) + if err != nil { + return renderErrorPage(c, "Error loading books", "books_load_error") + } + + // Find the matching section and convert items + for _, section := range sections { + if section.CollectionID.String() == collectionID { + // Convert []database.MediaItems to []handlers.BookInfo + bookCards := make([]handlers.BookInfo, len(section.Items)) + for i, item := range section.Items { + itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) + bookCards[i] = handlers.BookInfo{ + MediaItemID: itemUUID.String(), + Title: item.Title, + Author: getText(item.Author), + CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), + } + } + books = bookCards + break + } + } + } else { + // User collection - fetch collection items + collItems, err := cfg.Queries.GetCollectionItems(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true}) + if err != nil { + books = []handlers.BookInfo{} + } + + // Convert to BookInfo format + bookCards := make([]handlers.BookInfo, len(collItems)) + for i, item := range collItems { + itemUUID, _ := uuid.FromBytes(item.MediaItemID.Bytes[0:16]) + bookCards[i] = handlers.BookInfo{ + MediaItemID: itemUUID.String(), + Title: item.Title, + Author: getText(item.Author), + CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), + } + } + books = bookCards + } + // Build collection data + colData := templates.CollectionData{ + ID: collectionID, + Name: collection.Name, + Description: collection.Description.String, + Color: collection.Color.String, + Icon: collection.Icon.String, + } + // Render the CollectionDetail template + var buf bytes.Buffer + err = templates.CollectionDetail(user, colData, books).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + }) + // Custom Section Builder page frontendProtected.GET("/custom-section", func(c echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) diff --git a/internal/services/dashboard_service.go b/internal/services/dashboard_service.go index d03796f..0579297 100644 --- a/internal/services/dashboard_service.go +++ b/internal/services/dashboard_service.go @@ -196,7 +196,7 @@ func (s *DashboardService) GetDashboardSections( }) } - results = s.filterHiddenCollections(results, hiddenCollections) + results = s.FilterHiddenCollections(results, hiddenCollections) results = s.reorderCollections(results, collectionOrder) if len(collectionOrder) == 0 { @@ -206,7 +206,7 @@ func (s *DashboardService) GetDashboardSections( return results, nil } -func (s *DashboardService) filterHiddenCollections(sections []DashboardSection, hidden []string) []DashboardSection { +func (s *DashboardService) FilterHiddenCollections(sections []DashboardSection, hidden []string) []DashboardSection { if len(hidden) == 0 { return sections } @@ -373,6 +373,30 @@ func (s *DashboardService) UpsertDashboardPreferences(ctx context.Context, param return s.db.UpsertDashboardPreferences(ctx, params) } +func (s *DashboardService) SanitizeDashboardPreferences(hiddenCollections, collectionOrder []string) ([]string, []string) { + // Deduplicate collection_order while preserving order + seen := make(map[string]bool) + var sanitizedOrder []string + for _, id := range collectionOrder { + if !seen[id] { + seen[id] = true + sanitizedOrder = append(sanitizedOrder, id) + } + } + + // Deduplicate hidden_collections + seen = make(map[string]bool) + var sanitizedHidden []string + for _, id := range hiddenCollections { + if !seen[id] { + seen[id] = true + sanitizedHidden = append(sanitizedHidden, id) + } + } + + return sanitizedHidden, sanitizedOrder +} + func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID uuid.UUID, collectionName string, resetType string) error { defaultMetadata := map[string]struct { Description string diff --git a/templates/collections.templ b/templates/collections.templ index 01f99b0..4c27488 100644 --- a/templates/collections.templ +++ b/templates/collections.templ @@ -3,117 +3,122 @@ package templates import "bookhoard/internal/handlers" templ Collection(user User, collections []CollectionData, errorMessage string) { - - - - - - Collections - Bookhoard - - - - - - @Header(user, "/collections") - -
-
-
-

My Collections

-

Organize your books into custom collections

-
- -
- -
- if len(collections) == 0 { -
-
📚
-

No Collections Yet

-

Create collections to organize your books

- -
- } - - for _, col := range collections { -
-
-
{ col.Icon }
-
- - -
-
-

{ col.Name }

-

{ col.Description }

-
- } -
-
- - - - + + + + + @Header(user, "/collections") +
+
+
+

My Collections

+

Organize your books into custom collections

+
+ +
+
+ if len(collections) == 0 { +
+
📚
+

No Collections Yet

+

Create collections to organize your books

+ +
+ } + for _, col := range collections { +
+
+
{ col.Icon }
+
+ + +
+
+

{ col.Name }

+

{ col.Description }

+
+ } +
+
+ + - - @ErrorToast(errorMessage) - - + @ErrorToast(errorMessage) + + } templ CollectionDetail(user User, collection CollectionData, books []handlers.BookInfo) { - - - - - - { collection.Name } - Bookhoard - - - - - - @Header(user, "/collections") - -
-
- -
-
{ collection.Icon }
-
-

{ collection.Name }

-

{ collection.Description }

-
-
-
- -
-
-

Books in this Collection

- -
-
-
- -
- - -
-
- -
- if len(books) == 0 { -
No books in this collection yet.
- } - - for _, book := range books { -
-
- -
- Cover -
-
-

{ book.Title }

- if book.Author != "" { -

by { book.Author }

- } - -
-
-
- } -
-
- - - - + + + + + @Header(user, "/collections") +
+
+ +
+
{ collection.Icon }
+
+

{ collection.Name }

+

{ collection.Description }

+
+
+
+
+
+

Books in this Collection

+ +
+
+
+ +
+ + +
+
+
+ if len(books) == 0 { +
No books in this collection yet.
+ } + for _, book := range books { +
+
+ +
+ +
+
+

+ { book.Title } +

+ if book.Author != "" { +

+ by { book.Author } +

+ } +
+ +
+ if book.CoverImagePath != "" { + Cover + } else { + Cover + } +
+
+ +
+ +
+
+ } +
+
+ + - - + + } diff --git a/templates/collections_templ.go b/templates/collections_templ.go index 48ce107..78284f9 100644 --- a/templates/collections_templ.go +++ b/templates/collections_templ.go @@ -39,7 +39,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

My Collections

Organize your books into custom collections

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

My Collections

Organize your books into custom collections

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -57,7 +57,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 47, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 46, Col: 40} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -70,7 +70,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 59, Col: 109} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 64, Col: 91} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -83,7 +83,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 60, Col: 103} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 65, Col: 85} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -138,7 +138,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 222, Col: 32} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 226, Col: 27} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -159,7 +159,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 236, Col: 95} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 239, Col: 81} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -172,7 +172,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 238, Col: 107} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 241, Col: 90} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -185,7 +185,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 239, Col: 88} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 242, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -209,7 +209,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 286, Col: 131} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 301, Col: 108} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -227,7 +227,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 288, Col: 108} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 303, Col: 82} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil {