diff --git a/BOOKSHELF_COLLECTIONS_FILTER_PLAN.md b/BOOKSHELF_COLLECTIONS_FILTER_PLAN.md new file mode 100644 index 0000000..9b72c23 --- /dev/null +++ b/BOOKSHELF_COLLECTIONS_FILTER_PLAN.md @@ -0,0 +1,1154 @@ +# Bookshelf & Collections Filter Implementation Plan + +## Executive Summary + +Restore bookshelf page as an advanced filtering interface and fix collections book picker modal. Both components will use the existing `/api/media-items/filtered` API with SSR-first architecture. + +**Status:** Orphaned code restoration + new feature implementation +**Priority:** High (core library browsing functionality) +**Estimated Time:** 8-10 hours +**Backend Changes:** None (API already exists) +**Frontend Changes:** New templates + TypeScript + +--- + +## Table of Contents + +1. [Current State](#current-state) +2. [Architecture](#architecture) +3. [Phase 1: Bookshelf Page](#phase-1-bookshelf-page) +4. [Phase 2: Collections Book Picker](#phase-2-collections-book-picker) +5. [Phase 3: Shared Components](#phase-3-shared-components) +6. [Phase 4: Integration & Testing](#phase-4-integration--testing) +7. [Success Criteria](#success-criteria) + +--- + +## Current State + +### What Exists (Backend) ✅ + +**API Endpoint:** `/api/media-items/filtered` +- Supports: author, series, genre, language, year range, cover images +- Sorting by any field +- Pagination (limit/offset) +- **Status:** Fully functional, unused + +### What's Broken (Frontend) ❌ + +**Bookshelf Page:** +- Template: `templates/bookshelf.templ` (exists, orphaned) +- TypeScript: `web/src/bookshelf.ts` (exists, imported but unused) +- Route: `/bookshelf` (removed in commit 2df2b2d) +- Status: Template/JS exist, no route registration + +**Collections Book Picker:** +- Button: Disabled in `templates/collections.templ:153-154` +- Functions: Deleted in commit 93710a1 +- Status: Feature completely non-functional + +--- + +## Architecture + +### SSR-First Principles (Per PROJECT_GUIDELINES.md & ALPINE_COMPLETION_GUIDE.md) + +**Page Type:** Type 2 (SSR + Interactive) + +**SSR (Server-Side Rendering):** +- ✅ Backend renders initial page with data +- ✅ Complete HTML sent to browser (no empty states) +- ✅ No data fetch on page load + +**Alpine.js (UI State):** +- ✅ Manages component state (modals, dropdowns, filters) +- ✅ `x-show` for visibility (NOT `class="hidden"`) +- ✅ `x-data` for state storage +- ✅ x-init ONLY for setup (event listeners) +- ❌ NEVER fetch data in x-init + +**HTMX (Dynamic Updates):** +- ✅ Swaps HTML fragments without page reload +- ✅ Server returns new HTML +- ✅ Client swaps only changed portion +- ✅ No Alpine state lost (uses Alpine.store) + +**TypeScript (Business Logic):** +- ✅ Pure functions +- ✅ API calls +- ✅ Data processing +- ❌ NO manual DOM manipulation +- ❌ NO `classList.add/remove("hidden")` +- ❌ NO `getElementById` for show/hide + +**When to Use What:** +- **`class="hidden"`**: Structural hiding only (forms, utilities) +- **`x-show`**: All stateful UI (modals, dropdowns, dynamic content) +- **`style="display: none;"`**: FOUC prevention with `x-show` (Alpine guide recommends this) + +### Component Design + +**Shared Filtering Component:** +- Reusable across bookshelf page and collections picker +- Alpine.js for state management +- Server-side initial render +- HTMX for filter updates (no page reload) + +**Data Flow (SSR → HTMX → Alpine → TypeScript):** + +``` +Initial Page Load (SSR): + Server fetches libraries & initial books (paginated) + → Renders complete HTML with data + → Sends to browser + → Alpine initialized for UI state + +Filter Change (HTMX → Alpine): + User types in filter field + → Alpine updates local state (if needed) + → HTMX fetches filtered books (HTML) + → HTMX swaps book grid innerHTML + → Alpine re-initializes (maintains store state) + → User sees filtered results + +Multi-Select (Alpine Store): + User clicks checkbox + → Alpine.store.bookPicker.toggleBook(id) + → Checkbox state persisted in store + → User changes filter (HTMX swap) + → Grid re-rendered with new books + → Alpine re-applies :checked from store + → Selection persists across filter changes + +Form Submission (HTMX → TypeScript): + User clicks "Add Books" + → TypeScript reads from Alpine.store + → API call with all selected books + → Success: modal closes, page updates +``` + +--- + +## Phase 1: Bookshelf Page + +### 1.1 Restore Route Registration + +**File:** `internal/router/frontend.go` + +**Location:** After line 204 (before dashboard route) + +**Add route handler:** + +```go +// Bookshelf page - advanced library filtering interface +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, + } + } + + var buf bytes.Buffer + err = templates.BookShelf(user, libData, libraryID, errorMsg).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) +}) +``` + +**Why this approach:** +- SSR-first: Backend fetches libraries, not JavaScript +- Default library selection if none specified +- Error handling for no libraries +- Follows existing frontend.go patterns + +--- + +### 1.2 Update Bookshelf Template + +**File:** `templates/bookshelf.templ` + +**Complete rewrite (replace entire file):** + +```templ +package templates + +templ BookShelf(user User, libraries []LibraryData, currentLibraryID string, errorMessage string) { + + + + + + Library - Bookhoard + + + + + + @Header(user, "/bookshelf") + +
+ +
+
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+ + +
+ + +
+ +
+ + +
+ +
+
+ + + +
+ + +
+ +
+ + + + + + if errorMessage != "" { +
+

{ errorMessage }

+
+ } +
+ + + + + + + + +} +``` + +**Key Design Decisions:** + +1. **SSR-First:** + - Libraries fetched server-side + - Initial page render complete + - No data fetch in x-init + +2. **HTMX for Filtering:** + - Filter changes trigger HTMX requests + - Only book grid swaps (no full page reload) + - hx-include includes all filter values + +3. **Alpine.js for UI State:** + - Modal visibility (showSaveModal) + - Filter name input (filterName) + - No data fetching in Alpine + +4. **Progressive Enhancement:** + - Page works without JavaScript (server render) + - HTMX provides dynamic filtering + - Alpine adds modal interactions + +--- + +### 1.3 Create Bookshelf TypeScript + +**File:** `web/src/bookshelf.ts` + +**Replace entire file:** + +```typescript +// Bookshelf functionality - procedural/imperative style + +import { Alpine } from "./alpine"; +import { showToast } from "./toast"; + +// Filter state management (Alpine.js handles this, TS just saves/restores) + +async function loadSavedFilters(): Promise { + const token = localStorage.getItem("token"); + if (!token) return; + + try { + const response = await fetch("/api/bookshelf/filters", { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (response.ok) { + const filters = await response.json(); + localStorage.setItem("bookshelfFilters", JSON.stringify(filters)); + } + } catch (error) { + console.error("Failed to load saved filters:", error); + } +} + +async function saveFilter(event: Event): Promise { + event.preventDefault(); + const token = localStorage.getItem("token"); + if (!token) { + showToast("Not authenticated", "error"); + return; + } + + const filterForm = document.getElementById("filter-form") as HTMLFormElement; + const formData = new FormData(filterForm); + const filterData: Record = {}; + + formData.forEach((value, key) => { + filterData[key] = value.toString(); + }); + + // Add filter name from Alpine state + const filterName = (window as any).Alpine?.$store.bookshelf?.filterName; + if (!filterName) { + showToast("Please enter a filter name", "error"); + return; + } + + try { + const response = await fetch("/api/bookshelf/filters", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + name: filterName, + filters: filterData, + }), + }); + + if (response.ok) { + showToast("Filter saved successfully", "success"); + // Close modal via Alpine + (window as any).Alpine?.$store.bookshelf.showSaveModal = false; + loadSavedFilters(); + } else { + showToast("Failed to save filter", "error"); + } + } catch (error) { + console.error("Failed to save filter:", error); + showToast("Error saving filter", "error"); + } +} + +function initBookshelf(): void { + // Load saved filters on page load + loadSavedFilters(); + + // 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 + htmx.trigger(librarySelect, "change"); + } + } +} + +function clearFilters(): void { + const filterForm = document.getElementById("filter-form") as HTMLFormElement; + if (!filterForm) return; + + // Reset all form fields + const inputs = filterForm.querySelectorAll("input, select"); + inputs.forEach((input) => { + if (input instanceof HTMLInputElement && input.type === "checkbox") { + input.checked = false; + } else { + (input as HTMLInputElement).value = ""; + } + }); + + // Trigger HTMX reload with cleared filters + htmx.trigger(filterForm, "change"); +} + +function showSaveFilterModal(): void { + // Open modal via Alpine store + (window as any).Alpine?.$store.bookshelf.showSaveModal = true; +} + +// Alpine.js component +Alpine.data("bookshelf", () => ({ + showSaveModal: false, + filterName: "", + + initBookshelf, + clearFilters, + showSaveFilterModal, + saveFilter, +})); + +export { initBookshelf, clearFilters, showSaveFilterModal, saveFilter }; +``` + +**Why this approach:** + +1. **Procedural style:** No classes, no this-capture +2. **Alpine-first:** UI state in Alpine.data +3. **TypeScript for business logic:** Saving/loading filters +4. **No DOM manipulation:** All via Alpine/HTMX +5. **x-init for setup only:** No data fetching + +--- + +### 1.4 Update TypeScript Build + +**File:** `web/src/main.ts` + +**Line 9:** Remove orphaned import + +```typescript +// DELETE this line: +// import "./bookshelf"; + +// Bookshelf is now loaded via