This commit adds comprehensive functionality for filtering collections by library, improves WebSocket real-time updates with user activity detection, and adds extensive test coverage. ## Core Features ### Collection Library Filter - Added library_id parameter to media-items search API - Collections can now be filtered by specific library - Toggle UI component for enabling/disabling library filter - Default state is "checked" when library_id is present - Consistent behavior across partial and fuzzy search modes ### WebSocket Auto-Reload Mitigation - Added user activity detection to prevent disruptive page reloads - Checks if user is actively typing in INPUT/TEXTAREA/SELECT elements - Skips auto-reload when user is interacting with form elements - Toast notifications still show for awareness - Prevents data loss during editing operations ## Implementation Changes ### Backend - internal/database/queries.sql.go: Added library filter support to search queries - internal/handlers/media.go: Enhanced search with library_id parameter validation - internal/handlers/collections.go: Updated collection handlers with library filtering - internal/sync/websocket.go: Improved broadcast mechanism with user-scoped updates - internal/router/frontend.go: Pass libraryID to collection templates ### Frontend - templates/collections.templ: Added library filter toggle UI component - web/src/collections.ts: TypeScript implementation with WebSocket integration - templates/collections_templ.go: Generated template code ### Testing - cmd/server/tests/search_test.go: Added TestCollectionSearchLibraryFilter - cmd/server/tests/websocket_test.go: Added TestWebSocketUserScopedBroadcast - New helper functions for creating libraries and media items via API - Comprehensive test coverage for library filtering and user-scoped broadcasts ## API Documentation Updates ### Bruno Tests (Comprehensive Documentation) - bruno/collections/*: Added detailed API documentation for all collection endpoints - bruno/devices/*: Added device management and sync API documentation - bruno/devices/kobo/api.yml: Kobo-specific sync protocol docs - bruno/devices/koreader/api.yml: KOReader-specific sync protocol docs - bruno/opds/*: Added OPDS feed and download endpoint documentation - bruno/library/browse-folders.yml: Library folder browsing API docs ### New Bruno Tests - bruno/media-items/Search All Libraries.yml: Test search without library filter - bruno/media-items/Search Specific Library.yml: Test search with library filter - bruno/media-items/Search Invalid Library ID.yml: Test error handling ## Documentation - docs/developer/api/media-items/search_media_items.md: Updated with library_id parameter - IMPLEMENTATION_COLLECTION_FIX.md: Comprehensive implementation guide with test scenarios ## Testing ### Integration Tests - Library filter tests verify correct filtering across multiple libraries - Invalid library_id tests ensure proper error handling - WebSocket tests verify user-scoped broadcast behavior - User A no longer receives User B's collection updates ### Manual Testing Scenarios - Open collection in multiple tabs - updates propagate correctly - Type in search box while another tab adds books - no disruptive reload - Add/remove books from collection - toast notifications appear - Toggle library filter - results update dynamically ## Technical Details - WebSocket broadcasts are now user-scoped for privacy - Active element detection uses tagName and contenteditable attributes - Library ID validation uses UUID format checking - Progressive enhancement maintained - page works without JavaScript - All changes follow PROJECT_GUIDELINES.md conventions - TypeScript only for frontend logic - TailwindCSS only for styling - Procedural programming style throughout ## Breaking Changes None - all changes are additive and backward compatible.
931 lines
29 KiB
TypeScript
931 lines
29 KiB
TypeScript
async function loadCollections(): Promise<void> {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) return;
|
|
|
|
try {
|
|
const response = await fetch("/api/collections", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
renderCollections(data.collections || []);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to load collections:", error);
|
|
}
|
|
}
|
|
|
|
function renderCollections(collections: CollectionData[]): void {
|
|
const container = document.getElementById("collections-list");
|
|
if (!container) return;
|
|
|
|
if (collections.length === 0) {
|
|
container.innerHTML =
|
|
'<p class="text-center p-4" style="color: var(--text-secondary)">No collections yet</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = collections
|
|
.map(
|
|
(collection) => `
|
|
<a href="/collections/${collection.id}" class="block p-4 rounded-lg border transition-colors hover:border-opacity-50"
|
|
style="background-color: var(--bg-secondary); border-color: var(--border)">
|
|
<div class="flex items-center space-x-3">
|
|
<span class="text-2xl">${collection.icon || "📁"}</span>
|
|
<div>
|
|
<h3 class="font-medium" style="color: var(--text-primary)">${collection.name}</h3>
|
|
${collection.description ? `<p class="text-sm" style="color: var(--text-secondary)">${collection.description}</p>` : ""}
|
|
</div>
|
|
</div>
|
|
</a>
|
|
`,
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
async function loadCollectionRules(collectionId: string): Promise<void> {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/collections/${collectionId}/rules`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
|
|
if (response.ok) {
|
|
const rules: CollectionRule[] = await response.json();
|
|
renderRules(rules);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to load rules:", error);
|
|
}
|
|
}
|
|
|
|
function renderRules(rules: CollectionRule[]): void {
|
|
const container = document.getElementById("rules-list");
|
|
if (!container) return;
|
|
|
|
if (rules.length === 0) {
|
|
container.innerHTML =
|
|
'<p class="text-center p-4" style="color: var(--text-secondary)">No rules defined</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = rules
|
|
.map(
|
|
(rule) => `
|
|
<div class="p-3 rounded-lg border mb-2 flex justify-between items-center"
|
|
style="background-color: var(--bg-secondary); border-color: var(--border)">
|
|
<div>
|
|
<p class="font-medium" style="color: var(--text-primary)">${rule.field} ${rule.operator} "${rule.value}"</p>
|
|
<p class="text-sm" style="color: var(--text-secondary)">Priority: ${rule.priority} | ${rule.enabled ? "Enabled" : "Disabled"}</p>
|
|
</div>
|
|
<div class="flex space-x-2">
|
|
<button onclick="window.editRule('${rule.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Edit</button>
|
|
<button onclick="window.deleteRule('${rule.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Delete</button>
|
|
</div>
|
|
</div>
|
|
`,
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
async function createRule(
|
|
collectionId: string,
|
|
rule: Partial<CollectionRule>,
|
|
): Promise<void> {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/collections/${collectionId}/rules`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(rule),
|
|
});
|
|
|
|
if (response.ok) {
|
|
if ((window as any).showToast?.success) {
|
|
(window as any).showToast.success("Rule created");
|
|
}
|
|
loadCollectionRules(collectionId);
|
|
} else {
|
|
const error = await response.json();
|
|
if ((window as any).showToast?.error) {
|
|
(window as any).showToast.error(error.error || "Failed to create rule");
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to create rule:", error);
|
|
if ((window as any).showToast?.error) {
|
|
(window as any).showToast.error("Failed to create rule");
|
|
}
|
|
}
|
|
}
|
|
|
|
async function deleteRule(collectionId: string, ruleId: string): Promise<void> {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) return;
|
|
|
|
if (!confirm("Are you sure you want to delete this rule?")) return;
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`/api/collections/${collectionId}/rules/${ruleId}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
},
|
|
);
|
|
|
|
if (response.ok) {
|
|
if ((window as any).showToast?.success) {
|
|
(window as any).showToast.success("Rule deleted");
|
|
}
|
|
loadCollectionRules(collectionId);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to delete rule:", error);
|
|
if ((window as any).showToast?.error) {
|
|
(window as any).showToast.error("Failed to delete rule");
|
|
}
|
|
}
|
|
}
|
|
|
|
async function testRule(
|
|
collectionId: string,
|
|
rule: Partial<CollectionRule>,
|
|
): Promise<void> {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) return;
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`/api/collections/${collectionId}/rules/test`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(rule),
|
|
},
|
|
);
|
|
|
|
if (response.ok) {
|
|
const results = await response.json();
|
|
renderTestResults(results);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to test rule:", error);
|
|
if ((window as any).showToast?.error) {
|
|
(window as any).showToast.error("Failed to test rule");
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderTestResults(results: unknown[]): void {
|
|
const container = document.getElementById("test-results");
|
|
if (!container) return;
|
|
|
|
if (!results || (Array.isArray(results) && results.length === 0)) {
|
|
container.innerHTML =
|
|
'<p class="p-2 text-sm" style="color: var(--text-secondary)">No matching books found</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = `<p class="p-2 text-sm" style="color: var(--text-secondary)">${Array.isArray(results) ? results.length : 0} matching books</p>`;
|
|
}
|
|
|
|
(window as any).loadCollections = loadCollections;
|
|
(window as any).loadCollectionRules = loadCollectionRules;
|
|
(window as any).createRule = createRule;
|
|
(window as any).deleteRule = deleteRule;
|
|
(window as any).testRule = testRule;
|
|
|
|
// Add authorization header to all HTMX requests
|
|
function setupHTMXAuth(): void {
|
|
document.body.addEventListener("htmx:configRequest", function (evt: Event) {
|
|
const token = localStorage.getItem("token");
|
|
(evt as any).detail.headers["Authorization"] = `Bearer ${token}`;
|
|
});
|
|
}
|
|
// Auto-initialize HTMX auth when DOM is ready
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", setupHTMXAuth);
|
|
} else {
|
|
setupHTMXAuth();
|
|
}
|
|
|
|
function navigateToCollection(element: HTMLElement): void {
|
|
// Check if the click target is a button
|
|
const event = window.event as Event;
|
|
if (event && event.target instanceof HTMLElement) {
|
|
const target = event.target as HTMLElement;
|
|
|
|
// If user clicked a button or button icon, don't navigate
|
|
if (target.tagName === "BUTTON" || target.closest("button") !== null) {
|
|
return; // Let HTMX handle the button action
|
|
}
|
|
}
|
|
|
|
// Only navigate if clicking the card body
|
|
const href = element.getAttribute("data-href");
|
|
if (href) {
|
|
window.location.href = href;
|
|
}
|
|
}
|
|
|
|
(window as any).navigateToCollection = navigateToCollection;
|
|
|
|
// ============================================================================
|
|
// Collection Modal UI Helpers
|
|
// ============================================================================
|
|
const borderClasses: Record<string, string> = {
|
|
blue: "border-blue-500",
|
|
red: "border-red-500",
|
|
yellow: "border-yellow-500",
|
|
green: "border-green-500",
|
|
purple: "border-purple-500",
|
|
};
|
|
|
|
// Color selection for create/edit modal
|
|
function selectColor(color: string): void {
|
|
const colorInput = document.getElementById(
|
|
"collection-color",
|
|
) as HTMLInputElement;
|
|
if (colorInput) {
|
|
colorInput.value = color;
|
|
}
|
|
// Update visual selection
|
|
document.querySelectorAll(".color-option").forEach((btn) => {
|
|
(btn as HTMLElement).style.outline = "none";
|
|
(btn as HTMLElement).style.outlineOffset = "0";
|
|
});
|
|
const selectedBtn = document.querySelector(
|
|
`.color-option[onclick="selectColor('${color}')"]`,
|
|
) as HTMLElement;
|
|
if (selectedBtn) {
|
|
selectedBtn.style.outline = "3px solid var(--text-primary)";
|
|
selectedBtn.style.outlineOffset = "3px";
|
|
}
|
|
}
|
|
// Close modal (removes from DOM)
|
|
function closeCollectionModal(): void {
|
|
const modal = document.querySelector(".fixed.inset-0");
|
|
if (modal && modal.parentElement) {
|
|
modal.parentElement.remove();
|
|
}
|
|
}
|
|
|
|
// Initialize color selection on page load
|
|
function initColorSelection(): void {
|
|
const colorInput = document.getElementById(
|
|
"collection-color",
|
|
) as HTMLInputElement;
|
|
if (colorInput && colorInput.value) {
|
|
selectColor(colorInput.value);
|
|
}
|
|
|
|
// Apply border color classes to collection cards
|
|
document.querySelectorAll("[data-color]").forEach((card) => {
|
|
const color = (card as HTMLElement).getAttribute("data-color");
|
|
if (color && borderClasses[color]) {
|
|
// Remove old border color classes
|
|
Object.values(borderClasses).forEach((cls) => {
|
|
(card as HTMLElement).classList.remove(cls);
|
|
});
|
|
// Add new border color class
|
|
(card as HTMLElement).classList.add(borderClasses[color]);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Auto-initialize when DOM is ready
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", initColorSelection);
|
|
} else {
|
|
initColorSelection();
|
|
}
|
|
// Export functions for global access
|
|
(window as any).selectColor = selectColor;
|
|
(window as any).closeCollectionModal = closeCollectionModal;
|
|
(window as any).initColorSelection = initColorSelection;
|
|
|
|
// Initialize icon grid when modal is loaded via HTMX
|
|
function setupHTMXModalInit(): void {
|
|
document.body.addEventListener("htmx:afterSwap", function (evt: Event) {
|
|
const target = (evt as any).detail.target;
|
|
if (target && target.id === "modal-container") {
|
|
initIconSelection();
|
|
}
|
|
});
|
|
}
|
|
// Auto-initialize when DOM is ready
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", setupHTMXModalInit);
|
|
} else {
|
|
setupHTMXModalInit();
|
|
}
|
|
|
|
// Icon data with keywords (single source of truth)
|
|
const iconData: Record<string, string[]> = {
|
|
// Books & Reading
|
|
"📚": ["book", "books", "library", "read", "reading"],
|
|
"📖": ["book", "open", "read", "reading"],
|
|
"📝": ["memo", "note", "write", "writing", "edit"],
|
|
"📰": ["news", "newspaper", "press", "article"],
|
|
"🗂️": ["card", "index", "organize", "file"],
|
|
"📁": ["file", "folder", "directory"],
|
|
"📂": ["folder", "open", "directory"],
|
|
"📓": ["notebook", "book", "write"],
|
|
"📔": ["notebook", "book"],
|
|
"📕": ["book", "read", "red"],
|
|
"📗": ["book", "read", "green"],
|
|
"📘": ["book", "read", "blue"],
|
|
"📙": ["book", "read", "orange"],
|
|
|
|
// Favorites & Activities
|
|
"⭐": ["star", "favorite", "like", "rating"],
|
|
"❤️": ["heart", "love", "favorite", "like"],
|
|
"🔥": ["fire", "hot", "popular", "trending", "flame"],
|
|
"💡": ["idea", "bulb", "light", "think", "smart"],
|
|
"🎯": ["target", "goal", "aim", "focus"],
|
|
"🏆": ["trophy", "winner", "award", "achievement"],
|
|
"🎨": ["art", "paint", "color", "creative"],
|
|
"✅": ["check", "done", "complete", "success"],
|
|
"❌": ["cross", "x", "wrong", "error", "fail"],
|
|
"⚡️": ["bolt", "fast", "quick", "energy"],
|
|
"🚀": ["rocket", "fast", "launch", "space"],
|
|
|
|
// Places & Objects
|
|
"💎": ["gem", "diamond", "stone", "rich"],
|
|
"👍": ["thumb", "up", "good", "yes", "like"],
|
|
"👎": ["thumb", "down", "bad", "no", "dislike"],
|
|
"🏠": ["house", "home", "building"],
|
|
"🏢": ["building", "office", "work", "company"],
|
|
"🚗": ["car", "auto", "vehicle", "drive"],
|
|
"✈️": ["plane", "airplane", "fly", "travel"],
|
|
"🎮": ["game", "play", "video", "gaming"],
|
|
};
|
|
// Helper: Get just the emoji list
|
|
const allIcons = Object.keys(iconData);
|
|
|
|
function populateIconGrid(): void {
|
|
const iconGrid = document.getElementById("icon-grid");
|
|
if (!iconGrid) return;
|
|
// Clear any existing content
|
|
iconGrid.innerHTML = "";
|
|
// Generate buttons from iconData
|
|
Object.entries(iconData).forEach(([emoji, keywords]) => {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "icon-btn text-2xl p-1 hover:bg-opacity-80 rounded";
|
|
button.textContent = emoji;
|
|
button.setAttribute("onclick", `selectIcon('${emoji}')`);
|
|
button.setAttribute("data-keywords", keywords.join(","));
|
|
iconGrid.appendChild(button);
|
|
});
|
|
}
|
|
|
|
function selectIcon(icon: string): void {
|
|
const iconInput = document.getElementById(
|
|
"collection-icon",
|
|
) as HTMLInputElement;
|
|
const searchInput = document.getElementById(
|
|
"icon-search",
|
|
) as HTMLInputElement;
|
|
|
|
if (iconInput) iconInput.value = icon;
|
|
if (searchInput) searchInput.value = icon;
|
|
|
|
// Visual feedback
|
|
document.querySelectorAll(".icon-btn").forEach((btn) => {
|
|
(btn as HTMLElement).style.outline = "none";
|
|
(btn as HTMLElement).style.backgroundColor = "";
|
|
});
|
|
|
|
const selectedBtn = document.querySelector(
|
|
`.icon-btn[onclick="selectIcon('${icon}')"]`,
|
|
) as HTMLElement;
|
|
if (selectedBtn) {
|
|
selectedBtn.style.outline = "2px solid var(--text-primary)";
|
|
selectedBtn.style.backgroundColor = "var(--bg-secondary)";
|
|
}
|
|
}
|
|
function filterIcons(searchTerm: string): void {
|
|
const iconGrid = document.getElementById("icon-grid");
|
|
const iconInput = document.getElementById(
|
|
"collection-icon",
|
|
) as HTMLInputElement;
|
|
// Update hidden input with typed value
|
|
if (iconInput) iconInput.value = searchTerm;
|
|
if (!iconGrid) return;
|
|
const buttons = iconGrid.querySelectorAll(".icon-btn");
|
|
const lowerSearchTerm = searchTerm.toLowerCase();
|
|
|
|
buttons.forEach((btn) => {
|
|
const emoji = (btn as HTMLElement).textContent || "";
|
|
const keywords = iconData[emoji] || [];
|
|
|
|
// Search in keywords OR emoji itself
|
|
const matches =
|
|
searchTerm === "" ||
|
|
emoji.includes(searchTerm) ||
|
|
keywords.some((keyword) =>
|
|
keyword.toLowerCase().includes(lowerSearchTerm),
|
|
);
|
|
|
|
(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);
|
|
}
|
|
}
|
|
// Export for global access
|
|
(window as any).selectIcon = selectIcon;
|
|
(window as any).filterIcons = filterIcons;
|
|
(window as any).showAllIcons = showAllIcons;
|
|
(window as any).populateIconGrid = populateIconGrid;
|
|
(window as any).initIconSelection = initIconSelection;
|
|
|
|
// ============================================================================
|
|
// 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";
|
|
|
|
(window as any).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) {
|
|
(window as any).showToast?.("Please select at least one book", "error");
|
|
return;
|
|
}
|
|
|
|
const bookIds = Array.from(booksToAdd);
|
|
const token = localStorage.getItem("token");
|
|
if (!token) {
|
|
(window as any).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) {
|
|
(window as any).showToast?.(
|
|
`Added ${bookIds.length} book(s) to collection`,
|
|
"success",
|
|
);
|
|
hideAddBooksModal();
|
|
// Note: WebSocket will trigger page reload automatically
|
|
} else {
|
|
(window as any).showToast?.("Failed to add books", "error");
|
|
}
|
|
} catch (error) {
|
|
console.error("Add books error:", error);
|
|
(window as any).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) {
|
|
(window as any).showToast?.("Authentication required", "error");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`/api/collections/${collectionId}/books/${bookId}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
},
|
|
);
|
|
|
|
if (response.ok) {
|
|
(window as any).showToast?.("Book removed from collection", "success");
|
|
// Note: WebSocket will trigger page reload automatically
|
|
} else {
|
|
(window as any).showToast?.("Failed to remove book", "error");
|
|
}
|
|
} catch (error) {
|
|
console.error("Remove book error:", error);
|
|
(window as any).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) {
|
|
(window as any).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) {
|
|
(window as any).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) {
|
|
(window as any).showToast?.(
|
|
`Removed ${result.removed} book(s) from collection`,
|
|
"success",
|
|
);
|
|
// Note: WebSocket will trigger page reload automatically
|
|
} else {
|
|
(window as any).showToast?.("Failed to remove books", "error");
|
|
}
|
|
} else {
|
|
(window as any).showToast?.("Failed to remove books", "error");
|
|
}
|
|
} catch (error) {
|
|
console.error("Bulk remove error:", error);
|
|
(window as any).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";
|
|
}
|
|
}
|
|
|
|
// Export functions globally
|
|
(window as any).initCollectionDetail = initCollectionDetail;
|
|
(window as any).showAddBooksModal = showAddBooksModal;
|
|
(window as any).hideAddBooksModal = hideAddBooksModal;
|
|
(window as any).searchBooksForCollections = searchBooksForCollections;
|
|
(window as any).toggleBookSelection = toggleBookSelection;
|
|
(window as any).addbooksToAdd = addbooksToAdd;
|
|
(window as any).removeBook = removeBook;
|
|
(window as any).toggleBookForRemoval = toggleBookForRemoval;
|
|
(window as any).updateSelectedCount = updateSelectedCount;
|
|
(window as any).removebooksToAdd = removebooksToAdd;
|
|
(window as any).filterCollectionBooks = filterCollectionBooks;
|
|
(window as any).backToCollections = backToCollections;
|
|
|
|
// Auto-initialize
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", initCollectionDetail);
|
|
} else {
|
|
initCollectionDetail();
|
|
}
|