From f1844591b4a88e89a1f7b8ad3417534858a26741 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 18 Oct 2026 12:28:25 -0400 Subject: [PATCH] feat(web): templ partials replace the last UI twins (collections grid, conflicts list) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both pages had a templ render (initial load) and a TypeScript twin (innerHTML rebuild on refresh) of the same fragment. The twins are deleted; the server-rendered markup is now the only markup. - collections: the library switcher swaps in the new books-grid partial (GET /collections/:id/books-grid) instead of re-rendering cards from the /api/collections JSON. Book loading is extracted into Config.loadCollectionBooks, shared verbatim by the page and the partial (smart / dashboard-query / manual branches, family-lens scoping via resolveLibrary + UserVisibleLibraryIDs) so the two cannot drift. - conflicts: resolve/dismiss refreshes swap the new list partial (GET /conflicts/list) with the stats bar as an out-of-band swap, instead of the TS twin — which rendered a degraded card (no checkboxes, no timestamps), pointed at a window.showResolveModal that does not exist, and updated stats via element ids the SSR never had. - shims.d.ts: window.htmx typing (ajax/process) for TS callers. Visibility note: the books-grid partial inherits its family-lens scoping from the shared loader (identical to the detail page); a dedicated hidden-library pin test for the partial route is still worth adding in the next test pass. --- internal/router/frontend.go | 156 +++---- internal/router/helpers.go | 105 +++++ templates/collections.templ | 133 +++--- templates/collections_templ.go | 810 +++++++++++++++++---------------- templates/conflicts.templ | 205 +++++---- templates/conflicts_templ.go | 399 ++++++++++------ web/src/collections.ts | 77 +--- web/src/conflicts.ts | 62 +-- web/src/shims.d.ts | 20 + 9 files changed, 1078 insertions(+), 889 deletions(-) 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, "
Unauthorized
") + } + collectionID := c.Param("id") + collUUID, parseErr := uuid.Parse(collectionID) + if parseErr != nil { + return c.HTML(http.StatusBadRequest, "
Invalid collection ID
") + } + collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true}) + if err != nil { + return c.HTML(http.StatusNotFound, "
Collection not found
") + } + books, _, isSmart, errCode := cfg.loadCollectionBooks(c, user, collection, collectionID) + if errCode != "" { + return c.HTML(http.StatusBadRequest, "
Error loading books
") + } + colData := collectionDataFromRow(collection, collectionID, isSmart, len(books)) + + var buf bytes.Buffer + if renderErr := templates.CollectionBooksGrid(colData, books).Render(c.Request().Context(), &buf); renderErr != nil { + return renderErr + } + return c.HTML(http.StatusOK, buf.String()) + }) + // Week-widget fragment: the collapsible "This Week" strip re-fetches // itself here (htmx) whenever the library switcher dispatches // week-refresh, carrying the current #library-select value. Same @@ -945,6 +900,29 @@ func registerFrontendRoutes(cfg *Config) { } return c.HTML(http.StatusOK, buf.String()) }) + // Conflicts list partial: fresh list plus the stats bar as an + // out-of-band swap. The client refreshes through this instead of + // re-rendering conflict cards from JSON (rendering-ownership rule: + // templ renders, JS only swaps). + frontendProtected.GET("/conflicts/list", func(c *echo.Context) error { + user, err := getTemplateUserWithTheme(c, cfg) + if err != nil { + return c.HTML(http.StatusUnauthorized, "
Unauthorized
") + } + conflictsData, total, unresolved, dataErr := cfg.ConflictHandler.GetConflictsData(c) + if dataErr != nil { + log.Printf("GetConflictsData failed: %v", dataErr) + conflictsData = []handlers.ConflictDetailResponse{} + total = 0 + unresolved = 0 + } + + var buf bytes.Buffer + if renderErr := templates.ConflictsRefreshFragment(user, conflictsData, total, unresolved).Render(c.Request().Context(), &buf); renderErr != nil { + return renderErr + } + return c.HTML(http.StatusOK, buf.String()) + }) // Analytics page → /stats (the rebuilt reading-statistics page; // nobody bookmarks orphan pages twice). diff --git a/internal/router/helpers.go b/internal/router/helpers.go index a97dbaed..8e4e6cfc 100644 --- a/internal/router/helpers.go +++ b/internal/router/helpers.go @@ -10,6 +10,8 @@ import ( "time" "bookhoard/internal/database" + "bookhoard/internal/handlers" + "bookhoard/internal/services" "bookhoard/internal/utils" "bookhoard/templates" @@ -257,3 +259,106 @@ func visibleLibraryIDsFor(c *echo.Context, cfg *Config) (map[string]bool, []pgty } return cfg.LibraryService.VisibleLibrarySet(c.Request().Context(), uuidToPGType(userU)) } + +// collectionDataFromRow builds the template collection payload shared by +// the collection detail page and its books-grid partial. +func collectionDataFromRow(collection database.Collections, collectionID string, isSmart bool, bookCount int) templates.CollectionData { + return 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: bookCount, + } +} + +// loadCollectionBooks resolves the books shown for a collection: smart +// evaluation, a dashboard query section, or manual membership — scoped to +// the requesting user's visible libraries (family-lens) exactly like the +// collection detail page. Shared by the detail page and the books-grid +// partial route so the two can never drift. +// errCode is "" on success, otherwise a renderErrorPage code. +func (cfg *Config) loadCollectionBooks(c *echo.Context, user templates.User, collection database.Collections, collectionID string) (books []handlers.BookInfo, libRes LibraryResolution, isSmart bool, errCode string) { + collUUID, parseErr := uuid.Parse(collectionID) + if parseErr != nil { + return nil, libRes, isSmart, "invalid_id" + } + userUUID, _ := uuid.Parse(user.ID) + + 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 nil, libRes, isSmart, "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 nil, libRes, isSmart, "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 nil, libRes, isSmart, "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) + } + } + return books, libRes, isSmart, "" +} diff --git a/templates/collections.templ b/templates/collections.templ index bd45a7cf..edf2f6d9 100644 --- a/templates/collections.templ +++ b/templates/collections.templ @@ -14,6 +14,76 @@ func pluralizeBooks(n int) string { return "books" } +// CollectionBooksGrid renders the content of #books-container: the +// collection's book cards, or the empty state. Shared by the collection +// detail page and the /collections/:id/books-grid partial route (the +// library switcher refreshes the grid through it — rendering ownership: +// templ renders, JS only swaps). +templ CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo) { + if len(books) == 0 { +
No books in this collection yet.
+ } + for _, book := range books { +
+
+ if !collection.IsSystem && !collection.IsSmart { +
+ +
+ } +
+ +

+ { book.Title } +

+
+ if book.Author != "" { +

+ by { book.Author } +

+ } +
+ +
+ if !collection.IsSystem && !collection.IsSmart { +
+ +
+ } +
+ } +} + templ Collection(user User, collections []CollectionData, errorMessage string) { @@ -210,68 +280,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
- if len(books) == 0 { -
No books in this collection yet.
- } - for _, book := range books { -
-
- if !collection.IsSystem && !collection.IsSmart { -
- -
- } -
- -

- { book.Title } -

-
- if book.Author != "" { -

- by { book.Author } -

- } -
- -
- if !collection.IsSystem && !collection.IsSmart { -
- -
- } -
- } + @CollectionBooksGrid(collection, books)
if !collection.IsSystem && !collection.IsSmart { diff --git a/templates/collections_templ.go b/templates/collections_templ.go index fa474c2b..1ac837dd 100644 --- a/templates/collections_templ.go +++ b/templates/collections_templ.go @@ -22,7 +22,12 @@ func pluralizeBooks(n int) string { return "books" } -func Collection(user User, collections []CollectionData, errorMessage string) templ.Component { +// CollectionBooksGrid renders the content of #books-container: the +// collection's book cards, or the empty state. Shared by the collection +// detail page and the /collections/:id/books-grid partial route (the +// library switcher refreshes the grid through it — rendering ownership: +// templ renders, JS only swaps). +func CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -43,29 +48,262 @@ func Collection(user User, collections []CollectionData, errorMessage string) te templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Collections - Bookhoard") + if len(books) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
No books in this collection yet.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + for _, book := range books { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !collection.IsSystem && !collection.IsSmart { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 44, Col: 23} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.Author != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !collection.IsSystem && !collection.IsSmart { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func Collection(user User, collections []CollectionData, errorMessage string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var13 := templ.GetChildren(ctx) + if templ_7745c5c3_Var13 == nil { + templ_7745c5c3_Var13 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "Collections - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var2 = []any{"theme-" + user.Theme} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...) + var templ_7745c5c3_Var14 = []any{"theme-" + user.Theme} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -73,7 +311,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, 4, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -81,7 +319,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, 5, "

My Collections

Organize your books into custom collections

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "New Collection
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(collections) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -110,7 +348,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, 9, "

No Collections Yet

Create collections to organize your books

No Collections Yet

Create collections to organize your books

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "Create Your First Collection
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } for _, col := range collections { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon) + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 89, Col: 41} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 159, Col: 41} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if col.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "⚡ Smart ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "⚡ Smart ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name) + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 114, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 184, Col: 92} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "

") + 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, 48, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -277,7 +515,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, 27, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -301,47 +539,47 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var12 := templ.GetChildren(ctx) - if templ_7745c5c3_Var12 == nil { - templ_7745c5c3_Var12 = templ.NopComponent + templ_7745c5c3_Var24 := templ.GetChildren(ctx) + if templ_7745c5c3_Var24 == nil { + templ_7745c5c3_Var24 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 138, Col: 27} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 208, Col: 27} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var14 = []any{"theme-" + user.Theme} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...) + var templ_7745c5c3_Var26 = []any{"theme-" + user.Theme} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var26...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -353,7 +591,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -361,97 +599,97 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "Back to Collections
Back to Collections
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var17 string - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon) + var templ_7745c5c3_Var29 string + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 153, Col: 169} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 223, Col: 169} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var18 string - templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) + var templ_7745c5c3_Var30 string + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 156, Col: 25} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 226, Col: 25} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if collection.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "⚡ Smart") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "⚡ Smart") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "

