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:
@@ -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
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare var htmx: {
|
||||
trigger(elt: Element | string, event: string, detail?: any): void;
|
||||
find(selector: string): Element | null;
|
||||
};
|
||||
Reference in New Issue
Block a user