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.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
+120
-8
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user