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 - -
- -
-``` - -**Updated:** -```templ - -
- if len(books) > 0 { - for _, book := range books { - @BookCard(book) - } - } else { - -
-

📚 No books found

-

Try adjusting your filters or add some books to your library.

-
- } -
-``` - -**Location 3: BEFORE closing `` tag (around line 345)** - -**Add hidden script tag for server data:** -```templ - - -``` - -**Location 3:** Lines 280-282 (pagination section) - -**Current:** -```templ - - -``` - -**Updated:** -```templ - - -``` - -**Notes:** -- Reuses existing `BookCard` component from dashboard -- Shows empty state when no books -- Pagination buttons use HTMX for subsequent pages -- Disabled buttons when at start/end of results - ---- - -### Phase 3: Client-Side Changes - -#### File: `web/src/bookshelf.ts` - -**Location:** Lines 58-97 (initBookshelf method) - -**Current Code:** -```typescript -// Inline method - initialize the bookshelf -async initBookshelf() { - // Load saved filters from localStorage into component state - this.loadSavedFiltersIntoState(); - - // Also fetch fresh data from API - const token = localStorage.getItem("token"); - if (token) { - try { - const response = await fetch( - "/api/saved-filters?resource_type=media-items", - { - headers: { Authorization: `Bearer ${token}` }, - }, - ); - if (response.ok) { - const filters = await response.json(); - this.savedFilters = filters; - localStorage.setItem( - "bookshelfFilters", - JSON.stringify(filters), - ); - } - } catch (error) { - console.error("Failed to load saved filters:", error); - } - } - - // Setup initial book load via HTMX - const librarySelect = document.getElementById( - "library-select", - ) as HTMLSelectElement; - if (librarySelect && librarySelect.value) { - const filterForm = document.getElementById( - "filter-form", - ) as HTMLFormElement; - const booksGrid = document.getElementById("books-grid"); - - if (filterForm && booksGrid) { - // Trigger initial HTMX load - window.htmx.trigger(librarySelect, "change"); - } - } -}, -``` - -**Updated Code (Guideline-Compliant):** -```typescript -// Helper function to load server-rendered filters -function loadServerFilters(): string { - const filterDataElement = document.getElementById("saved-filters-data"); - if (filterDataElement) { - return filterDataElement.textContent || "[]"; - } - return "[]"; -} - -// Standalone function - initialize the bookshelf (NOT async, like dashboard.ts) -function initBookshelf() { - // Setup event listeners only - NO data fetching (guideline-compliant) - - // Check if books were already rendered server-side - const booksGrid = document.getElementById("books-grid"); - const hasServerBooks = booksGrid && booksGrid.querySelector('[data-book-id]') !== null; - - if (!hasServerBooks) { - // Only trigger HTMX if no SSR books rendered - const librarySelect = document.getElementById( - "library-select", - ) as HTMLSelectElement; - if (librarySelect && librarySelect.value) { - const filterForm = document.getElementById( - "filter-form", - ) as HTMLFormElement; - if (filterForm) { - // Trigger initial HTMX load - window.htmx.trigger(librarySelect, "change"); - } - } - } -} -``` - -**Remove from current code:** -- Delete `async function loadSavedFilters()` function (no longer needed) -- Delete `loadSavedFiltersIntoState()` method -- Delete `async initBookshelf()` inline method -- Remove all `localStorage` operations for filters - -**Updated Alpine component:** -```typescript -Alpine.data("bookshelf", () => ({ - // Component state - showSaveModal: false, - filterName: "", - savedFilters: JSON.parse(loadServerFilters()), // Load from server-rendered JSON - showFiltersDropdown: false, - - // Standalone function references (don't access component state) - clearFilters, - initBookshelf, - - // Inline methods remain unchanged... - showSaveFilterModal() { - this.showSaveModal = true; - }, - // ... rest of methods -})) -``` - -**Remove from Alpine.data component:** -- Delete `loadSavedFiltersIntoState()` method -- Delete `async initBookshelf()` inline method -- Add `initBookshelf` as standalone function reference - -**Updated Alpine component:** -```typescript -Alpine.data("bookshelf", () => ({ - // Component state - showSaveModal: false, - filterName: "", - savedFilters: (window as any).bookshelfServerFilters || [], - showFiltersDropdown: false, - - // Standalone function references (don't access component state) - clearFilters, - initBookshelf, - - // Inline methods remain unchanged... - showSaveFilterModal() { - this.showSaveModal = true; - }, - // ... rest of methods -})); -``` - -**Key Changes:** -- ✅ `initBookshelf()` is now **synchronous** (not `async`) -- ✅ **No data fetching** in x-init (guideline-compliant) -- ✅ Filters loaded from server-rendered JSON -- ✅ Matches `dashboard.ts` pattern exactly -- ✅ Only sets up event listeners and checks for SSR books - ---- - -## Testing Plan - -### Test Cases - -#### 1. Initial Page Load -**Steps:** -1. Navigate to `/bookshelf` -2. Check that books are rendered -3. Check that saved filters are visible -4. Check Network tab - no duplicate HTMX request - -**Expected:** -- ✅ Books visible immediately (SSR) -- ✅ Saved filters visible immediately (SSR) -- ✅ No HTMX request to `/api/media-items/filtered` -- ✅ No API call to `/api/saved-filters` -- ✅ initBookshelf() is synchronous (not async) - -#### 2. Library Switch -**Steps:** -1. Change library dropdown -2. Check books update - -**Expected:** -- ✅ HTMX request triggered -- ✅ New books loaded -- ✅ Pagination updated - -#### 3. Filter Application -**Steps:** -1. Enter text in search field -2. Wait for debounce -3. Check filtered results - -**Expected:** -- ✅ HTMX request triggered -- ✅ SSR books replaced -- ✅ Filtered results displayed - -#### 4. Pagination -**Steps:** -1. Click "Next" button -2. Check URL updates -3. Check new books loaded - -**Expected:** -- ✅ HTMX request with `offset` param -- ✅ New books loaded -- ✅ Previous button enabled - -#### 5. Empty Library -**Steps:** -1. Navigate to bookshelf with empty library - -**Expected:** -- ✅ Empty state displayed -- ✅ No errors in console -- ✅ Pagination hidden - -#### 6. Browser Back/Forward -**Steps:** -1. Navigate to page 2 -2. Click browser back button -3. Check page 1 restored - -**Expected:** -- ✅ Page 1 books restored -- ✅ Pagination updated -- ✅ No page reload - -### Performance Benchmarks - -**Before (Client-side only):** -- TTI (Time to Interactive): ~800ms -- LCP (Largest Contentful Paint): ~1200ms -- Books visible: ~1200ms -- Saved filters visible: ~1300ms - -**After (Full SSR):** -- TTI: ~600ms -- LCP: ~400ms -- Books visible: ~400ms -- Saved filters visible: ~400ms - -**Expected improvement:** 3x faster initial book display, instant filter availability - ---- - -## Summary of Changes - -| File | Lines Changed | Type | Complexity | -|------|--------------|------|------------| -| `frontend.go` | ~55 lines | Add saved filters + book fetching (using existing queries) | Low | -| `bookshelf.templ` | ~20 lines | Add JSON parameter + books + pagination | Low | -| `bookshelf.ts` | ~30 lines | Remove async, load from server JSON | Low | - -**Total:** ~105 lines of code across 3 files - -**No new backend code needed** - All queries and handlers already exist! - ---- - -## Advantages of This Approach - -✅ **100% Guideline-compliant** - Follows PROJECT_GUIDELINES.md SSR-first principles -✅ **Fast initial load** - Books and filters rendered server-side -✅ **No race conditions** - Client checks for SSR books -✅ **Simple saved filters** - Uses existing queries, no client-side API calls -✅ **Minimal changes** - Only 3 files modified -✅ **No backend changes** - All database queries already exist -✅ **SEO benefits** - First page of books indexed (if public) -✅ **Progressive enhancement** - Works with or without SSR -✅ **Matches dashboard pattern** - Consistent with existing codebase - ---- - -## Rollback Plan - -If issues arise, rollback is straightforward: - -### Step 1: Revert Template Changes -```bash -git checkout HEAD~1 templates/bookshelf.templ -``` - -### Step 2: Revert Client Changes -```bash -git checkout HEAD~1 web/src/bookshelf.ts -``` - -### Step 3: Revert Server Changes -```bash -git checkout HEAD~1 internal/router/frontend.go -``` - -### Step 4: Rebuild -```bash -npm run build:ts -podman compose down -podman compose build -podman compose up -d -``` - ---- - -## Potential Issues & Mitigations - -### Issue 1: Pagination Count Inaccuracy -**Problem:** `len(books)` only shows current page count, not total - -**Mitigation:** -- Acceptable for first implementation -- Future: Add `COUNT(*)` query for accurate totals -- Users can still navigate, just won't see "Page 1 of 10" - -### Issue 2: Search Not SSR'd -**Problem:** Search results won't be SSR'd - -**Mitigation:** -- By design - HTMX handles search -- Search is less common than initial page load -- Acceptable trade-off for simplicity - -### Issue 3: Memory Usage on Server -**Problem:** Rendering 50 books server-side uses more memory - -**Mitigation:** -- 50 books is reasonable (current default limit) -- Template rendering is fast -- No significant memory impact expected - -### Issue 4: Cache Invalidation -**Problem:** Browser might cache SSR HTML - -**Mitigation:** -- HTMX updates replace cached content -- Cache headers can be adjusted if needed -- Users see fresh data on interactions - ---- - -## Future Enhancements - -### Phase 2 Improvements (Optional) - -1. **Accurate Pagination Count** - - Add `CountMediaItems` query - - Display "Page X of Y" - -2. **Search SSR** - - Parse search params from URL - - Pre-fetch search results server-side - -3. **Prefetch Next Page** - - Link header with `rel="next"` - - Browser prefetches next page - -4. **Streaming SSR** - - Use HTMX streaming for faster perception - - Render books as they become available - ---- - -## Checklist - -### Implementation -- [ ] **CRITICAL:** Change line 178 in frontend.go from `[]database.ListMediaItemsByLibraryRow` to `[]database.ListMediaItemsFilteredRow` -- [ ] Update `internal/router/frontend.go` with saved filters + book fetching -- [ ] Update `templates/bookshelf.templ` signature and add JSON script tag -- [ ] Add SSR book rendering to template -- [ ] Add pagination controls to template -- [ ] Update `web/src/bookshelf.ts` - remove async, load from server JSON -- [ ] Build TypeScript (`npm run build:ts`) -- [ ] Rebuild container (`podman compose build`) - -### Testing -- [ ] Test initial page load -- [ ] Test library switching -- [ ] Test filter application -- [ ] Test pagination -- [ ] Test empty library -- [ ] Test browser back/forward -- [ ] Test with multiple users -- [ ] Performance benchmark comparison - -### Documentation -- [ ] Update user documentation if needed -- [ ] Add comments to code explaining SSR logic -- [ ] Document any new query parameters - ---- - -## References - -- Current issue: Discussion about SSR-first implementation -- Dashboard SSR: `internal/router/frontend.go:172-254` -- Media handler: `internal/handlers/media.go:706-766` -- BookCard component: `templates/dashboard.templ:153-219` -- Saved filters: `SAVED_FILTERS_IMPLEMENTATION.md` (if exists) - ---- - -## Questions & Decisions Log - -### Q1: Should saved filters be SSR'd? -**Decision:** YES - To comply with PROJECT_GUIDELINES.md. No data fetching in x-init allowed. - -### Q2: Do we need to write new backend code? -**Decision:** NO - All database queries already exist. Just call `cfg.Queries.GetSavedFilters()` directly from `frontend.go`. - -### Q3: Should we SSR search results? -**Decision:** No - Let HTMX handle search for simplicity. Search is less common than initial page load. - -### Q4: Should we add accurate pagination count? -**Decision:** Not in Phase 1. Use `len(books)` for now. Can add `COUNT(*)` query in Phase 2 if needed. - -### Q5: What if book fetching fails on server? -**Decision:** Show empty grid with friendly message. Log error but don't crash the page. - -### Q6: Should we respect URL params on initial load? -**Decision:** Respect `limit` and `offset` for pagination. Don't respect filter params (let HTMX handle those). - ---- - -**Last Updated:** March 21, 2026 -**Document Version:** 3.0 (Simplified - No new backend code needed) -**Status:** Ready for Implementation -**Guideline Compliance:** ✅ Fully compliant with PROJECT_GUIDELINES.md SSR-first principles -**Backend Changes:** ✅ None required - All queries exist