Complete bulk operations for collections management:
BULK ADD BOOKS:
- Implemented searchBooks() with real API integration
- Multi-select checkboxes for book selection
- SelectedBooks Set tracks chosen books
- AddSelectedBooks() sends array to existing endpoint
- Uses existing POST /api/collections/:id/books endpoint
BULK REMOVE BOOKS:
- New endpoint: POST /api/collections/:id/books/bulk-remove
- Checkboxes on each book card for selection
- BooksToRemove Set tracks selections
- Live counter showing selected count
- BulkRemoveBooks() handler removes all in one API call
- More efficient than N individual DELETE requests
Frontend Changes:
- Selected counter badge shows number selected
- Bulk remove button (enabled when books selected)
- Checkboxes on all books for multi-select
- Confirmation dialog for bulk operations
- Toast notifications with counts
Backend Changes:
- BulkRemoveBooks() handler in collections.go
- Accepts book_ids array, returns removed/total counts
- Iterates and removes, counting successes
- Route: POST /api/collections/:id/books/bulk-remove
API Request:
{
"book_ids": ["uuid1", "uuid2", "uuid3"]
}
API Response:
{
"removed": 3,
"total": 3
}
Tests Added:
- TestCompareValues_* (existing)
- TestEvaluateRule_* (existing)
Resolves Limitations #2 (Bulk Operations) and #5 (Bulk Remove)
This commit is contained in:
1 parent
592ccddf65
commit
508bfb0387
4 files changed
+227
-36
No files matched your search
@@ -306,6 +306,43 @@ func (h *CollectionHandler) RemoveBook(c echo.Context) error {
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type BulkRemoveBooksRequest struct {
|
||||
BookIDs []string `json:"book_ids" validate:"required"`
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) BulkRemoveBooks(c echo.Context) error {
|
||||
collectionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||
}
|
||||
|
||||
var req BulkRemoveBooksRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
removedCount := 0
|
||||
for _, bookIDStr := range req.BookIDs {
|
||||
bookID, err := uuid.Parse(bookIDStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
err = h.collectionService.RemoveBookFromCollection(c.Request().Context(), collectionID, bookID)
|
||||
if err == nil {
|
||||
removedCount++
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"removed": removedCount,
|
||||
"total": len(req.BookIDs),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) GetDeviceMappings(c echo.Context) error {
|
||||
deviceID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
|
||||
@@ -81,6 +81,7 @@ func SetupRoutes(g *echo.Group, db *database.Queries, connManager *wsync.Connect
|
||||
collections.GET("/:id/books", collectionHandler.GetBookCollections)
|
||||
collections.POST("/:id/books", collectionHandler.AddBooks)
|
||||
collections.DELETE("/:id/books/:bookId", collectionHandler.RemoveBook)
|
||||
collections.POST("/:id/books/bulk-remove", collectionHandler.BulkRemoveBooks)
|
||||
collections.POST("/test-rules", collectionHandler.TestRules)
|
||||
|
||||
// Device shelf mapping routes
|
||||
|
||||
+176
-23
@@ -240,10 +240,21 @@ templ CollectionDetail(user User, collection CollectionDetailData, books []BookD
|
||||
</div>
|
||||
|
||||
<div class="mb-6 flex justify-between items-center">
|
||||
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Books in this Collection</h2>
|
||||
<button onclick="showAddBooksModal()" class="btn-primary px-4 py-2 rounded-lg">
|
||||
➕ Add Books
|
||||
</button>
|
||||
<div class="flex items-center gap-4">
|
||||
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Books in this Collection</h2>
|
||||
<span id="selected-count" class="hidden px-3 py-1 text-sm rounded" style="background-color: var(--accent); color: var(--bg-primary);">
|
||||
0 selected
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button id="bulk-remove-btn" onclick="removeSelectedBooks()" disabled
|
||||
class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
🗑️ Remove Selected
|
||||
</button>
|
||||
<button onclick="showAddBooksModal()" class="btn-primary px-4 py-2 rounded-lg">
|
||||
➕ Add Books
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
@@ -252,22 +263,29 @@ templ CollectionDetail(user User, collection CollectionDetailData, books []BookD
|
||||
}
|
||||
|
||||
for _, book := range books {
|
||||
<div class="card p-4 rounded-lg border cursor-pointer hover:shadow-lg transition-shadow"
|
||||
<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="aspect-w-3 aspect-h-4 mb-3 overflow-hidden rounded">
|
||||
<img src="{ book.CoverImagePath }" alt="Cover"
|
||||
class="w-full h-48 object-cover rounded"
|
||||
onerror="this.src='/static/placeholder-book.svg'">
|
||||
<div class="flex items-start gap-4">
|
||||
<input type="checkbox"
|
||||
onchange="toggleBookForRemoval('{ book.MediaItemID }')"
|
||||
class="w-5 h-5 mt-2">
|
||||
<div class="aspect-w-3 aspect-h-4 flex-shrink-0 w-24 mb-3 overflow-hidden rounded">
|
||||
<img src="{ book.CoverImagePath }" alt="Cover"
|
||||
class="w-full h-32 object-cover rounded"
|
||||
onerror="this.src='/static/placeholder-book.svg'">
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-lg mb-1 line-clamp-2" style="color: var(--text-primary)">{ book.Title }</h3>
|
||||
if book.Author != "" {
|
||||
<p class="text-sm" style="color: var(--text-secondary)">by { book.Author }</p>
|
||||
}
|
||||
<button onclick="removeBook('{ book.MediaItemID }')"
|
||||
class="mt-2 px-3 py-1 text-sm border rounded hover:opacity-80"
|
||||
style="border-color: var(--border); color: var(--text-secondary);">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="font-semibold text-lg mb-1 line-clamp-2" style="color: var(--text-primary)">{ book.Title }</h3>
|
||||
if book.Author != "" {
|
||||
<p class="text-sm" style="color: var(--text-secondary)">by { book.Author }</p>
|
||||
}
|
||||
<button onclick="removeBook('{ book.MediaItemID }')"
|
||||
class="mt-2 px-3 py-1 text-sm border rounded hover:opacity-80"
|
||||
style="border-color: var(--border); color: var(--text-secondary);">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -299,6 +317,8 @@ templ CollectionDetail(user User, collection CollectionDetailData, books []BookD
|
||||
|
||||
<script>
|
||||
let collectionId = '{ collection.ID }';
|
||||
let selectedBooks = new Set();
|
||||
let booksToRemove = new Set();
|
||||
|
||||
function backToCollections() {
|
||||
window.location.href = '/collections';
|
||||
@@ -306,7 +326,8 @@ templ CollectionDetail(user User, collection CollectionDetailData, books []BookD
|
||||
|
||||
function showAddBooksModal() {
|
||||
document.getElementById('add-books-modal').classList.remove('hidden');
|
||||
searchBooks();
|
||||
selectedBooks.clear();
|
||||
document.getElementById('book-results').innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">Enter at least 2 characters to search.</p>';
|
||||
}
|
||||
|
||||
function hideAddBooksModal() {
|
||||
@@ -325,13 +346,84 @@ templ CollectionDetail(user User, collection CollectionDetailData, books []BookD
|
||||
return;
|
||||
}
|
||||
|
||||
// For now, this is a placeholder. We need a proper search endpoint.
|
||||
// In a real implementation, you would call an API endpoint to search books.
|
||||
container.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">Book search coming soon!</p>';
|
||||
container.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">Searching...</p>';
|
||||
|
||||
fetch(`/api/media-items/search?q=${encodeURIComponent(searchTerm)}`, {
|
||||
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.data && result.data.length > 0) {
|
||||
let html = '<div class="space-y-2">';
|
||||
result.data.slice(0, 50).forEach(book => {
|
||||
const isSelected = selectedBooks.has(book.media_item_id);
|
||||
const checkedAttr = isSelected ? 'checked' : '';
|
||||
const authorHtml = book.author ? `<div class="text-xs" style="color: var(--text-secondary)">${book.author}</div>` : '';
|
||||
html += `
|
||||
<div class="flex items-center gap-3 p-2 rounded cursor-pointer hover:opacity-80"
|
||||
style="background-color: var(--bg-primary);"
|
||||
onclick="toggleBookSelection('${book.media_item_id}')">
|
||||
<input type="checkbox" ${checkedAttr} class="w-4 h-4">
|
||||
<img src="${book.cover_image_path || '/static/placeholder-book.svg'}"
|
||||
alt="Cover" class="w-10 h-14 object-cover rounded">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium" style="color: var(--text-primary)">${book.title}</div>
|
||||
${authorHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
if (result.data.length > 50) {
|
||||
html += '<p class="text-sm mt-2" style="color: var(--text-secondary)">Showing first 50 of ' + result.data.length + ' results</p>';
|
||||
}
|
||||
container.innerHTML = html;
|
||||
} else {
|
||||
container.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">No books found</p>';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
container.innerHTML = '<p class="text-sm" style="color: var(--error)">Failed to search books</p>';
|
||||
});
|
||||
}
|
||||
|
||||
function toggleBookSelection(bookId) {
|
||||
if (selectedBooks.has(bookId)) {
|
||||
selectedBooks.delete(bookId);
|
||||
} else {
|
||||
selectedBooks.add(bookId);
|
||||
}
|
||||
searchBooks();
|
||||
}
|
||||
|
||||
function addSelectedBooks() {
|
||||
showToast('Add books feature coming soon!', 'info');
|
||||
if (selectedBooks.size === 0) {
|
||||
showToast('Please select at least one book', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const bookIds = Array.from(selectedBooks);
|
||||
|
||||
fetch(`/api/collections/${collectionId}/books`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||||
},
|
||||
body: JSON.stringify({ book_ids: bookIds })
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
showToast(`Added ${bookIds.length} book(s) to collection`, 'success');
|
||||
hideAddBooksModal();
|
||||
location.reload();
|
||||
} else {
|
||||
showToast('Failed to add books', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showToast('Failed to add books', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function removeBook(bookId) {
|
||||
@@ -354,6 +446,67 @@ templ CollectionDetail(user User, collection CollectionDetailData, books []BookD
|
||||
});
|
||||
}
|
||||
|
||||
function toggleBookForRemoval(bookId) {
|
||||
if (booksToRemove.has(bookId)) {
|
||||
booksToRemove.delete(bookId);
|
||||
} else {
|
||||
booksToRemove.add(bookId);
|
||||
}
|
||||
updateSelectedCount();
|
||||
}
|
||||
|
||||
function updateSelectedCount() {
|
||||
const count = booksToRemove.size;
|
||||
const countSpan = document.getElementById('selected-count');
|
||||
const removeBtn = document.getElementById('bulk-remove-btn');
|
||||
|
||||
if (count > 0) {
|
||||
countSpan.textContent = count + ' selected';
|
||||
countSpan.classList.remove('hidden');
|
||||
removeBtn.disabled = false;
|
||||
} else {
|
||||
countSpan.classList.add('hidden');
|
||||
removeBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function removeSelectedBooks() {
|
||||
if (booksToRemove.size === 0) {
|
||||
showToast('No books selected', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Remove ${booksToRemove.size} book(s) from the collection?`)) return;
|
||||
|
||||
const bookIds = Array.from(booksToRemove);
|
||||
|
||||
fetch(`/api/collections/${collectionId}/books/bulk-remove`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||||
},
|
||||
body: JSON.stringify({ book_ids: bookIds })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.removed > 0) {
|
||||
showToast(`Removed ${result.removed} book(s) from collection`, 'success');
|
||||
location.reload();
|
||||
} else {
|
||||
showToast('Failed to remove books', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showToast('Failed to remove books', 'error');
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showToast('Failed to remove book', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
|
||||
@@ -55,7 +55,7 @@ func Collection(user User, collections []CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 46, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 46, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -68,7 +68,7 @@ func Collection(user User, collections []CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 58, Col: 109}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 58, Col: 109}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -81,7 +81,7 @@ func Collection(user User, collections []CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 59, Col: 103}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 59, Col: 103}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -128,7 +128,7 @@ func CollectionDetail(user User, collection CollectionDetailData, books []BookDa
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 219, Col: 32}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 219, Col: 32}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -149,7 +149,7 @@ func CollectionDetail(user User, collection CollectionDetailData, books []BookDa
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 234, Col: 95}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 234, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -162,7 +162,7 @@ func CollectionDetail(user User, collection CollectionDetailData, books []BookDa
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 236, Col: 107}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 236, Col: 107}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -175,13 +175,13 @@ func CollectionDetail(user User, collection CollectionDetailData, books []BookDa
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 237, Col: 88}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 237, Col: 88}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p></div></div></div><div class=\"mb-6 flex justify-between items-center\"><h2 class=\"text-xl font-semibold\" style=\"color: var(--text-primary)\">Books in this Collection</h2><button onclick=\"showAddBooksModal()\" class=\"btn-primary px-4 py-2 rounded-lg\">➕ Add Books</button></div><div id=\"books-container\" class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p></div></div></div><div class=\"mb-6 flex justify-between items-center\"><div class=\"flex items-center gap-4\"><h2 class=\"text-xl font-semibold\" style=\"color: var(--text-primary)\">Books in this Collection</h2><span id=\"selected-count\" class=\"hidden px-3 py-1 text-sm rounded\" style=\"background-color: var(--accent); color: var(--bg-primary);\">0 selected</span></div><div class=\"flex gap-3\"><button id=\"bulk-remove-btn\" onclick=\"removeSelectedBooks()\" disabled class=\"btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed\">🗑️ Remove Selected</button> <button onclick=\"showAddBooksModal()\" class=\"btn-primary px-4 py-2 rounded-lg\">➕ Add Books</button></div></div><div id=\"books-container\" class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -192,14 +192,14 @@ func CollectionDetail(user User, collection CollectionDetailData, books []BookDa
|
||||
}
|
||||
}
|
||||
for _, book := range books {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"card p-4 rounded-lg border cursor-pointer hover:shadow-lg transition-shadow\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"aspect-w-3 aspect-h-4 mb-3 overflow-hidden rounded\"><img src=\"{ book.CoverImagePath }\" alt=\"Cover\" class=\"w-full h-48 object-cover rounded\" onerror=\"this.src='/static/placeholder-book.svg'\"></div><h3 class=\"font-semibold text-lg mb-1 line-clamp-2\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<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 items-start gap-4\"><input type=\"checkbox\" onchange=\"toggleBookForRemoval('{ book.MediaItemID }')\" class=\"w-5 h-5 mt-2\"><div class=\"aspect-w-3 aspect-h-4 flex-shrink-0 w-24 mb-3 overflow-hidden rounded\"><img src=\"{ book.CoverImagePath }\" alt=\"Cover\" class=\"w-full h-32 object-cover rounded\" onerror=\"this.src='/static/placeholder-book.svg'\"></div><div class=\"flex-1\"><h3 class=\"font-semibold text-lg mb-1 line-clamp-2\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 262, Col: 123}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 278, Col: 131}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -217,7 +217,7 @@ func CollectionDetail(user User, collection CollectionDetailData, books []BookDa
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 264, Col: 100}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 280, Col: 108}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -228,12 +228,12 @@ func CollectionDetail(user User, collection CollectionDetailData, books []BookDa
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<button onclick=\"removeBook('{ book.MediaItemID }')\" class=\"mt-2 px-3 py-1 text-sm border rounded hover:opacity-80\" style=\"border-color: var(--border); color: var(--text-secondary);\">Remove</button></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<button onclick=\"removeBook('{ book.MediaItemID }')\" class=\"mt-2 px-3 py-1 text-sm border rounded hover:opacity-80\" style=\"border-color: var(--border); color: var(--text-secondary);\">Remove</button></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div></div><div id=\"add-books-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center\" style=\"background-color: rgba(0, 0, 0, 0.7);\"><div class=\"card rounded-lg p-6 w-full max-w-2xl mx-4\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Add Books to Collection</h2><button onclick=\"hideAddBooksModal()\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-primary)\">✕</button></div><p class=\"mb-4\" style=\"color: var(--text-secondary)\">Search and select books to add to this collection.</p><div class=\"mb-4\"><input type=\"text\" id=\"book-search\" placeholder=\"Search books...\" class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><div id=\"book-results\" class=\"max-h-64 overflow-y-auto mb-4\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" onclick=\"hideAddBooksModal()\" class=\"btn-secondary px-4 py-2 rounded-lg\">Cancel</button> <button type=\"button\" onclick=\"addSelectedBooks()\" class=\"btn-primary px-4 py-2 rounded-lg\">Add Selected Books</button></div></div></div><script>\n let collectionId = '{ collection.ID }';\n\n function backToCollections() {\n window.location.href = '/collections';\n }\n\n function showAddBooksModal() {\n document.getElementById('add-books-modal').classList.remove('hidden');\n searchBooks();\n }\n\n function hideAddBooksModal() {\n document.getElementById('add-books-modal').classList.add('hidden');\n document.getElementById('book-search').value = '';\n document.getElementById('book-results').innerHTML = '';\n selectedBooks.clear();\n }\n\n function searchBooks() {\n const searchTerm = document.getElementById('book-search').value;\n const container = document.getElementById('book-results');\n\n if (searchTerm.length < 2) {\n container.innerHTML = '<p class=\"text-sm\" style=\"color: var(--text-secondary)\">Enter at least 2 characters to search.</p>';\n return;\n }\n\n // For now, this is a placeholder. We need a proper search endpoint.\n // In a real implementation, you would call an API endpoint to search books.\n container.innerHTML = '<p class=\"text-sm\" style=\"color: var(--text-secondary)\">Book search coming soon!</p>';\n }\n\n function addSelectedBooks() {\n showToast('Add books feature coming soon!', 'info');\n }\n\n function removeBook(bookId) {\n if (!confirm('Remove this book from the collection?')) return;\n\n fetch(`/api/collections/${collectionId}/books/${bookId}`, {\n method: 'DELETE',\n headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }\n })\n .then(response => {\n if (response.ok) {\n showToast('Book removed from collection', 'success');\n location.reload();\n } else {\n showToast('Failed to remove book', 'error');\n }\n })\n .catch(error => {\n showToast('Failed to remove book', 'error');\n });\n }\n\n function logout() {\n localStorage.removeItem('token');\n window.location.href = '/login';\n }\n\n renderBooks();\n </script></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div></div><div id=\"add-books-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center\" style=\"background-color: rgba(0, 0, 0, 0.7);\"><div class=\"card rounded-lg p-6 w-full max-w-2xl mx-4\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Add Books to Collection</h2><button onclick=\"hideAddBooksModal()\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-primary)\">✕</button></div><p class=\"mb-4\" style=\"color: var(--text-secondary)\">Search and select books to add to this collection.</p><div class=\"mb-4\"><input type=\"text\" id=\"book-search\" placeholder=\"Search books...\" class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><div id=\"book-results\" class=\"max-h-64 overflow-y-auto mb-4\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" onclick=\"hideAddBooksModal()\" class=\"btn-secondary px-4 py-2 rounded-lg\">Cancel</button> <button type=\"button\" onclick=\"addSelectedBooks()\" class=\"btn-primary px-4 py-2 rounded-lg\">Add Selected Books</button></div></div></div><script>\n let collectionId = '{ collection.ID }';\n let selectedBooks = new Set();\n let booksToRemove = new Set();\n\n function backToCollections() {\n window.location.href = '/collections';\n }\n\n function showAddBooksModal() {\n document.getElementById('add-books-modal').classList.remove('hidden');\n selectedBooks.clear();\n document.getElementById('book-results').innerHTML = '<p class=\"text-sm\" style=\"color: var(--text-secondary)\">Enter at least 2 characters to search.</p>';\n }\n\n function hideAddBooksModal() {\n document.getElementById('add-books-modal').classList.add('hidden');\n document.getElementById('book-search').value = '';\n document.getElementById('book-results').innerHTML = '';\n selectedBooks.clear();\n }\n\n function searchBooks() {\n const searchTerm = document.getElementById('book-search').value;\n const container = document.getElementById('book-results');\n\n if (searchTerm.length < 2) {\n container.innerHTML = '<p class=\"text-sm\" style=\"color: var(--text-secondary)\">Enter at least 2 characters to search.</p>';\n return;\n }\n\n container.innerHTML = '<p class=\"text-sm\" style=\"color: var(--text-secondary)\">Searching...</p>';\n\n fetch(`/api/media-items/search?q=${encodeURIComponent(searchTerm)}`, {\n headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }\n })\n .then(response => response.json())\n .then(result => {\n if (result.data && result.data.length > 0) {\n let html = '<div class=\"space-y-2\">';\n result.data.slice(0, 50).forEach(book => {\n const isSelected = selectedBooks.has(book.media_item_id);\n const checkedAttr = isSelected ? 'checked' : '';\n const authorHtml = book.author ? `<div class=\"text-xs\" style=\"color: var(--text-secondary)\">${book.author}</div>` : '';\n html += `\n <div class=\"flex items-center gap-3 p-2 rounded cursor-pointer hover:opacity-80\"\n style=\"background-color: var(--bg-primary);\"\n onclick=\"toggleBookSelection('${book.media_item_id}')\">\n <input type=\"checkbox\" ${checkedAttr} class=\"w-4 h-4\">\n <img src=\"${book.cover_image_path || '/static/placeholder-book.svg'}\"\n alt=\"Cover\" class=\"w-10 h-14 object-cover rounded\">\n <div class=\"flex-1\">\n <div class=\"text-sm font-medium\" style=\"color: var(--text-primary)\">${book.title}</div>\n ${authorHtml}\n </div>\n </div>\n `;\n });\n html += '</div>';\n if (result.data.length > 50) {\n html += '<p class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">Showing first 50 of ' + result.data.length + ' resulLine truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user