feat(web): templ partials replace the last UI twins (collections grid, conflicts list)

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.
This commit is contained in:
John O'Keefe committed 2026-10-18 12:28:25 -04:00
1 parent 1fdf306f2d
commit f1844591b4
9 files changed
+1078 -889

No files matched your search

+67 -89
View File
@@ -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, "<div>Unauthorized</div>")
}
collectionID := c.Param("id")
collUUID, parseErr := uuid.Parse(collectionID)
if parseErr != nil {
return c.HTML(http.StatusBadRequest, "<div>Invalid collection ID</div>")
}
collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
if err != nil {
return c.HTML(http.StatusNotFound, "<div>Collection not found</div>")
}
books, _, isSmart, errCode := cfg.loadCollectionBooks(c, user, collection, collectionID)
if errCode != "" {
return c.HTML(http.StatusBadRequest, "<div>Error loading books</div>")
}
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, "<div>Unauthorized</div>")
}
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).
+105
View File
@@ -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, ""
}