Files
bookhoard/BOOK_PICKER_IMPL.md
T
john-okeefe f2cbb5a433 chore: add HTMX TypeScript types and book picker implementation plan
- Add htmx.d.ts with TypeScript type definitions for HTMX global
- Add BOOK_PICKER_IMPL.md with implementation plan for book picker modal
2026-03-16 16:24:26 -04:00

5.8 KiB

Book Picker Implementation Plan

Overview

Implement a client-side book picker modal for collections that allows users to search and select books to add to a collection.

Architecture: Client-side only (Alpine.js + TypeScript + HTMX for submission)

  • No SSR for book grid
  • Uses existing /api/media-items and /api/media-items/search APIs
  • Alpine manages checkbox state and rendering

Current State

What's Done

  • Collections "Add Books" button enabled
  • Book picker modal HTML in template
  • Alpine state management in collections.ts
  • Icon picker fix (showAllIcons, setupHTMXModalInit)

What's Broken

  • Book picker modal has HTMX-based search that calls non-existent endpoint
  • Checkboxes not rendered (API returns JSON, not HTML with checkboxes)

Implementation

Phase 1: Update collections.ts

Add the following methods to Alpine.data("collections", ...):

// State
bookPickerPage: number = 0,
bookPickerHasMore: boolean = true,
bookPickerLoading: boolean = false,

// Load books from API
async loadBooksForPicker(reset: boolean = false): Promise<void> {
  if (reset) {
    this.bookPickerPage = 0;
    this.bookPickerSelected = [];
  }
  
  this.bookPickerLoading = true;
  
  const token = localStorage.getItem("token");
  if (!token) return;
  
  try {
    const offset = this.bookPickerPage * 50;
    const response = await fetch(`/api/media-items?limit=50&offset=${offset}`, {
      headers: { Authorization: `Bearer ${token}` },
    });
    
    if (response.ok) {
      const result = await response.json();
      const items = result.data || [];
      this.renderBooksGrid(items, reset);
      this.bookPickerHasMore = items.length === 50;
    }
  } catch (error) {
    console.error("Failed to load books:", error);
  } finally {
    this.bookPickerLoading = false;
  }
},

// Search books
async searchBooks(query: string): Promise<void> {
  if (query.length < 2) {
    if (query.length === 0) {
      this.loadBooksForPicker(true);
    }
    return;
  }
  
  this.bookPickerLoading = true;
  
  const token = localStorage.getItem("token");
  if (!token) return;
  
  try {
    const response = await fetch(`/api/media-items/search?q=${encodeURIComponent(query)}&limit=50`, {
      headers: { Authorization: `Bearer ${token}` },
    });
    
    if (response.ok) {
      const items = await response.json();
      this.renderBooksGrid(items, true);
      this.bookPickerHasMore = false;
    }
  } catch (error) {
    console.error("Failed to search books:", error);
  } finally {
    this.bookPickerLoading = false;
  }
},

// Render books grid with checkboxes
renderBooksGrid(items: any[], reset: boolean): void {
  const grid = document.getElementById("book-picker-grid");
  if (!grid) return;
  
  const html = items.map((item: any) => `
    <div class="book-item flex gap-3 p-2 border-b" style="border-color: var(--border);">
      <input
        type="checkbox"
        value="${item.id}"
        ${this.bookPickerSelected.includes(item.id) ? "checked" : ""}
        @change="toggleBookPickerBook('${item.id}')"
        class="w-5 h-5"
      />
      <div class="flex-1 min-w-0">
        <p class="font-medium truncate" style="color: var(--text-primary);">${item.title}</p>
        <p class="text-sm truncate" style="color: var(--text-secondary);">${item.author || "Unknown"}</p>
      </div>
    </div>
  `).join("");
  
  if (reset) {
    grid.innerHTML = html;
  } else {
    grid.innerHTML += html;
  }
},

// Pagination
loadMoreBooks(): void {
  if (this.bookPickerLoading || !this.bookPickerHasMore) return;
  this.bookPickerPage++;
  this.loadBooksForPicker(false);
},

Phase 2: Update collections.templ

Update the modal to use Alpine methods:

  1. Search input - Change from HTMX to Alpine:
<input
  type="text"
  placeholder="Search books..."
  class="w-full px-3 py-2 border rounded-lg"
  style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
  @input.debounce.300ms="searchBooks($el.value)"
/>
  1. Books grid - Remove hx-get, add Alpine rendering:
<div 
  id="book-picker-grid"
  class="grid grid-cols-1 gap-2 p-4 max-h-96 overflow-y-auto"
  x-init="loadBooksForPicker(true)"
>
  <!-- Alpine renders books here -->
</div>
  1. Pagination button:
<button 
  x-show="bookPickerHasMore"
  @click="loadMoreBooks()"
  x-text="bookPickerLoading ? 'Loading...' : 'Load More'"
  class="px-4 py-2 rounded-lg border"
  style="border-color: var(--border); color: var(--text-primary);"
></button>
  1. Clear search button:
<button 
  @click="loadBooksForPicker(true); $el.previousElementSibling.value = ''"
  class="px-4 py-2 rounded-lg border"
  style="border-color: var(--border); color: var(--text-primary);"
>
   Clear
</button>

Phase 3: Verify Submission Works

Ensure the form submission works. The existing form should work:

<form hx-post={ fmt.Sprintf("/api/collections/%s/books", collection.ID) }
      hx-on::after-request="if(event.detail.successful) { closeBookPicker(); htmx.trigger(htmx.find('#books-container'), 'refresh'); }">

Verify the endpoint accepts book_ids as form data.

Files Modified

File Changes
web/src/collections.ts Add loadBooksForPicker, searchBooks, renderBooksGrid, loadMoreBooks methods
templates/collections.templ Update modal to use Alpine methods instead of HTMX for search

Testing Checklist

  • Modal opens when clicking "Add Books"
  • Books load on modal open (initial load)
  • Search filters books correctly
  • Clear button resets to all books
  • Checkboxes can be selected/deselected
  • Selected count updates correctly
  • "Load More" loads additional books
  • Form submission adds books to collection
  • Modal closes after successful submission
  • Collection books list refreshes after add