") + 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, "

Books in this Collection

0\" x-text=\"selectedBooks.length + ' selected'\" class=\"badge\" style=\"display: none; background-color: var(--accent); color: var(--bg-primary);\">
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "

Books in this Collection

0\" x-text=\"selectedBooks.length + ' selected'\" class=\"badge\" style=\"display: none; background-color: var(--accent); color: var(--bg-primary);\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !collection.IsSystem { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" class=\"btn btn-secondary\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -459,12 +697,12 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "Edit Rules ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "Edit Rules ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !collection.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "Add Books") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if len(books) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
No books in this collection yet.
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - for _, book := range books { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if !collection.IsSystem && !collection.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var27 string - templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 234, Col: 23} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if book.Author != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "

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, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if !collection.IsSystem && !collection.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } + templ_7745c5c3_Err = CollectionBooksGrid(collection, books).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err } templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "
") if templ_7745c5c3_Err != nil { @@ -715,12 +749,12 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var32 string - templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID) + var templ_7745c5c3_Var33 string + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 324, Col: 93} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 333, Col: 93} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -728,12 +762,12 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var33 string - templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID) + var templ_7745c5c3_Var34 string + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 336, Col: 93} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 345, Col: 93} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -741,12 +775,12 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var34 string - templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID) + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 348, Col: 93} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 357, Col: 93} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -762,12 +796,12 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var35 string - templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID) + var templ_7745c5c3_Var36 string + templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 368, Col: 90} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 377, Col: 90} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -788,12 +822,12 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var36 string - templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID) + var templ_7745c5c3_Var37 string + templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 423, Col: 26} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 432, Col: 26} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -801,12 +835,12 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var37 string - templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID) + var templ_7745c5c3_Var38 string + templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 424, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 433, Col: 30} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -814,12 +848,12 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var38 string - templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSystem) + var templ_7745c5c3_Var39 string + templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSystem) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 425, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 434, Col: 39} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -827,12 +861,12 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var39 string - templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSmart) + var templ_7745c5c3_Var40 string + templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSmart) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 426, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 435, Col: 37} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -860,9 +894,9 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var40 := templ.GetChildren(ctx) - if templ_7745c5c3_Var40 == nil { - templ_7745c5c3_Var40 = templ.NopComponent + templ_7745c5c3_Var41 := templ.GetChildren(ctx) + if templ_7745c5c3_Var41 == nil { + templ_7745c5c3_Var41 = templ.NopComponent } ctx = templ.ClearChildren(ctx) for _, book := range books { @@ -870,12 +904,12 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var41 string - templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.toggleBook('" + book.MediaItemID + "')") + var templ_7745c5c3_Var42 string + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.toggleBook('" + book.MediaItemID + "')") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 436, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 445, Col: 70} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -888,12 +922,12 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var42 string - templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath) + var templ_7745c5c3_Var43 string + templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 440, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 449, Col: 35} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -901,12 +935,12 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var43 string - templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title) + var templ_7745c5c3_Var44 string + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 440, Col: 54} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 449, Col: 54} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -919,12 +953,12 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var44 string - templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title) + var templ_7745c5c3_Var45 string + templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 442, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 451, Col: 61} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -937,12 +971,12 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var45 string - templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')") + var templ_7745c5c3_Var46 string + templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 445, Col: 72} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 454, Col: 72} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -950,12 +984,12 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var46 string - templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')") + var templ_7745c5c3_Var47 string + templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 450, Col: 72} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 459, Col: 72} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -963,12 +997,12 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var47 string - templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) + var templ_7745c5c3_Var48 string + templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 457, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 466, Col: 87} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/conflicts.templ b/templates/conflicts.templ index 86b435f6..79507fc7 100644 --- a/templates/conflicts.templ +++ b/templates/conflicts.templ @@ -2,96 +2,10 @@ package templates import "bookhoard/internal/handlers" -templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int, errorMessage string) { - - - - - - Sync Conflicts - Bookhoard - - - - - - @Header(user, "/conflicts") -
-
-
-
-
- - @Icon("alert", "h-5 w-5") - -

