- Add htmx.d.ts with TypeScript type definitions for HTMX global - Add BOOK_PICKER_IMPL.md with implementation plan for book picker modal
212 lines
5.8 KiB
Markdown
212 lines
5.8 KiB
Markdown
# 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", ...)`:
|
|
|
|
```typescript
|
|
// 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:
|
|
```templ
|
|
<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)"
|
|
/>
|
|
```
|
|
|
|
2. **Books grid** - Remove hx-get, add Alpine rendering:
|
|
```templ
|
|
<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>
|
|
```
|
|
|
|
3. **Pagination button**:
|
|
```templ
|
|
<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>
|
|
```
|
|
|
|
4. **Clear search button**:
|
|
```templ
|
|
<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:
|
|
|
|
```templ
|
|
<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
|