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
This commit is contained in:
2026-03-16 16:24:26 -04:00
parent 6d92dff5e3
commit f2cbb5a433
2 changed files with 215 additions and 0 deletions
+211
View File
@@ -0,0 +1,211 @@
# 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
+4
View File
@@ -0,0 +1,4 @@
declare var htmx: {
trigger(elt: Element | string, event: string, detail?: any): void;
find(selector: string): Element | null;
};