From 30c8132c96ad95d4df1de044ed55572e629b806b Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 15 Mar 2026 21:20:56 -0400 Subject: [PATCH] docs: add bookshelf and collections filter implementation plan - Create comprehensive implementation plan for restoring bookshelf page - Add detailed specifications for collections book picker modal - Document SSR-first architecture with Alpine.js + HTMX pattern - Define 2-fold use case: bookshelf browsing + collections book selection - Include Phase 1-4 breakdown with technical specifications - Note existing /api/media-items/filtered API will be used - Note AddBookToCollection handler already exists in collections.go - Follow PROJECT_GUIDELINES.md and ALPINE_COMPLETION_GUIDE.md principles - Estimate 8-10 hours implementation time This plan restores functionality lost in commit 2df2b2d when bookshelf route was removed and consolidated into dashboard. The backend filtering API and book addition endpoints already exist and are functional. --- BOOKSHELF_COLLECTIONS_FILTER_PLAN.md | 1154 ++++++++++++++++++++++++++ 1 file changed, 1154 insertions(+) create mode 100644 BOOKSHELF_COLLECTIONS_FILTER_PLAN.md 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