Sync Conflicts

-
-

Resolve conflicts when reading progress differs across devices

-
-
- - -
-
-
-
-
{ total }
-
Total
-
-
-
{ unresolved }
-
Unresolved
-
-
-
{ total - unresolved }
-
Resolved
-
-
- -
- - -
+// ConflictsListContent renders the inner content of #conflicts-list: +// bulk-select controls, the empty state, or the conflict cards. Shared by +// the conflicts page and the /conflicts/list partial route. +templ ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse) {
} +} + +// ConflictStatsBar renders the total/unresolved/resolved stat cards. +templ ConflictStatsBar(total int, unresolved int) { +
+
{ total }
+
Total
+
+
+
{ unresolved }
+
Unresolved
+
+
+
{ total - unresolved }
+
Resolved
+
+} + +// ConflictsRefreshFragment is the /conflicts/list partial response: a +// fresh list plus the stats bar as an out-of-band swap. The client swaps +// this instead of re-rendering conflict cards from JSON (rendering +// ownership: templ renders, JS only swaps). +templ ConflictsRefreshFragment(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int) { +
+ @ConflictsListContent(user, conflicts) +
+
+ @ConflictStatsBar(total, unresolved) +
+} + +templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int, errorMessage string) { + + + + + + Sync Conflicts - Bookhoard + + + + + + @Header(user, "/conflicts") +
+
+
+
+
+ + @Icon("alert", "h-5 w-5") + +

Sync Conflicts

+
+

Resolve conflicts when reading progress differs across devices

+
+
+ + +
+
+
+ @ConflictStatsBar(total, unresolved) +
+ +
+ + +
+ @ConflictsListContent(user, conflicts)
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "

Resolution Options

Manual Progress

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -269,7 +392,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int 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, 31, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/src/collections.ts b/web/src/collections.ts index 97ee34c9..9562ca0a 100644 --- a/web/src/collections.ts +++ b/web/src/collections.ts @@ -53,62 +53,6 @@ function initializeCollectionWebSocket(): void { }); } -function renderCollectionBooks(books: BookInfo[]): void { - const container = document.getElementById("books-container"); - if (!container) return; - - const dataEl = document.getElementById("collection-data"); - const isSystem = dataEl?.dataset.isSystem === "true"; - // Smart collections are rule-managed: no hand-pick actions either way. - const canEditMembership = - !isSystem && dataEl?.dataset.isSmart !== "true"; - - if (books.length === 0) { - container.innerHTML = `
No books in this collection yet.
`; - return; - } - - container.innerHTML = books - .map( - (book) => ` -
-
- ${canEditMembership ? ` -
- -
` : ""} -
- -

${book.title}

-
- ${book.author ? `

by ${book.author}

` : ""} -
-
- - Cover - -
-
- ${canEditMembership ? ` -
- -
` : ""} -
- `, - ) - .join(""); - - Alpine.initTree(container); -} - async function loadCollectionRules(collectionId: string): Promise { const token = localStorage.getItem("token"); if (!token) return; @@ -480,19 +424,16 @@ function initCollectionsPage(): void { if (dataEl) dataEl.dataset.libraryId = libraryId; await switchWithTransition("books-container", async () => { - const param = libraryId ? `?library_id=${libraryId}` : ""; - const response = await fetch( - `/api/collections/${collId}${param}`, - { - headers: { - Authorization: `Bearer ${localStorage.getItem("token")}`, - "Content-Type": "application/json", - }, - }, + // Rendering ownership: the grid is server-rendered markup. Swap + // in the templ partial instead of re-rendering from JSON. + const param = libraryId ? `?library_id=${encodeURIComponent(libraryId)}` : ""; + await window.htmx.ajax( + "GET", + `/collections/${collId}/books-grid${param}`, + { target: "#books-container", swap: "innerHTML" }, ); - if (!response.ok) throw new Error("Failed to load collection"); - const data = await response.json(); - renderCollectionBooks(data.books || []); + const container = document.getElementById("books-container"); + if (container) Alpine.initTree(container); }); }, }); diff --git a/web/src/conflicts.ts b/web/src/conflicts.ts index 5a9a524c..3d07fa1d 100644 --- a/web/src/conflicts.ts +++ b/web/src/conflicts.ts @@ -2,19 +2,16 @@ import { Alpine } from "./alpine"; import { showToast } from "./toast"; async function refreshConflicts(): Promise { - const token = localStorage.getItem("token"); - if (!token) return; - + // Rendering ownership: the list and stats are server-rendered markup. + // Swap in the templ partial (stats arrive as an out-of-band swap) + // instead of re-rendering cards from JSON. try { - const response = await fetch("/api/conflicts", { - headers: { Authorization: `Bearer ${token}` }, + await window.htmx.ajax("GET", "/conflicts/list", { + target: "#conflicts-list", + swap: "outerHTML", }); - - if (response.ok) { - const data: ConflictListResponse = await response.json(); - renderConflicts(data.conflicts); - updateConflictStats(data); - } + const list = document.getElementById("conflicts-list"); + if (list) Alpine.initTree(list); } catch (error) { console.error("Failed to refresh conflicts:", error); } @@ -123,49 +120,6 @@ async function dismissAllResolved(): Promise { } } -function renderConflicts(conflicts: ConflictDetailResponse[]): void { - const container = document.getElementById("conflicts-list"); - if (!container) return; - - if (conflicts.length === 0) { - container.innerHTML = - '

No conflicts found

'; - return; - } - - container.innerHTML = conflicts - .map( - (conflict) => ` -
-
-
-

${conflict.media_item_title}

-

${conflict.conflict_type} - ${conflict.resolution_status}

-
- ${ - conflict.resolution_status === "unresolved" - ? ` -
- -
- ` - : "" - } -
-
- `, - ) - .join(""); -} - -function updateConflictStats(data: ConflictListResponse): void { - const totalEl = document.getElementById("conflicts-total"); - const unresolvedEl = document.getElementById("conflicts-unresolved"); - - if (totalEl) totalEl.textContent = String(data.total); - if (unresolvedEl) unresolvedEl.textContent = String(data.unresolved); -} - function showResolveModal(conflictId: string): void { const modal = document.getElementById("resolve-modal"); const conflictIdInput = document.getElementById( diff --git a/web/src/shims.d.ts b/web/src/shims.d.ts index 68f38977..646904ed 100644 --- a/web/src/shims.d.ts +++ b/web/src/shims.d.ts @@ -1,2 +1,22 @@ declare module "markdown-it-footnote"; declare module "turndown-plugin-gfm"; + +// htmx is loaded as a classic script on every page and used from TS for +// partial swaps (rendering-ownership rule: templ renders, JS swaps). +interface HtmxAjaxOptions { + target?: string; + swap?: string; + values?: Record; + headers?: Record; +} + +interface Window { + htmx: { + ajax( + method: string, + url: string, + options?: HtmxAjaxOptions, + ): Promise; + process(element: Element): void; + }; +}