diff --git a/SSR_BOOKSHELF_IMPLEMENTATION.md b/SSR_BOOKSHELF_IMPLEMENTATION.md deleted file mode 100644 index 7e35328..0000000 --- a/SSR_BOOKSHELF_IMPLEMENTATION.md +++ /dev/null @@ -1,803 +0,0 @@ -# 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.
-