diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 36571b60..8c053640 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -660,18 +660,19 @@ func registerFrontendRoutes(cfg *Config) { }) // Collection detail page (works for both system and user collections) + // Collection detail page. Book loading lives in loadCollectionBooks — + // shared verbatim with the books-grid partial below so the SSR page and + // the partial can never drift (rendering-ownership rule). 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" { @@ -679,102 +680,56 @@ func registerFrontendRoutes(cfg *Config) { } return renderErrorPage(c, "Error loading collection", "collection_load_error") } - // Fetch books in collection - userUUID, _ := uuid.Parse(user.ID) - var books []handlers.BookInfo - - libRes := resolveLibrary(c, cfg, user.ID) - libraryID := libRes.LibraryID - - var libScope pgtype.UUID - if !libRes.IsAll { - if parsed, parseErr := uuid.Parse(libraryID); parseErr == nil { - libScope = pgtype.UUID{Bytes: parsed, Valid: true} - } - } - colService := cfg.CollectionHandler.CollectionService() - isSmart := colService.IsSmartCollection(collection.AutoAssignRules) - - visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c) - if visErr != nil { - log.Printf("UserVisibleLibraryIDs failed: %v", visErr) - visibleIDs = nil - } - - libTypes := handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries) - - if isSmart { - // Smart (dynamic) collection: membership is evaluated live — - // visible libraries, live items only, any enabled rule matches. - matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(), - colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{ - LibraryID: libScope, - VisibleIDs: visibleIDs, - }) - if evalErr != nil { - return renderErrorPage(c, "Error loading books", "books_load_error") - } - books = handlers.BookCardsFromMediaItems(matches, libTypes) - } else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" { - dashboardSvc := services.NewDashboardService(cfg.Queries) - sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libRes.LibUUID, 1000, []string{}, []string{}) - if secErr != nil { - return renderErrorPage(c, "Error loading books", "books_load_error") - } - - for _, section := range sections { - if section.CollectionID.String() == collectionID { - books = handlers.BookCardsFromMediaItems(section.Items, libTypes) - break - } - } - } else { - if libraryID != "" && !libRes.IsAll { - libUUID, parseErr := uuid.Parse(libraryID) - if parseErr != nil { - return renderErrorPage(c, "Invalid library ID", "invalid_library_id") - } - - collItems, collErr := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(), - database.GetCollectionItemsForDashboardParams{ - CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true}, - LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, - Limit: pgtype.Int4{Int32: 1000, Valid: true}, - }) - if collErr != nil { - books = handlers.BookCardsFromCollectionDashRows(nil, libTypes) - } else { - // The query filters excluded rows in SQL. - books = handlers.BookCardsFromCollectionDashRows(collItems, libTypes) - } - } else { - collItems, collErr := cfg.Queries.GetCollectionItems(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true}) - if collErr != nil { - collItems = nil - } - books = handlers.BookCardsFromCollectionItemRows(collItems, libTypes) - } - } - // Build collection data - colData := templates.CollectionData{ - ID: collectionID, - Name: collection.Name, - Description: collection.Description.String, - Color: collection.Color.String, - Icon: collection.Icon.String, - IsSystem: collection.IsSystemCollection.Bool, - IsSmart: isSmart, - BookCount: len(books), + books, libRes, isSmart, errCode := cfg.loadCollectionBooks(c, user, collection, collectionID) + if errCode != "" { + msg := map[string]string{ + "books_load_error": "Error loading books", + "invalid_library_id": "Invalid library ID", + "invalid_id": "Invalid collection ID", + }[errCode] + return renderErrorPage(c, msg, errCode) } + colData := collectionDataFromRow(collection, collectionID, isSmart, len(books)) var buf bytes.Buffer - err = templates.CollectionDetail(user, colData, books, libraryID, libRes.Libraries).Render(c.Request().Context(), &buf) + err = templates.CollectionDetail(user, colData, books, libRes.LibraryID, libRes.Libraries).Render(c.Request().Context(), &buf) if err != nil { return err } return c.HTML(http.StatusOK, buf.String()) }) + // Books-grid partial: the collection detail page's #books-container + // content. The library switcher swaps this in (htmx.ajax) instead of + // re-rendering the grid client-side from JSON. Family-lens scoped via + // loadCollectionBooks — same resolution rules as the detail page. + frontendProtected.GET("/collections/:id/books-grid", func(c *echo.Context) error { + user, err := getTemplateUserWithTheme(c, cfg) + if err != nil { + return c.HTML(http.StatusUnauthorized, "
+ by { book.Author } +
+ } +- by { book.Author } -
- } -by ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 49, Col: 27} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Organize your books into custom collections
Create collections to organize your books
Create collections to organize your books
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description) + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 115, Col: 81} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 185, Col: 81} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d %s", col.BookCount, pluralizeBooks(col.BookCount))) + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d %s", col.BookCount, pluralizeBooks(col.BookCount))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 117, Col: 103} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 187, Col: 103} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if col.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "· rules-matched live") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "· rules-matched live") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var19 string - templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description) + var templ_7745c5c3_Var31 string + templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 161, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 231, Col: 87} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if collection.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "Membership is rules-matched live — always current, nothing to refresh.
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "Membership is rules-matched live — always current, nothing to refresh.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "by ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var28 string - templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 239, Col: 27} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "Resolve conflicts when reading progress differs across devices
-Loading conflicts...
-Your devices are in sync! No conflicts to resolve.
-Resolve conflicts when reading progress differs across devices
+Loading conflicts...
+Your devices are in sync! No conflicts to resolve.
+Resolve conflicts when reading progress differs across devices
Loading conflicts...
Your devices are in sync! No conflicts to resolve.
Your devices are in sync! No conflicts to resolve.
Your devices are in sync! No conflicts to resolve.
Type: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
Type: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType) + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 126, Col: 96} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 40, Col: 96} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
Book Title
Resolve conflicts when reading progress differs across devices
Loading conflicts...
Your devices are in sync! No conflicts to resolve.
Book Title
by ${book.author}
` : ""} -No conflicts found
'; - return; - } - - container.innerHTML = conflicts - .map( - (conflict) => ` -${conflict.conflict_type} - ${conflict.resolution_status}
-