refactor(collections): move WebSocket from TypeScript to template with server-side token
- Move WebSocket connection logic from collections.ts to collections.templ template - Inject JWT token directly into WebSocket URL from server-side User.Token - Remove createWebSocket import, ws variable, and connectWebSocket() function - Remove unused CollectionUpdateMessage interface - Embed complete WebSocket message handling in template script tag Changes to collections.templ: - Add inline <script> with WebSocket connection using server-injected token - Implement collection_updated message handler with toast notifications - Include 5-second auto-reconnection on disconnect - Add user activity detection to skip auto-reload when actively typing - Initialize collectionId and libraryId from data attributes Changes to collections.ts: - Remove createWebSocket import - Remove ws variable declaration - Remove connectWebSocket() function and CollectionUpdateMessage interface - Keep all other collection functionality (CRUD operations, modals, search, etc.) This refactoring eliminates client-side localStorage dependencies for WebSocket authentication, making the collections page consistent with the SSR architecture. The token is now injected server-side on every page load, ensuring WebSocket connections always work when the user is authenticated via HttpOnly cookie. Auto-reconnection and smart reload behavior is preserved for optimal UX.
This commit is contained in:
@@ -12,6 +12,79 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
|
|||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="/static/main.js" defer></script>
|
<script src="/static/main.js" defer></script>
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
|
<script>
|
||||||
|
let ws = null;
|
||||||
|
let collectionId = null;
|
||||||
|
let libraryId = null;
|
||||||
|
|
||||||
|
function connectWebSocket() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/ws/sync?token={ user.Token }`;
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.onopen = function() {
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = function(event) {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
if (message.type === 'collection_updated' && message.data.collection_id === collectionId) {
|
||||||
|
const actionText = message.data.action === 'books_added'
|
||||||
|
? `Added ${message.data.count || 0} book(s)`
|
||||||
|
: message.data.action === 'book_removed'
|
||||||
|
? 'Removed a book'
|
||||||
|
: message.data.action === 'books_bulk_removed'
|
||||||
|
? `Removed ${message.data.count || 0} book(s)`
|
||||||
|
: 'Collection updated';
|
||||||
|
|
||||||
|
// Show toast notification
|
||||||
|
if (window.showToast) {
|
||||||
|
window.showToast(actionText, 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is actively typing
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
const isUserActive = activeElement && (
|
||||||
|
activeElement.tagName === 'INPUT' ||
|
||||||
|
activeElement.tagName === 'TEXTAREA' ||
|
||||||
|
activeElement.tagName === 'SELECT' ||
|
||||||
|
activeElement.getAttribute('contenteditable') === 'true'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isUserActive) {
|
||||||
|
setTimeout(function() {
|
||||||
|
location.reload();
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
console.log('User actively typing - skipping auto-reload');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse WebSocket message:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = function() {
|
||||||
|
console.log('WebSocket disconnected, reconnecting in 5s...');
|
||||||
|
setTimeout(connectWebSocket, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = function(error) {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Get collection and library IDs from data attributes
|
||||||
|
const dataEl = document.getElementById('collection-data');
|
||||||
|
if (dataEl) {
|
||||||
|
collectionId = dataEl.dataset.id || '';
|
||||||
|
libraryId = dataEl.dataset.libraryId || '';
|
||||||
|
connectWebSocket();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="collections" class="theme-{ user.Theme }">
|
<body x-data="collections" class="theme-{ user.Theme }">
|
||||||
@Header(user, "/collections")
|
@Header(user, "/collections")
|
||||||
@@ -60,7 +133,7 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
for _, col := range collections {
|
for _, col := range collections {
|
||||||
<div @click="$store.collections.navigateToCollection($el)" data-href={ "/collections/" + col.ID } class="block">
|
<div @click="navigateToCollection($el)" data-href={ "/collections/" + col.ID } class="block">
|
||||||
<div
|
<div
|
||||||
class="card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow"
|
class="card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow"
|
||||||
style="background-color: var(--bg-secondary);"
|
style="background-color: var(--bg-secondary);"
|
||||||
@@ -116,7 +189,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
|||||||
@Header(user, "/collections")
|
@Header(user, "/collections")
|
||||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<button @click="$store.collections.backToCollections" class="btn-secondary px-4 py-2 rounded-lg mb-4">
|
<button @click="backToCollections" class="btn-secondary px-4 py-2 rounded-lg mb-4">
|
||||||
← Back to Collections
|
← Back to Collections
|
||||||
</button>
|
</button>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
@@ -147,13 +220,13 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
id="bulk-remove-btn"
|
id="bulk-remove-btn"
|
||||||
@click="$store.collections.removebooksToAdd"
|
@click="removebooksToAdd"
|
||||||
disabled
|
disabled
|
||||||
class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
🗑️ Remove Selected
|
🗑️ Remove Selected
|
||||||
</button>
|
</button>
|
||||||
<button @click="$store.collections.showAddBooksModal" class="btn-primary px-4 py-2 rounded-lg">
|
<button @click="showAddBooksModal" class="btn-primary px-4 py-2 rounded-lg">
|
||||||
➕ Add Books
|
➕ Add Books
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,7 +286,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
|||||||
<!-- Remove Button -->
|
<!-- Remove Button -->
|
||||||
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
||||||
<button
|
<button
|
||||||
@click="$store.collections.removeBook('{ book.MediaItemID }')"
|
@click="removeBook('{ book.MediaItemID }')"
|
||||||
class="px-3 py-1 text-sm border rounded hover:opacity-80"
|
class="px-3 py-1 text-sm border rounded hover:opacity-80"
|
||||||
style="border-color: var(--border); color: var(--text-secondary);"
|
style="border-color: var(--border); color: var(--text-secondary);"
|
||||||
>
|
>
|
||||||
@@ -228,7 +301,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
|||||||
<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="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">
|
<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>
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add Books to Collection</h2>
|
||||||
<button @click="$store.collections.hideAddBooksModal" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
<button @click="hideAddBooksModal" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-4" style="color: var(--text-secondary)">Search and select books to add to this collection.</p>
|
<p class="mb-4" style="color: var(--text-secondary)">Search and select books to add to this collection.</p>
|
||||||
<!-- Library filter toggle - hidden by default, shown by JS when library_id present -->
|
<!-- Library filter toggle - hidden by default, shown by JS when library_id present -->
|
||||||
@@ -249,10 +322,10 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
|||||||
</div>
|
</div>
|
||||||
<div id="book-results" class="max-h-64 overflow-y-auto mb-4"></div>
|
<div id="book-results" class="max-h-64 overflow-y-auto mb-4"></div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button type="button" @click="$store.collections.hideAddBooksModal" class="btn-secondary px-4 py-2 rounded-lg">
|
<button type="button" @click="hideAddBooksModal" class="btn-secondary px-4 py-2 rounded-lg">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="button" @click="$store.collections.addbooksToAdd" class="btn-primary px-4 py-2 rounded-lg">
|
<button type="button" @click="addbooksToAdd" class="btn-primary px-4 py-2 rounded-lg">
|
||||||
Add Selected Books
|
Add Selected Books
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-464
@@ -421,468 +421,6 @@ function filterIcons(searchTerm: string): void {
|
|||||||
(btn as HTMLElement).style.display = matches ? "" : "none";
|
(btn as HTMLElement).style.display = matches ? "" : "none";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function showAllIcons(): void {
|
|
||||||
const iconGrid = document.getElementById("icon-grid");
|
|
||||||
if (!iconGrid) return;
|
|
||||||
|
|
||||||
const buttons = iconGrid.querySelectorAll(".icon-btn");
|
|
||||||
buttons.forEach((btn) => {
|
|
||||||
(btn as HTMLElement).style.display = "";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function initIconSelection(): void {
|
|
||||||
// First, populate the grid with all icons
|
|
||||||
populateIconGrid();
|
|
||||||
// Then, set the current selection
|
|
||||||
const iconInput = document.getElementById(
|
|
||||||
"collection-icon",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const searchInput = document.getElementById(
|
|
||||||
"icon-search",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
|
|
||||||
if (iconInput && iconInput.value && searchInput) {
|
|
||||||
searchInput.value = iconInput.value;
|
|
||||||
selectIcon(iconInput.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Collection Detail Page - TypeScript with WebSocket Support
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
interface SearchBookResult {
|
|
||||||
media_item_id: string;
|
|
||||||
title: string;
|
|
||||||
author: string | null;
|
|
||||||
cover_image_path: string | null;
|
|
||||||
library_id: string;
|
|
||||||
library_name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CollectionUpdateMessage {
|
|
||||||
type: string;
|
|
||||||
data: {
|
|
||||||
collection_id: string;
|
|
||||||
action: string;
|
|
||||||
count?: number;
|
|
||||||
book_id?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let collectionId = "";
|
|
||||||
let libraryId = "";
|
|
||||||
let booksToAdd = new Set<string>();
|
|
||||||
let booksToRemove = new Set<string>();
|
|
||||||
let ws: WebSocket | null = null;
|
|
||||||
|
|
||||||
// Initialize from data attributes (called on page load)
|
|
||||||
function initCollectionDetail(): void {
|
|
||||||
const dataEl = document.getElementById("collection-data");
|
|
||||||
if (dataEl) {
|
|
||||||
collectionId = dataEl.dataset.id || "";
|
|
||||||
libraryId = dataEl.dataset.libraryId || "";
|
|
||||||
|
|
||||||
// Show/hide library filter toggle based on whether library_id is present
|
|
||||||
const filterContainer = document.getElementById("library-filter-container");
|
|
||||||
if (filterContainer) {
|
|
||||||
if (libraryId) {
|
|
||||||
filterContainer.classList.remove("hidden");
|
|
||||||
// Default: checked (filter by library)
|
|
||||||
const checkbox = document.getElementById(
|
|
||||||
"filter-by-library",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
if (checkbox) checkbox.checked = true;
|
|
||||||
} else {
|
|
||||||
filterContainer.classList.add("hidden");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize WebSocket connection
|
|
||||||
connectWebSocket();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current library filter setting
|
|
||||||
function getLibraryFilterParam(): string {
|
|
||||||
if (!libraryId) return "";
|
|
||||||
|
|
||||||
const checkbox = document.getElementById(
|
|
||||||
"filter-by-library",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
if (checkbox && checkbox.checked) {
|
|
||||||
return `&library_id=${libraryId}`;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebSocket connection for real-time collection updates
|
|
||||||
function connectWebSocket(): void {
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
const wsUrl = `${protocol}//${window.location.host}/ws/sync?token=${token}`;
|
|
||||||
|
|
||||||
ws = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
ws.onopen = (): void => {
|
|
||||||
console.log("WebSocket connected");
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (event: MessageEvent): void => {
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(event.data) as CollectionUpdateMessage;
|
|
||||||
|
|
||||||
if (
|
|
||||||
message.type === "collection_updated" &&
|
|
||||||
message.data.collection_id === collectionId
|
|
||||||
) {
|
|
||||||
const actionText =
|
|
||||||
message.data.action === "books_added"
|
|
||||||
? `Added ${message.data.count || 0} book(s)`
|
|
||||||
: message.data.action === "book_removed"
|
|
||||||
? "Removed a book"
|
|
||||||
: message.data.action === "books_bulk_removed"
|
|
||||||
? `Removed ${message.data.count || 0} book(s)`
|
|
||||||
: "Collection updated";
|
|
||||||
|
|
||||||
showToast(actionText, "info");
|
|
||||||
|
|
||||||
// Mitigation: Skip auto-reload if user is actively typing or interacting
|
|
||||||
const activeElement = document.activeElement;
|
|
||||||
const isUserActive =
|
|
||||||
activeElement &&
|
|
||||||
(activeElement.tagName === "INPUT" ||
|
|
||||||
activeElement.tagName === "TEXTAREA" ||
|
|
||||||
activeElement.tagName === "SELECT" ||
|
|
||||||
activeElement.getAttribute("contenteditable") === "true");
|
|
||||||
|
|
||||||
if (!isUserActive) {
|
|
||||||
// Auto-reload after 1 second to see updates (only if user not actively typing)
|
|
||||||
setTimeout(() => {
|
|
||||||
location.reload();
|
|
||||||
}, 1000);
|
|
||||||
} else {
|
|
||||||
// User is active - just show toast, don't reload
|
|
||||||
// They'll see updates when they navigate away or manually refresh
|
|
||||||
console.log("User actively typing - skipping auto-reload");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to parse WebSocket message:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = (): void => {
|
|
||||||
console.log("WebSocket disconnected, reconnecting in 5s...");
|
|
||||||
setTimeout(connectWebSocket, 5000);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = (error: Event): void => {
|
|
||||||
console.error("WebSocket error:", error);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function backToCollections(): void {
|
|
||||||
window.location.href = "/collections";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal functions (called from onclick attributes)
|
|
||||||
function showAddBooksModal(): void {
|
|
||||||
const modal = document.getElementById("add-books-modal");
|
|
||||||
if (modal) modal.classList.remove("hidden");
|
|
||||||
booksToAdd.clear();
|
|
||||||
|
|
||||||
const results = document.getElementById("book-results");
|
|
||||||
if (results) {
|
|
||||||
results.innerHTML =
|
|
||||||
'<p class="text-sm" style="color: var(--text-secondary)">Enter at least 2 characters to search.</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideAddBooksModal(): void {
|
|
||||||
const modal = document.getElementById("add-books-modal");
|
|
||||||
if (modal) modal.classList.add("hidden");
|
|
||||||
|
|
||||||
const searchInput = document.getElementById(
|
|
||||||
"book-search",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
if (searchInput) searchInput.value = "";
|
|
||||||
|
|
||||||
const results = document.getElementById("book-results");
|
|
||||||
if (results) results.innerHTML = "";
|
|
||||||
|
|
||||||
booksToAdd.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search books - includes library filter
|
|
||||||
async function searchBooksForCollections(): Promise<void> {
|
|
||||||
const searchInput = document.getElementById(
|
|
||||||
"book-search",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const container = document.getElementById("book-results");
|
|
||||||
if (!searchInput || !container) return;
|
|
||||||
|
|
||||||
const searchTerm = searchInput.value;
|
|
||||||
if (searchTerm.length < 2) {
|
|
||||||
container.innerHTML =
|
|
||||||
'<p class="text-sm" style="color: var(--text-secondary)">Enter at least 2 characters to search.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML =
|
|
||||||
'<p class="text-sm" style="color: var(--text-secondary)">Searching...</p>';
|
|
||||||
|
|
||||||
const libraryFilter = getLibraryFilterParam();
|
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
if (!token) {
|
|
||||||
container.innerHTML =
|
|
||||||
'<p class="text-sm" style="color: var(--error)">Authentication required</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/media-items/search?q=${encodeURIComponent(searchTerm)}${libraryFilter}`,
|
|
||||||
{
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = (await response.json()) as SearchBookResult[];
|
|
||||||
|
|
||||||
if (result.length > 0) {
|
|
||||||
let html = '<div class="space-y-2">';
|
|
||||||
result.slice(0, 50).forEach((book) => {
|
|
||||||
const isSelected = booksToAdd.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>`
|
|
||||||
: "";
|
|
||||||
const libraryBadge =
|
|
||||||
book.library_id === libraryId
|
|
||||||
? '<span class="text-xs px-1 bg-blue-500 text-white rounded">This Library</span>'
|
|
||||||
: "";
|
|
||||||
|
|
||||||
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>
|
|
||||||
${libraryBadge}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
html += "</div>";
|
|
||||||
container.innerHTML = html;
|
|
||||||
} else {
|
|
||||||
container.innerHTML =
|
|
||||||
'<p class="text-sm" style="color: var(--text-secondary)">No books found</p>';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Search error:", error);
|
|
||||||
container.innerHTML =
|
|
||||||
'<p class="text-sm" style="color: var(--error)">Failed to search books</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle book selection
|
|
||||||
function toggleBookSelection(bookId: string): void {
|
|
||||||
if (booksToAdd.has(bookId)) {
|
|
||||||
booksToAdd.delete(bookId);
|
|
||||||
} else {
|
|
||||||
booksToAdd.add(bookId);
|
|
||||||
}
|
|
||||||
searchBooksForCollections();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add selected books to collection
|
|
||||||
async function addbooksToAdd(): Promise<void> {
|
|
||||||
if (booksToAdd.size === 0) {
|
|
||||||
showToast("Please select at least one book", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bookIds = Array.from(booksToAdd);
|
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
if (!token) {
|
|
||||||
showToast("Authentication required", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/collections/${collectionId}/books`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ book_ids: bookIds }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
showToast(`Added ${bookIds.length} book(s) to collection`, "success");
|
|
||||||
hideAddBooksModal();
|
|
||||||
// Note: WebSocket will trigger page reload automatically
|
|
||||||
} else {
|
|
||||||
showToast("Failed to add books", "error");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Add books error:", error);
|
|
||||||
showToast("Failed to add books", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove single book from collection
|
|
||||||
async function removeBook(bookId: string): Promise<void> {
|
|
||||||
if (!confirm("Remove this book from the collection?")) return;
|
|
||||||
|
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
if (!token) {
|
|
||||||
showToast("Authentication required", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/collections/${collectionId}/books/${bookId}`,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
showToast("Book removed from collection", "success");
|
|
||||||
// Note: WebSocket will trigger page reload automatically
|
|
||||||
} else {
|
|
||||||
showToast("Failed to remove book", "error");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Remove book error:", error);
|
|
||||||
showToast("Failed to remove book", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bulk remove functions
|
|
||||||
function toggleBookForRemoval(bookId: string): void {
|
|
||||||
if (booksToRemove.has(bookId)) {
|
|
||||||
booksToRemove.delete(bookId);
|
|
||||||
} else {
|
|
||||||
booksToRemove.add(bookId);
|
|
||||||
}
|
|
||||||
updateSelectedCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSelectedCount(): void {
|
|
||||||
const count = booksToRemove.size;
|
|
||||||
const countSpan = document.getElementById("selected-count");
|
|
||||||
const removeBtn = document.getElementById(
|
|
||||||
"bulk-remove-btn",
|
|
||||||
) as HTMLButtonElement;
|
|
||||||
|
|
||||||
if (count > 0) {
|
|
||||||
if (countSpan) {
|
|
||||||
countSpan.textContent = `${count} selected`;
|
|
||||||
countSpan.classList.remove("hidden");
|
|
||||||
}
|
|
||||||
if (removeBtn) removeBtn.disabled = false;
|
|
||||||
} else {
|
|
||||||
if (countSpan) countSpan.classList.add("hidden");
|
|
||||||
if (removeBtn) removeBtn.disabled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removebooksToAdd(): Promise<void> {
|
|
||||||
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);
|
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
if (!token) {
|
|
||||||
showToast("Authentication required", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/collections/${collectionId}/books/bulk-remove`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ book_ids: bookIds }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const result = (await response.json()) as { removed: number };
|
|
||||||
if (result.removed > 0) {
|
|
||||||
showToast(
|
|
||||||
`Removed ${result.removed} book(s) from collection`,
|
|
||||||
"success",
|
|
||||||
);
|
|
||||||
// Note: WebSocket will trigger page reload automatically
|
|
||||||
} else {
|
|
||||||
showToast("Failed to remove books", "error");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showToast("Failed to remove books", "error");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Bulk remove error:", error);
|
|
||||||
showToast("Failed to remove books", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client-side search filter for displayed books
|
|
||||||
function filterCollectionBooks(): void {
|
|
||||||
const searchTerm =
|
|
||||||
(
|
|
||||||
document.getElementById("collection-search") as HTMLInputElement
|
|
||||||
)?.value.toLowerCase() || "";
|
|
||||||
const booksContainer = document.getElementById("books-container");
|
|
||||||
if (!booksContainer) return;
|
|
||||||
|
|
||||||
const bookCards = booksContainer.children;
|
|
||||||
for (let i = 0; i < bookCards.length; i++) {
|
|
||||||
const card = bookCards[i] as HTMLElement;
|
|
||||||
if (card.id === "empty-state") continue;
|
|
||||||
|
|
||||||
const titleEl = card.querySelector(".font-semibold");
|
|
||||||
const authorEl = card.querySelector(".text-sm");
|
|
||||||
const title = titleEl?.textContent?.toLowerCase() || "";
|
|
||||||
const author = authorEl?.textContent?.toLowerCase() || "";
|
|
||||||
|
|
||||||
const matches = title.includes(searchTerm) || author.includes(searchTerm);
|
|
||||||
card.style.display = matches || searchTerm === "" ? "" : "none";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-initialize
|
|
||||||
if (document.readyState === "loading") {
|
|
||||||
document.addEventListener("DOMContentLoaded", initCollectionDetail);
|
|
||||||
} else {
|
|
||||||
initCollectionDetail();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export functions globally
|
// Export functions globally
|
||||||
export {
|
export {
|
||||||
@@ -914,7 +452,7 @@ export {
|
|||||||
updateSelectedCount,
|
updateSelectedCount,
|
||||||
};
|
};
|
||||||
|
|
||||||
Alpine.store("collections", {
|
Alpine.data("collections", () => ({
|
||||||
addbooksToAdd,
|
addbooksToAdd,
|
||||||
backToCollections,
|
backToCollections,
|
||||||
closeCollectionModal,
|
closeCollectionModal,
|
||||||
@@ -941,4 +479,4 @@ Alpine.store("collections", {
|
|||||||
toggleBookForRemoval,
|
toggleBookForRemoval,
|
||||||
toggleBookSelection,
|
toggleBookSelection,
|
||||||
updateSelectedCount,
|
updateSelectedCount,
|
||||||
});
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user