fix(ui): wire up collection detail page interactions
The /collections/:id page had several broken features because three
referenced functions (removeBook, toggleBookForRemoval,
filterCollectionBooks) were never defined, and every book card was
wrapped in <a href="/media/..."> so clicking the checkbox or remove
button navigated to the book detail page instead.
Card restructure:
- Remove the <a> wrapper; title and cover are now individual links.
- Checkbox sits in a <label> with expanded click area (p-2 -m-2).
- Checkbox uses Alpine :checked/@change bound to a reactive
selectedBooks array on the collections component.
Remove (single + bulk):
- Add removeBook(id) and bulkRemove() methods with confirm() dialogs.
- Wire the "Remove Selected" button with :disabled binding and @click.
- Selected-count badge is now Alpine-reactive (x-show/x-text).
Search within collection:
- Add filterCollectionBooks() that filters cards client-side by
title/author via data-* attributes and @input.
Book picker ("Add Books"):
- Point the HTMX search inputs at the existing /api/media-items/search
endpoint instead of the non-existent /api/media-items/filtered.
- Add hx-trigger="loadBooks" + hx-get to the grid so loadBooks()
actually fires an initial request when the picker opens.
- Merge the hidden limit/offset inputs into the #book-picker-filters
div so hx-include picks them up (was a separate <form id=filter-form>
that nobody referenced).
- Add show_checkbox mode to handleSearchHTML: when present, render a
new BookPickerGrid template with clickable, selectable cards instead
of the reader BookCard.
- Fix bookPicker submit() to location.reload() instead of a non-existent
reloadCollection HTMX event, and clearFilters() to target text inputs.
This commit is contained in:
+7
-10
@@ -48,18 +48,16 @@ Alpine.store("bookPicker", {
|
||||
},
|
||||
|
||||
clearFilters() {
|
||||
const filterForm = document.getElementById(
|
||||
"book-picker-filters",
|
||||
) as HTMLFormElement;
|
||||
if (!filterForm) return;
|
||||
const filterDiv = document.getElementById("book-picker-filters");
|
||||
if (!filterDiv) return;
|
||||
|
||||
// Reset all form fields except checkbox state (that's in Alpine.store)
|
||||
const inputs = filterForm.querySelectorAll("input:not([type='checkbox'])");
|
||||
const inputs = filterDiv.querySelectorAll(
|
||||
'input[type="text"]',
|
||||
) as NodeListOf<HTMLInputElement>;
|
||||
inputs.forEach((input) => {
|
||||
(input as HTMLInputElement).value = "";
|
||||
input.value = "";
|
||||
});
|
||||
|
||||
// Reload books - selection state preserved in Alpine.store
|
||||
this.loadBooks();
|
||||
},
|
||||
|
||||
@@ -97,8 +95,7 @@ Alpine.store("bookPicker", {
|
||||
"success",
|
||||
);
|
||||
this.close();
|
||||
// Reload collection detail page
|
||||
window.htmx.trigger(document.body, "reloadCollection");
|
||||
location.reload();
|
||||
} else {
|
||||
showToast("Failed to add books", "error");
|
||||
}
|
||||
|
||||
+128
-18
@@ -135,29 +135,36 @@ function renderCollectionBooks(books: BookInfo[]): void {
|
||||
container.innerHTML = books
|
||||
.map(
|
||||
(book) => `
|
||||
<a href="/media/${book.media_item_id}">
|
||||
<div class="card p-4 rounded-lg border hover:shadow-lg transition-shadow"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-shrink-0 pt-1">
|
||||
<input type="checkbox" onchange="toggleBookForRemoval('${book.media_item_id}')" class="w-5 h-5"/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-lg mb-1 line-clamp-2" style="color: var(--text-primary)">${book.title}</h3>
|
||||
${book.author ? `<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">by ${book.author}</p>` : ""}
|
||||
</div>
|
||||
<div class="flex-shrink-0 w-16 sm:w-20">
|
||||
<div class="card p-4 rounded-2xl collection-book-card" data-title="${book.title}" data-author="${book.author || ""}" data-media-id="${book.media_item_id}">
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-shrink-0 pt-1">
|
||||
<label class="flex items-center cursor-pointer p-2 -m-2">
|
||||
<input type="checkbox" class="w-5 h-5"
|
||||
:checked="selectedBooks.includes('${book.media_item_id}')"
|
||||
@change="toggleSelection('${book.media_item_id}')"/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<a href="/media/${book.media_item_id}">
|
||||
<h3 class="font-semibold text-lg mb-1 line-clamp-2 hover:underline" style="color: var(--text-primary)">${book.title}</h3>
|
||||
</a>
|
||||
${book.author ? `<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">by ${book.author}</p>` : ""}
|
||||
</div>
|
||||
<div class="flex-shrink-0 w-16 sm:w-20">
|
||||
<a href="/media/${book.media_item_id}">
|
||||
<img src="${book.cover_image_path || "/static/placeholder-book.svg"}" alt="Cover"
|
||||
class="w-full aspect-[3/4] object-cover rounded shadow-md"
|
||||
onerror="this.src='/static/placeholder-book.svg'"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
||||
<button @click="removeBook('${book.media_item_id}')" class="px-3 py-1 text-sm border rounded hover:opacity-80"
|
||||
style="border-color: var(--border); color: var(--text-secondary);">🗑️ Remove from Collection</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
||||
<button @click="removeBook('${book.media_item_id}')" class="btn btn-secondary w-full">
|
||||
<svg class="h-4 w-4 inline" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path></svg>
|
||||
Remove from Collection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
@@ -393,6 +400,9 @@ function setupHTMXModalInit(): void {
|
||||
populateIconGrid();
|
||||
Alpine.initTree(target);
|
||||
}
|
||||
if (target && target.id === "book-picker-grid") {
|
||||
Alpine.initTree(target);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -553,10 +563,103 @@ function initCollectionsPage(): void {
|
||||
setupHTMXModalInit();
|
||||
}
|
||||
|
||||
function getCollectionId(): string {
|
||||
const dataEl = document.getElementById("collection-data");
|
||||
return dataEl?.dataset.id || "";
|
||||
}
|
||||
|
||||
function toggleSelection(this: any, id: string): void {
|
||||
const idx = this.selectedBooks.indexOf(id);
|
||||
if (idx >= 0) {
|
||||
this.selectedBooks.splice(idx, 1);
|
||||
} else {
|
||||
this.selectedBooks.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeBook(id: string): Promise<void> {
|
||||
if (!confirm("Remove this book from the collection?")) return;
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) return;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/collections/${getCollectionId()}/books/${id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
);
|
||||
if (response.ok) {
|
||||
showToast("Removed from collection", "success");
|
||||
setTimeout(() => location.reload(), 500);
|
||||
} else {
|
||||
showToast("Failed to remove book", "error");
|
||||
}
|
||||
} catch {
|
||||
showToast("Error removing book", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkRemove(this: any): Promise<void> {
|
||||
if (this.selectedBooks.length === 0) return;
|
||||
if (
|
||||
!confirm(
|
||||
`Remove ${this.selectedBooks.length} book(s) from this collection?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) return;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/collections/${getCollectionId()}/books/bulk-remove`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ book_ids: this.selectedBooks }),
|
||||
},
|
||||
);
|
||||
if (response.ok) {
|
||||
showToast(
|
||||
`Removed ${this.selectedBooks.length} book(s)`,
|
||||
"success",
|
||||
);
|
||||
this.selectedBooks = [];
|
||||
setTimeout(() => location.reload(), 500);
|
||||
} else {
|
||||
showToast("Failed to remove books", "error");
|
||||
}
|
||||
} catch {
|
||||
showToast("Error removing books", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function filterCollectionBooks(): void {
|
||||
const input = document.getElementById(
|
||||
"collection-search",
|
||||
) as HTMLInputElement;
|
||||
if (!input) return;
|
||||
const query = input.value.toLowerCase();
|
||||
const cards = document.querySelectorAll<HTMLElement>(
|
||||
".collection-book-card",
|
||||
);
|
||||
cards.forEach((card) => {
|
||||
const title = (card.dataset.title || "").toLowerCase();
|
||||
const author = (card.dataset.author || "").toLowerCase();
|
||||
card.style.display =
|
||||
title.includes(query) || author.includes(query) ? "" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
bulkRemove,
|
||||
closeCollectionModal,
|
||||
createRule,
|
||||
deleteRule,
|
||||
filterCollectionBooks,
|
||||
filterIcons,
|
||||
initColorSelection,
|
||||
initializeCollectionWebSocket,
|
||||
@@ -565,18 +668,21 @@ export {
|
||||
loadCollections,
|
||||
navigateToCollection,
|
||||
populateIconGrid,
|
||||
removeBook,
|
||||
selectColor,
|
||||
selectIcon,
|
||||
setupHTMXAuth,
|
||||
setupHTMXModalInit,
|
||||
showAllIcons,
|
||||
testRule,
|
||||
toggleSelection,
|
||||
};
|
||||
|
||||
Alpine.data("collections", () => ({
|
||||
closeCollectionModal,
|
||||
createRule,
|
||||
deleteRule,
|
||||
filterCollectionBooks,
|
||||
filterIcons,
|
||||
initColorSelection,
|
||||
initializeCollectionWebSocket,
|
||||
@@ -585,9 +691,13 @@ Alpine.data("collections", () => ({
|
||||
loadCollections,
|
||||
navigateToCollection,
|
||||
populateIconGrid,
|
||||
removeBook,
|
||||
selectColor,
|
||||
selectIcon,
|
||||
selectedBooks: [] as string[],
|
||||
setupHTMXModalInit,
|
||||
showAllIcons,
|
||||
testRule,
|
||||
toggleSelection,
|
||||
bulkRemove,
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user