docs: remove SSR bookshelf implementation plan after completion
Remove SSR_BOOKSHELF_IMPLEMENTATION.md as the SSR-first bookshelf feature has been successfully implemented and deployed. The implementation plan served its purpose: - Guided SSR-first bookshelf page implementation - Server-side rendering of books and saved filters - Template changes and TypeScript refactoring - All components now follow SSR-first principles The plan document (803 lines) has been preserved in git history: - commit059a281: Initial documentation - commit63816fe: Implementation reference Keeping the plan document would be redundant since: - Feature is complete and working - Code contains inline comments - git history preserves the planning process - No longer needed for future work Follows YAGNI principle - remove planning docs after implementation.
This commit is contained in:
@@ -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
|
||||
<!-- Books Grid -->
|
||||
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
|
||||
<!-- Books will be loaded here via HTMX -->
|
||||
</div>
|
||||
```
|
||||
|
||||
**Updated:**
|
||||
```templ
|
||||
<!-- Books Grid -->
|
||||
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
|
||||
if len(books) > 0 {
|
||||
for _, book := range books {
|
||||
@BookCard(book)
|
||||
}
|
||||
} else {
|
||||
<!-- Empty state -->
|
||||
<div class="col-span-full text-center py-12" style="color: var(--text-secondary);">
|
||||
<p class="text-lg mb-2">📚 No books found</p>
|
||||
<p class="text-sm">Try adjusting your filters or add some books to your library.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Location 3: BEFORE closing `</body>` tag (around line 345)**
|
||||
|
||||
**Add hidden script tag for server data:**
|
||||
```templ
|
||||
<!-- Server-rendered data for client-side JavaScript -->
|
||||
<script id="saved-filters-data" type="application/json">
|
||||
{ savedFiltersJSON }
|
||||
</script>
|
||||
```
|
||||
|
||||
**Location 3:** Lines 280-282 (pagination section)
|
||||
|
||||
**Current:**
|
||||
```templ
|
||||
<!-- Pagination -->
|
||||
<div id="pagination" class="mt-6 flex justify-center gap-2">
|
||||
<!-- Pagination will be loaded here via HTMX -->
|
||||
</div>
|
||||
```
|
||||
|
||||
**Updated:**
|
||||
```templ
|
||||
<!-- Pagination -->
|
||||
<div id="pagination" class="mt-6 flex justify-center gap-2">
|
||||
if count > 0 {
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg border disabled:opacity-50"
|
||||
style="border-color: var(--border); color: var(--text-primary);"
|
||||
hx-get="/api/media-items/filtered?library_id={ currentLibraryID }&limit={ limit }&offset={ offset - limit }"
|
||||
hx-target="#books-grid"
|
||||
hx-include="#filter-form"
|
||||
disabled={ offset <= 0 ? "true" : "" }
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
|
||||
<span class="px-4 py-2" style="color: var(--text-secondary);">
|
||||
Page { offset / limit + 1 }
|
||||
</span>
|
||||
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg border disabled:opacity-50"
|
||||
style="border-color: var(--border); color: var(--text-primary);"
|
||||
hx-get="/api/media-items/filtered?library_id={ currentLibraryID }&limit={ limit }&offset={ offset + limit }"
|
||||
hx-target="#books-grid"
|
||||
hx-include="#filter-form"
|
||||
disabled={ count < limit ? "true" : "" }
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
```
|
||||
|
||||
**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
|
||||
Reference in New Issue
Block a user