# SSR Bookshelf Implementation Plan - Option A: Full SSR **Status:** Planning Phase **Created:** March 21, 2026 **Priority:** Medium **Complexity:** Medium --- ## Overview Implement **full Server-Side Rendering (SSR)** for the bookshelf page, following PROJECT_GUIDELINES.md SSR-first principles. Both books and saved filters will be rendered server-side, with client-side JavaScript handling only UI state and event listeners. **IMPORTANT:** No backend changes needed! We'll use existing database queries directly from `frontend.go`. ### Key Decision Points - ✅ **SSR first page of books** - Server renders initial book grid - ✅ **SSR saved filters** - Call existing `GetSavedFilters` query directly (no new backend code) - ✅ **No async x-init** - Client-side only sets up event listeners (like dashboard) - ✅ **HTMX-based pagination** - Continue using HTMX for pagination/filter changes - ✅ **Follows PROJECT_GUIDELINES.md** - Complies with SSR-first principles - ✅ **Minimal changes** - Only frontend.go, template, and TypeScript --- ## Current State Analysis ### Dashboard SSR Pattern (Reference Implementation) **Server-Side:** Full SSR with collections pre-rendered ```go // frontend.go:172-254 sections, err := cfg.DashboardService.GetDashboardSections(...) visibleSections := cfg.DashboardService.FilterHiddenCollections(...) sectionData := handlers.BuildSections(visibleSections, libraryID) templates.Dashboard(user, sectionData, ...).Render(...) ``` **Client-Side:** Event listeners only, no data fetching ```typescript // dashboard.ts:429-515 function initDashboard() { initDragAndDrop(); document.addEventListener("click", ...); // Event delegation only // No data fetching! } ``` ### Bookshelf Current Implementation **Server-Side:** No SSR books - empty grid ```go // frontend.go:120-169 libraries, err := cfg.Queries.GetUserVisibleLibraries(...) templates.BookShelf(user, libData, libraryID, errorMsg).Render(...) // Note: No books fetched, no pagination data ``` **Client-Side:** Async init with HTMX trigger ```typescript // bookshelf.ts:58-97 async initBookshelf() { this.loadSavedFiltersIntoState(); const response = await fetch("/api/saved-filters?resource_type=media-items"); this.savedFilters = filters; // Triggers HTMX to load books window.htmx.trigger(librarySelect, "change"); } ``` --- ## ⚠️ CRITICAL TYPE CHANGE REQUIRED **Before implementing, you MUST change line 178 in `internal/router/frontend.go`:** ```go // Current (line 178) - WRONG TYPE: var books []database.ListMediaItemsByLibraryRow // Must change to: var books []database.ListMediaItemsFilteredRow ``` **Why:** `ListMediaItemsFiltered` returns `[]ListMediaItemsFilteredRow`, not `[]ListMediaItemsByLibraryRow`. If you don't make this change, you will get a compilation error. --- ## Implementation Plan ### Phase 1: Server-Side Changes #### File: `internal/router/frontend.go` **Location:** Lines 120-169 (bookshelf route handler) **CRITICAL - Line 178 MUST CHANGE:** ```go // Current (WRONG): var books []database.ListMediaItemsByLibraryRow // Change to: var books []database.ListMediaItemsFilteredRow // ✅ CORRECT TYPE ``` **Change Type:** Add book fetching AND saved filters fetching logic **Implementation:** ```go frontendProtected.GET("/bookshelf", func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) if err != nil { return renderErrorPage(c, "Error loading user", "user_load_error") } var errorMsg string // Get library_id from query param or user's first library libraryID := c.QueryParam("library_id") if libraryID == "" { userUUID, _ := uuid.Parse(user.ID) 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() } else { errorMsg = "No libraries available" } } // Get libraries for dropdown userUUID, _ := uuid.Parse(user.ID) libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID)) if err != nil { log.Printf("GetUserVisibleLibraries failed: %v", err) libraries = []database.GetUserVisibleLibrariesRow{} if errorMsg == "" { errorMsg = "Error loading libraries" } } libData := make([]templates.LibraryData, len(libraries)) for i, lib := range libraries { libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16]) libData[i] = templates.LibraryData{ ID: libUUID.String(), Name: lib.Name, Description: getText(lib.Description), TypeName: lib.TypeName, } } // ========== NEW CODE START ========== // Fetch saved filters for SSR (using existing query) var savedFilters []database.SavedFilters if libraryID != "" && errorMsg == "" { savedFilters, err = cfg.Queries.GetSavedFilters(c.Request().Context(), database.GetSavedFiltersParams{ UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, ResourceType: pgtype.Text{String: "media-items", Valid: true}, }) if err != nil { log.Printf("GetSavedFilters failed: %v", err) savedFilters = []database.SavedFilters{} // Empty list, not critical error } } // Fetch first page of books for SSR // NOTE: Use ListMediaItemsFilteredRow, NOT ListMediaItemsByLibraryRow var books []database.ListMediaItemsFilteredRow var bookInfoList []handlers.BookInfo totalCount := 0 limit := 50 offset := 0 if libraryID != "" && errorMsg == "" { libUUID, err := uuid.Parse(libraryID) if err == nil { // Check URL params for pagination if limitStr := c.QueryParam("limit"); limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { limit = l } } if offsetStr := c.QueryParam("offset"); offsetStr != "" { if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 { offset = o } } // Fetch books with default filters books, err = cfg.Queries.ListMediaItemsFiltered(c.Request().Context(), database.ListMediaItemsFilteredParams{ LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, AuthorFilter: pgtype.Text{String: "", Valid: false}, SeriesFilter: pgtype.Text{String: "", Valid: false}, GenreFilter: pgtype.Text{String: "", Valid: false}, LanguageFilter: pgtype.Text{String: "", Valid: false}, YearMin: pgtype.Int4{Valid: false}, YearMax: pgtype.Int4{Valid: false}, HasCover: pgtype.Bool{Valid: false}, Sort: pgtype.Text{String: "created_at DESC", Valid: true}, Limit: pgtype.Int4{Int32: int32(limit), Valid: true}, Offset: pgtype.Int4{Int32: int32(offset), Valid: true}, }) if err != nil { log.Printf("ListMediaItemsFiltered failed: %v", err) // Continue without books - will show empty state } else { // Convert database rows to BookInfo structs (matching BuildSections pattern) bookInfoList = make([]handlers.BookInfo, len(books)) for i, book := range books { bookUUID, _ := uuid.FromBytes(book.ID.Bytes[0:16]) bookLibUUID, _ := uuid.FromBytes(book.LibraryID.Bytes[0:16]) bookInfoList[i] = handlers.BookInfo{ MediaItemID: bookUUID.String(), Title: book.Title, Author: getText(book.Author), CoverImagePath: utils.ResolveMediaURL(bookLibUUID, book.CoverImagePath), } } totalCount = len(bookInfoList) } } } // Convert saved filters to JSON for template import "encoding/json" filtersJSON, err := json.Marshal(savedFilters) if err != nil { log.Printf("Failed to marshal saved filters: %v", err) filtersJSON = []byte("[]") } // ========== NEW CODE END ========== var buf bytes.Buffer err = templates.BookShelf( user, libData, libraryID, errorMsg, string(filtersJSON), // NEW: SSR saved filters as JSON string bookInfoList, // NEW: SSR books limit, // NEW: pagination limit offset, // NEW: pagination offset totalCount, // NEW: current page count ).Render(c.Request().Context(), &buf) if err != nil { return err } return c.HTML(http.StatusOK, buf.String()) }) ``` **Notes:** - ✅ Uses existing `GetSavedFilters` query directly (no new backend code) - ✅ Uses existing `ListMediaItemsFiltered` query - ✅ Converts to `handlers.BookInfo` struct (already defined) - ✅ Handles errors gracefully (shows empty grid/filters if fetch fails) - ✅ Respects URL params for pagination - ✅ **Guideline-compliant:** All data fetched server-side, no async x-init - ✅ **No backend changes needed** - All queries already exist --- ### Phase 2: Template Changes #### File: `templates/bookshelf.templ` **Location 1:** Line 3 (templ signature) **Change:** Add new parameters ```templ templ BookShelf( user User, libraries []LibraryData, currentLibraryID string, errorMessage string, savedFiltersJSON string, // NEW: JSON string of saved filters books []handlers.BookInfo, // NEW limit int, // NEW offset int, // NEW count int, // NEW ) { ``` **Location 2:** Lines 277-278 (books grid section) **Current:** ```templ
📚 No books found
Try adjusting your filters or add some books to your library.