docs: add implementation plans for SSR bookshelf and GET /:id endpoint
Add comprehensive implementation planning documents for two features: SSR_BOOKSHELF_IMPLEMENTATION.md: - Complete SSR-first bookshelf page implementation plan - Server-side rendering of books and saved filters - Template changes with pagination controls - Client-side TypeScript refactor (remove async x-init) - Testing plan with performance benchmarks - Rollback procedures and potential issues - 803 lines covering full implementation lifecycle GET_SAVED_FILTER_BY_ID_IMPLEMENTATION.md: - Plan for GET /api/saved-filters/:id endpoint - Reuses existing GetSavedFilterByID database query - Handler and service layer implementation - Integration tests covering all contexts (no auth, user, different users) - Bruno API collection YAML file - API documentation updates - Security considerations (404 for cross-user access) - Future enhancements (caching, batch operations) Both documents follow PROJECT_GUIDELINES.md patterns: - Service layer architecture - Test helpers usage - Bruno YAML documentation - Comprehensive testing plans - Rollback procedures Stored in git history for future reference and implementation.
This commit is contained in:
@@ -0,0 +1,740 @@
|
|||||||
|
# GET /api/saved-filters/:id Endpoint Implementation Plan
|
||||||
|
|
||||||
|
**Status:** Planning Phase
|
||||||
|
**Created:** March 21, 2026
|
||||||
|
**Priority:** Medium
|
||||||
|
**Complexity:** Low
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add a **GET /api/saved-filters/:id** endpoint to retrieve a single saved filter by ID. This enables future mobile/SPA clients to fetch filter details on-demand without relying on server-side rendering.
|
||||||
|
|
||||||
|
### Key Decision Points
|
||||||
|
|
||||||
|
- ✅ **Reuse existing service method** - `GetSavedFilterByID` query already exists
|
||||||
|
- ✅ **Follow established patterns** - Match existing handler structure
|
||||||
|
- ✅ **Minimal code changes** - Only add one new handler method
|
||||||
|
- ✅ **Backward compatible** - Doesn't affect existing endpoints
|
||||||
|
- ✅ **Future-proof** - Enables mobile/SPA clients
|
||||||
|
- ✅ **Follows PROJECT_GUIDELINES.md** - Service layer, test helpers, docs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State Analysis
|
||||||
|
|
||||||
|
### Existing Saved Filters API
|
||||||
|
|
||||||
|
**Endpoints Implemented:**
|
||||||
|
- ✅ `GET /api/saved-filters?resource_type=X` - List all filters (Line 54 in filters.go)
|
||||||
|
- ✅ `POST /api/saved-filters` - Create filter (Line 91 in filters.go)
|
||||||
|
- ✅ `PUT /api/saved-filters/:id` - Update filter (Line 160 in filters.go)
|
||||||
|
- ✅ `DELETE /api/saved-filters/:id` - Delete filter (Line 138 in filters.go)
|
||||||
|
|
||||||
|
**Database Query Already Exists:**
|
||||||
|
```go
|
||||||
|
// internal/database/queries.sql.go - Line 4480+
|
||||||
|
func (q *Queries) GetSavedFilterByID(ctx context.Context, db database.GetSavedFilterByIDParams) (database.SavedFilters, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Service Layer Already Uses It:**
|
||||||
|
```go
|
||||||
|
// internal/services/filters.go:71-77
|
||||||
|
existing, err := s.db.GetSavedFilterByID(ctx, database.GetSavedFilterByIDParams{
|
||||||
|
ID: pgtype.UUID{Bytes: filterID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why This Endpoint Is Needed
|
||||||
|
|
||||||
|
**Current Limitation:**
|
||||||
|
- SSR approach stores filter data in HTML (data attributes)
|
||||||
|
- Mobile apps can't access HTML data attributes
|
||||||
|
- SPA clients need direct API access to filter details
|
||||||
|
- Future clients need RESTful CRUD operations
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Add GET /:id endpoint following REST conventions
|
||||||
|
- Reuse existing service/database code
|
||||||
|
- Follow established handler patterns
|
||||||
|
- Complete CRUD API for saved filters
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1: Add Handler Method
|
||||||
|
|
||||||
|
#### File: `internal/handlers/filters.go`
|
||||||
|
|
||||||
|
**Location:** After `GetSavedFilters` method (around line 88)
|
||||||
|
|
||||||
|
**Change Type:** Add new handler method
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// GetSavedFilterByID - GET /api/saved-filters/:id
|
||||||
|
// Retrieve a single saved filter by ID
|
||||||
|
func (h *FiltersHandler) GetSavedFilterByID(c *echo.Context) error {
|
||||||
|
user := c.Get("user").(database.Users)
|
||||||
|
userUUID := uuid.UUID(user.ID.Bytes)
|
||||||
|
|
||||||
|
// Parse filter ID from URL parameter
|
||||||
|
filterID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||||
|
"error": "invalid filter ID",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use service layer (includes business logic + ownership verification)
|
||||||
|
filter, err := h.filtersService.GetSavedFilterByID(c.Request().Context(), userUUID, filterID)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "filter not found or access denied" {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{
|
||||||
|
"error": "filter not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": "failed to fetch filter",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return JSON response (JSONB handled automatically)
|
||||||
|
response := SavedFilterResponse{
|
||||||
|
ID: filterID,
|
||||||
|
Name: filter.Name,
|
||||||
|
ResourceType: filter.ResourceType,
|
||||||
|
Filters: json.RawMessage(filter.Filters), // Return JSONB as-is
|
||||||
|
CreatedAt: filter.CreatedAt.Time.Format(time.RFC3339),
|
||||||
|
UpdatedAt: filter.UpdatedAt.Time.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- ✅ Follows existing handler pattern (same as `GetSavedFilters`)
|
||||||
|
- ✅ Uses service layer (no direct database access)
|
||||||
|
- ✅ Returns `SavedFilterResponse` struct (already defined)
|
||||||
|
- ✅ Proper error handling (400, 404, 500)
|
||||||
|
- ✅ Ownership verification via service layer
|
||||||
|
- ✅ Matches existing authentication pattern
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: Add Service Layer Method
|
||||||
|
|
||||||
|
#### File: `internal/services/filters.go`
|
||||||
|
|
||||||
|
**Location:** After `GetSavedFilters` method (around line 32)
|
||||||
|
|
||||||
|
**Change Type:** Add new service method
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// GetSavedFilterByID - Retrieve a single saved filter by ID
|
||||||
|
func (s *FiltersService) GetSavedFilterByID(ctx context.Context, userID uuid.UUID, filterID uuid.UUID) (database.SavedFilters, error) {
|
||||||
|
filter, err := s.db.GetSavedFilterByID(ctx, database.GetSavedFilterByIDParams{
|
||||||
|
ID: pgtype.UUID{Bytes: filterID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return database.SavedFilters{}, fmt.Errorf("filter not found or access denied: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- ✅ Business logic: Verify filter exists and belongs to user
|
||||||
|
- ✅ Returns descriptive error for "not found or access denied"
|
||||||
|
- ✅ Reuses existing database query
|
||||||
|
- ✅ Follows existing service pattern (similar to UpdateSavedFilter:71-77)
|
||||||
|
- ✅ No new database code needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: Register Route
|
||||||
|
|
||||||
|
#### File: `internal/router/filters.go`
|
||||||
|
|
||||||
|
**Location:** Line 12 (after existing routes)
|
||||||
|
|
||||||
|
**Change Type:** Add route registration
|
||||||
|
|
||||||
|
**Current Code:**
|
||||||
|
```go
|
||||||
|
filters.GET("", cfg.FiltersHandler.GetSavedFilters)
|
||||||
|
filters.POST("", cfg.FiltersHandler.CreateSavedFilter)
|
||||||
|
filters.PUT("/:id", cfg.FiltersHandler.UpdateSavedFilter)
|
||||||
|
filters.DELETE("/:id", cfg.FiltersHandler.DeleteSavedFilter)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Updated Code:**
|
||||||
|
```go
|
||||||
|
filters.GET("", cfg.FiltersHandler.GetSavedFilters)
|
||||||
|
filters.GET("/:id", cfg.FiltersHandler.GetSavedFilterByID) // NEW
|
||||||
|
filters.POST("", cfg.FiltersHandler.CreateSavedFilter)
|
||||||
|
filters.PUT("/:id", cfg.FiltersHandler.UpdateSavedFilter)
|
||||||
|
filters.DELETE("/:id", cfg.FiltersHandler.DeleteSavedFilter)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- ✅ Follows RESTful routing conventions
|
||||||
|
- ✅ `/:id` route must come BEFORE `""` route (already correct order)
|
||||||
|
- ✅ Matches existing route patterns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: Integration Tests
|
||||||
|
|
||||||
|
#### File: `cmd/server/tests/filters_test.go`
|
||||||
|
|
||||||
|
**Location:** After existing tests (around line 216)
|
||||||
|
|
||||||
|
**Change Type:** Add test cases
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
t.Run("GET /api/saved-filters/:id retrieves single filter", func(t *testing.T) {
|
||||||
|
// First create a filter
|
||||||
|
reqBody := map[string]interface{}{
|
||||||
|
"name": "Test Filter",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": map[string]string{"search": "test"},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(reqBody)
|
||||||
|
|
||||||
|
createReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/saved-filters", bytes.NewBuffer(body))
|
||||||
|
createReq.Header.Set("Content-Type", "application/json")
|
||||||
|
createReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
|
||||||
|
createResp, err := client.Do(createReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer createResp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusCreated, createResp.StatusCode)
|
||||||
|
|
||||||
|
var createdFilter map[string]interface{}
|
||||||
|
json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||||
|
filterID := createdFilter["id"].(string)
|
||||||
|
|
||||||
|
// Now retrieve the filter by ID
|
||||||
|
getReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
|
||||||
|
getReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
|
||||||
|
getResp, err := client.Do(getReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer getResp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||||
|
|
||||||
|
var retrievedFilter map[string]interface{}
|
||||||
|
json.NewDecoder(getResp.Body).Decode(&retrievedFilter)
|
||||||
|
assert.Equal(t, "Test Filter", retrievedFilter["name"])
|
||||||
|
assert.Equal(t, "media-items", retrievedFilter["resource_type"])
|
||||||
|
assert.Equal(t, filterID, retrievedFilter["id"])
|
||||||
|
assert.NotEmpty(t, retrievedFilter["filters"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GET /api/saved-filters/:id without auth returns 401", func(t *testing.T) {
|
||||||
|
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/550e8400-e29b-41d4-a716-446655440000", nil)
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GET /api/saved-filters/:id with invalid UUID returns 400", func(t *testing.T) {
|
||||||
|
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/invalid-uuid", nil)
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GET /api/saved-filters/:id with non-existent filter returns 404", func(t *testing.T) {
|
||||||
|
fakeID := uuid.New().String()
|
||||||
|
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+fakeID, nil)
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GET /api/saved-filters/:id from different user returns 404", func(t *testing.T) {
|
||||||
|
// Create filter with user 1
|
||||||
|
reqBody := map[string]interface{}{
|
||||||
|
"name": "User1 Filter",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": map[string]string{"search": "test"},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(reqBody)
|
||||||
|
|
||||||
|
createReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/saved-filters", bytes.NewBuffer(body))
|
||||||
|
createReq.Header.Set("Content-Type", "application/json")
|
||||||
|
createReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
|
||||||
|
createResp, err := client.Do(createReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer createResp.Body.Close()
|
||||||
|
|
||||||
|
var createdFilter map[string]interface{}
|
||||||
|
json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||||
|
filterID := createdFilter["id"].(string)
|
||||||
|
|
||||||
|
// Try to access with user 2 (different token)
|
||||||
|
// Note: This requires setup.AdminToken or creating a second user
|
||||||
|
// For now, we'll test with a non-existent filter to verify 404 logic
|
||||||
|
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+setup.AdminToken) // Different user
|
||||||
|
|
||||||
|
getResp, err := client.Do(httpReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer getResp.Body.Close()
|
||||||
|
|
||||||
|
// Should return 404 (not found or access denied)
|
||||||
|
assert.Equal(t, http.StatusNotFound, getResp.StatusCode)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- ✅ Uses `setupTestServer(t)` helper (PROJECT_GUIDELINES.md compliant)
|
||||||
|
- ✅ Tests all contexts: no auth, user, different user
|
||||||
|
- ✅ Tests error cases: 400, 401, 404
|
||||||
|
- ✅ Tests success case: 200
|
||||||
|
- ✅ Follows existing test pattern in filters_test.go
|
||||||
|
- ✅ Single setup shared across subtests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: Bruno API Collection
|
||||||
|
|
||||||
|
#### File: `bruno/saved-filters/Get Saved Filter By ID.yml`
|
||||||
|
|
||||||
|
**Location:** `/home/nymusicman/Code/bookhoard/bruno/saved-filters/`
|
||||||
|
|
||||||
|
**Change Type:** Create new file
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
info:
|
||||||
|
name: Get Saved Filter By ID
|
||||||
|
type: http
|
||||||
|
seq: 4
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: '{{base_url}}/api/saved-filters/{{filter_id}}'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
docs: |-
|
||||||
|
## Get Saved Filter By ID
|
||||||
|
|
||||||
|
Retrieves a single saved filter by its ID.
|
||||||
|
|
||||||
|
**Method:** GET
|
||||||
|
|
||||||
|
**Endpoint:** /api/saved-filters/:id
|
||||||
|
|
||||||
|
**Authentication:** Required (Bearer token)
|
||||||
|
|
||||||
|
**URL Parameters:**
|
||||||
|
- `id` (UUID, required): The unique identifier of the saved filter
|
||||||
|
- Example: "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
- Must be a valid UUID format
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
Single saved filter object:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"name": "My Sci-Fi Books",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {
|
||||||
|
"genre_filter": "Science Fiction",
|
||||||
|
"sort": "title ASC"
|
||||||
|
},
|
||||||
|
"created_at": "2024-03-20T12:00:00Z",
|
||||||
|
"updated_at": "2024-03-20T12:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes:**
|
||||||
|
- 200: Success - Returns the saved filter
|
||||||
|
- 400: Bad Request - Invalid filter ID format
|
||||||
|
- 401: Unauthorized - Invalid or missing authentication token
|
||||||
|
- 404: Not Found - Filter doesn't exist or doesn't belong to user
|
||||||
|
|
||||||
|
**Example Usage:**
|
||||||
|
```bash
|
||||||
|
# Get a specific saved filter
|
||||||
|
curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
"{{base_url}}/api/saved-filters/550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Filters are user-specific (ownership verified via JWT)
|
||||||
|
- Returns 404 if filter doesn't exist OR doesn't belong to authenticated user
|
||||||
|
- Use `GET /api/saved-filters?resource_type=X` to list all filters first
|
||||||
|
|
||||||
|
**Scenarios:**
|
||||||
|
- Get filter by ID from list response
|
||||||
|
- Load filter details for editing
|
||||||
|
- Verify filter exists before updating
|
||||||
|
- Mobile app on-demand filter loading
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- ✅ Follows existing Bruno YAML pattern
|
||||||
|
- ✅ Includes comprehensive documentation
|
||||||
|
- ✅ Uses variable placeholders (`{{filter_id}}`)
|
||||||
|
- ✅ Documents all status codes
|
||||||
|
- ✅ Includes example usage
|
||||||
|
- ✅ Placed in correct directory structure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 6: API Documentation
|
||||||
|
|
||||||
|
#### File: `docs/developer/api/saved-filters/index.md`
|
||||||
|
|
||||||
|
**Location:** After DELETE section (around line 106)
|
||||||
|
|
||||||
|
**Change Type:** Add new endpoint documentation
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### GET /api/saved-filters/:id
|
||||||
|
|
||||||
|
Retrieve a single saved filter by ID.
|
||||||
|
|
||||||
|
**URL Parameters:**
|
||||||
|
- `id` (UUID, required) - Filter ID to retrieve
|
||||||
|
|
||||||
|
**Response:** Single saved filter object (HTTP 200)
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer YOUR_TOKEN" \\
|
||||||
|
"http://localhost:8765/api/saved-filters/550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"name": "My Sci-Fi Books",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {
|
||||||
|
"genre_filter": "Science Fiction",
|
||||||
|
"sort": "title ASC"
|
||||||
|
},
|
||||||
|
"created_at": "2024-03-20T12:00:00Z",
|
||||||
|
"updated_at": "2024-03-20T12:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
- 400 Bad Request - Invalid filter ID format
|
||||||
|
- 401 Unauthorized - Invalid or missing authentication token
|
||||||
|
- 404 Not Found - Filter doesn't exist or doesn't belong to user
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- Mobile apps: Fetch filter details on-demand
|
||||||
|
- SPAs: Load filter data without page reload
|
||||||
|
- Editing: Pre-fill filter update form
|
||||||
|
- Verification: Check filter exists before operations
|
||||||
|
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- ✅ Follows existing documentation pattern
|
||||||
|
- ✅ Includes example requests/responses
|
||||||
|
- ✅ Documents all error codes
|
||||||
|
- ✅ Added to existing index.md file
|
||||||
|
- ✅ Provides use cases for new endpoint
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Plan
|
||||||
|
|
||||||
|
### Test Cases
|
||||||
|
|
||||||
|
#### 1. Success Case - Retrieve Existing Filter
|
||||||
|
**Steps:**
|
||||||
|
1. Create a saved filter via POST
|
||||||
|
2. GET /api/saved-filters/:id with returned ID
|
||||||
|
3. Verify response matches created filter
|
||||||
|
|
||||||
|
**Expected:**
|
||||||
|
- ✅ HTTP 200 status
|
||||||
|
- ✅ Filter object returned
|
||||||
|
- ✅ All fields present (id, name, resource_type, filters, timestamps)
|
||||||
|
- ✅ Filters field is valid JSON
|
||||||
|
|
||||||
|
#### 2. Error Case - Invalid UUID Format
|
||||||
|
**Steps:**
|
||||||
|
1. GET /api/saved-filters/invalid-uuid
|
||||||
|
2. Check response
|
||||||
|
|
||||||
|
**Expected:**
|
||||||
|
- ✅ HTTP 400 status
|
||||||
|
- ✅ Error message: "invalid filter ID"
|
||||||
|
|
||||||
|
#### 3. Error Case - Filter Not Found
|
||||||
|
**Steps:**
|
||||||
|
1. Generate random UUID
|
||||||
|
2. GET /api/saved-filters/:random-uuid
|
||||||
|
3. Check response
|
||||||
|
|
||||||
|
**Expected:**
|
||||||
|
- ✅ HTTP 404 status
|
||||||
|
- ✅ Error message: "filter not found"
|
||||||
|
|
||||||
|
#### 4. Error Case - No Authentication
|
||||||
|
**Steps:**
|
||||||
|
1. GET /api/saved-filters/:id without Authorization header
|
||||||
|
2. Check response
|
||||||
|
|
||||||
|
**Expected:**
|
||||||
|
- ✅ HTTP 401 status
|
||||||
|
- ✅ Standard auth error response
|
||||||
|
|
||||||
|
#### 5. Security Case - Cross-User Access
|
||||||
|
**Steps:**
|
||||||
|
1. User A creates filter
|
||||||
|
2. User B tries to GET User A's filter
|
||||||
|
3. Check response
|
||||||
|
|
||||||
|
**Expected:**
|
||||||
|
- ✅ HTTP 404 status (not 403 - hide existence)
|
||||||
|
- ✅ Error message: "filter not found"
|
||||||
|
- ✅ No information leakage about other users' filters
|
||||||
|
|
||||||
|
#### 6. Integration Case - From List to Detail
|
||||||
|
**Steps:**
|
||||||
|
1. GET /api/saved-filters?resource_type=media-items
|
||||||
|
2. Pick first filter ID from list
|
||||||
|
3. GET /api/saved-filters/:id
|
||||||
|
4. Verify data consistency
|
||||||
|
|
||||||
|
**Expected:**
|
||||||
|
- ✅ IDs match
|
||||||
|
- ✅ Names match
|
||||||
|
- ✅ Filters data matches
|
||||||
|
- ✅ Timestamps match
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of Changes
|
||||||
|
|
||||||
|
| File | Lines Changed | Type | Complexity |
|
||||||
|
|------|--------------|------|------------|
|
||||||
|
| `internal/handlers/filters.go` | +35 lines | Add handler method | Low |
|
||||||
|
| `internal/services/filters.go` | +8 lines | Add service method | Low |
|
||||||
|
| `internal/router/filters.go` | +1 line | Add route registration | Low |
|
||||||
|
| `cmd/server/tests/filters_test.go` | +120 lines | Add integration tests | Medium |
|
||||||
|
| `bruno/saved-filters/Get Saved Filter By ID.yml` | +80 lines | Create Bruno file | Low |
|
||||||
|
| `docs/developer/api/saved-filters/index.md` | +35 lines | Update documentation | Low |
|
||||||
|
|
||||||
|
**Total:** ~279 lines of code across 6 files
|
||||||
|
|
||||||
|
**No new database code needed** - All queries and handlers already exist!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Advantages of This Approach
|
||||||
|
|
||||||
|
✅ **100% Pattern-compliant** - Follows established handler/service/test patterns
|
||||||
|
✅ **Minimal code changes** - Reuses existing database queries and service methods
|
||||||
|
✅ **Complete CRUD API** - Adds missing GET /:id endpoint
|
||||||
|
✅ **Future-proof** - Enables mobile/SPA clients
|
||||||
|
✅ **Backward compatible** - Doesn't affect existing endpoints
|
||||||
|
✅ **Security maintained** - Ownership verification via service layer
|
||||||
|
✅ **Well-tested** - Comprehensive integration tests using test_helpers
|
||||||
|
✅ **Well-documented** - Bruno YAML + API docs
|
||||||
|
✅ **No database changes** - All queries already exist
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
If issues arise, rollback is straightforward:
|
||||||
|
|
||||||
|
### Step 1: Revert Handler Changes
|
||||||
|
```bash
|
||||||
|
git checkout HEAD~1 internal/handlers/filters.go
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Revert Service Changes
|
||||||
|
```bash
|
||||||
|
git checkout HEAD~1 internal/services/filters.go
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Revert Router Changes
|
||||||
|
```bash
|
||||||
|
git checkout HEAD~1 internal/router/filters.go
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Revert Test Changes
|
||||||
|
```bash
|
||||||
|
git checkout HEAD~1 cmd/server/tests/filters_test.go
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Remove New Files
|
||||||
|
```bash
|
||||||
|
rm bruno/saved-filters/Get\ Saved\ Filter\ By\ ID.yml
|
||||||
|
git checkout HEAD~1 docs/developer/api/saved-filters/index.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Rebuild
|
||||||
|
```bash
|
||||||
|
podman compose down
|
||||||
|
podman compose build
|
||||||
|
podman compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Potential Issues & Mitigations
|
||||||
|
|
||||||
|
### Issue 1: Route Order Conflict
|
||||||
|
**Problem:** `/:id` route must come before `""` route
|
||||||
|
|
||||||
|
**Mitigation:**
|
||||||
|
- Already correct - existing routes are in proper order
|
||||||
|
- Verified: `filters.PUT("/:id")` is after `filters.GET("")`
|
||||||
|
|
||||||
|
### Issue 2: UUID Parsing Errors
|
||||||
|
**Problem:** Malformed UUIDs cause panic
|
||||||
|
|
||||||
|
**Mitigation:**
|
||||||
|
- Handler uses `uuid.Parse()` with error checking
|
||||||
|
- Returns 400 for invalid UUID format
|
||||||
|
- Follows existing pattern in UpdateSavedFilter
|
||||||
|
|
||||||
|
### Issue 3: Service Layer Error Messages
|
||||||
|
**Problem:** Database errors leak implementation details
|
||||||
|
|
||||||
|
**Mitigation:**
|
||||||
|
- Service layer wraps errors in descriptive messages
|
||||||
|
- Returns "filter not found or access denied" (hides which)
|
||||||
|
- Handler maps service errors to HTTP status codes
|
||||||
|
|
||||||
|
### Issue 4: Test Database Cleanup
|
||||||
|
**Problem:** Tests create filters that persist
|
||||||
|
|
||||||
|
**Mitigation:**
|
||||||
|
- Uses `setupTestServer(t)` which handles cleanup
|
||||||
|
- Test server is isolated from development database
|
||||||
|
- Tests follow existing pattern (no special cleanup needed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Phase 2 Improvements (Optional)
|
||||||
|
|
||||||
|
1. **Caching Headers**
|
||||||
|
- Add `Cache-Control: private, max-age=300` for 5-minute client caching
|
||||||
|
- Add `ETag` header for conditional requests
|
||||||
|
- Reduces unnecessary API calls
|
||||||
|
|
||||||
|
2. **Filter Validation Endpoint**
|
||||||
|
- Add `POST /api/saved-filters/:id/validate` to test filters
|
||||||
|
- Returns count of matching items without applying filter
|
||||||
|
- Useful for preview before loading
|
||||||
|
|
||||||
|
3. **Batch Filter Load**
|
||||||
|
- Add `POST /api/saved-filters/batch` with `ids: []` array
|
||||||
|
- Reduces round-trips for mobile apps
|
||||||
|
- Returns array of filter objects
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
- [ ] Add `GetSavedFilterByID()` method to `internal/handlers/filters.go`
|
||||||
|
- [ ] Add `GetSavedFilterByID()` method to `internal/services/filters.go`
|
||||||
|
- [ ] Register route in `internal/router/filters.go`
|
||||||
|
- [ ] Add integration tests to `cmd/server/tests/filters_test.go`
|
||||||
|
- [ ] Create Bruno YAML file `bruno/saved-filters/Get Saved Filter By ID.yml`
|
||||||
|
- [ ] Update documentation `docs/developer/api/saved-filters/index.md`
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- [ ] Test success case (200 - filter found)
|
||||||
|
- [ ] Test error case (400 - invalid UUID)
|
||||||
|
- [ ] Test error case (404 - filter not found)
|
||||||
|
- [ ] Test error case (401 - no auth)
|
||||||
|
- [ ] Test security case (cross-user access returns 404)
|
||||||
|
- [ ] Test integration case (list → detail flow)
|
||||||
|
- [ ] Run full test suite: `go test ./... -v`
|
||||||
|
|
||||||
|
### Build & Deploy
|
||||||
|
- [ ] Build TypeScript: `npm run build:ts`
|
||||||
|
- [ ] Rebuild container: `podman compose build`
|
||||||
|
- [ ] Restart services: `podman compose up -d`
|
||||||
|
- [ ] Verify endpoint with Bruno or curl
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- [ ] Update API documentation
|
||||||
|
- [ ] Verify Bruno YAML renders correctly
|
||||||
|
- [ ] Test docs search finds new content
|
||||||
|
- [ ] Add comments to code explaining logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Current saved filters implementation: `internal/handlers/filters.go`
|
||||||
|
- Service layer pattern: `internal/services/filters.go`
|
||||||
|
- Existing tests: `cmd/server/tests/filters_test.go`
|
||||||
|
- API documentation: `docs/developer/api/saved-filters/index.md`
|
||||||
|
- Bruno collection: `bruno/saved-filters/`
|
||||||
|
- PROJECT_GUIDELINES.md: Service layer architecture, testing patterns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Questions & Decisions Log
|
||||||
|
|
||||||
|
### Q1: Should we add pagination to GET /api/saved-filters?
|
||||||
|
**Decision:** Not in this implementation. Current endpoint returns all filters for a resource type, which is reasonable given user-specific data and likely small counts (< 100). Can add pagination later if needed.
|
||||||
|
|
||||||
|
### Q2: Should we add filtering/sorting to GET /api/saved-filters?
|
||||||
|
**Decision:** Not in this implementation. Current implementation filters by `resource_type` query parameter which is sufficient. Can add more sophisticated filtering later if needed.
|
||||||
|
|
||||||
|
### Q3: Should we return 403 or 404 for cross-user access attempts?
|
||||||
|
**Decision:** Return 404 to hide existence of other users' filters (security best practice). Don't reveal whether a filter exists - just "not found or access denied".
|
||||||
|
|
||||||
|
### Q4: Should we add ETag/If-None-Match support?
|
||||||
|
**Decision:** Not in Phase 1. Can add in Phase 2 as a caching enhancement. For now, simple GET /:id is sufficient.
|
||||||
|
|
||||||
|
### Q5: Do we need to validate that the filter ID belongs to the resource type?
|
||||||
|
**Decision:** Not necessary. The `resource_type` is part of the filter object returned. Clients can verify it matches their expectations. Ownership verification is already handled by the service layer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated:** March 21, 2026
|
||||||
|
**Document Version:** 1.0
|
||||||
|
**Status:** Ready for Implementation
|
||||||
|
**Guideline Compliance:** ✅ Fully compliant with PROJECT_GUIDELINES.md
|
||||||
|
**Backend Changes:** ✅ Minimal - Reuses existing queries and patterns
|
||||||
@@ -0,0 +1,803 @@
|
|||||||
|
# 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