feat(frontend): integrate shared library switcher into all pages

Wire up the shared library switcher module on dashboard, collections
list, collection detail, and collection rules pages. All pages use SSR
for initial load and AJAX with fade transitions on library switch.

web/src/collections.ts:
- Add initCollectionsPage() that auto-detects list vs detail page
  by checking for #collection-data element
- Collections list: onSwitch fetches /api/collections?library_id=X and
  re-renders the grid with per-library book counts
- Collection detail: onSwitch fetches /api/collections/:id?library_id=X
  and re-renders the books grid
- Add renderCollectionsGrid() and renderCollectionBooks() with
  Alpine.initTree() calls for dynamic content
- Collection cards now link with ?library_id= from selected library
- Update hidden #collection-data data-library-id on switch

web/src/dashboard.ts:
- Replace standalone switchLibrary() with initLibrarySwitcher() +
  switchWithTransition() from shared module
- Extract fetchAndRenderSections() helper shared by onSwitch callback,
  reloadPage(), and saveDashboardSettings()
- Remove inline #library-select change listener and switch-library
  data-action handler (now handled by shared module)
- Scan-complete event handler unchanged (independent incremental logic)

web/src/collection-rules.ts:
- Update backToCollection() to preserve library context by appending
  ?library_id= from localStorage selectedLibrary key
This commit is contained in:
2026-05-17 21:12:45 -04:00
parent 43d8afc6cd
commit 6e2d304b35
3 changed files with 184 additions and 152 deletions
+5 -1
View File
@@ -37,7 +37,11 @@ function setupEventDelegation(): void {
function backToCollection(): void {
if (!collectionId) return;
window.location.href = `/collections/${collectionId}`;
const libraryId = localStorage.getItem("selectedLibrary");
const url = libraryId
? `/collections/${collectionId}?library_id=${libraryId}`
: `/collections/${collectionId}`;
window.location.href = url;
}
function getFieldLabel(field: string): string {
+144 -48
View File
@@ -1,6 +1,11 @@
import { Alpine } from "./alpine";
import { showToast } from "./toast";
import { createWebSocket } from "./websocket";
import {
getCurrentLibraryId,
initLibrarySwitcher,
switchWithTransition,
} from "./library-switcher";
let collectionId: string | null = null;
@@ -60,39 +65,104 @@ async function loadCollections(): Promise<void> {
if (response.ok) {
const data = await response.json();
renderCollections(data.collections || []);
renderCollectionsGrid(data.collections || []);
}
} catch (error) {
console.error("Failed to load collections:", error);
}
}
function renderCollections(collections: CollectionData[]): void {
function renderCollectionsGrid(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>';
container.innerHTML = `
<div class="text-center py-16 col-span-full" style="color: var(--text-secondary)">
<div class="text-6xl mb-4">📚</div>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Collections Yet</h3>
<p class="mb-4">Create collections to organize your books</p>
<button hx-get="/collections/create-modal" hx-target="#modal-container" hx-swap="innerHTML"
class="btn-primary px-4 py-2 rounded-lg">Create Your First Collection</button>
</div>`;
return;
}
const libraryId = getCurrentLibraryId();
const libraryParam = libraryId ? `?library_id=${libraryId}` : "";
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>
(col) => `
<div @click="navigateToCollection($el)" data-href="/collections/${col.id}${libraryParam}" class="block">
<div class="card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow"
style="background-color: var(--bg-secondary);"
data-color="${col.color}">
<div class="flex justify-between items-start mb-4">
<div class="text-3xl">${col.icon}</div>
<div class="flex space-x-2">
<button hx-get="/collections/${col.id}/edit-modal" hx-target="#modal-container"
hx-swap="innerHTML" class="p-2 hover:opacity-80 rounded"
style="color: var(--text-secondary); background-color: var(--bg-primary);">✏️</button>
<button hx-delete="/api/collections/${col.id}" hx-redirect="/collections"
hx-confirm="Are you sure you want to delete this collection?"
class="p-2 hover:opacity-80 rounded"
style="color: var(--text-secondary); background-color: var(--bg-primary);">🗑️</button>
</div>
</div>
</a>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">${col.name}</h3>
<p class="text-sm mb-4" style="color: var(--text-secondary)">${col.description}</p>
${col.book_count > 0 ? `<p class="text-xs" style="color: var(--text-secondary)">${col.book_count} books</p>` : ""}
</div>
</div>
`,
)
.join("");
initColorSelection();
Alpine.initTree(container);
}
function renderCollectionBooks(books: BookInfo[]): void {
const container = document.getElementById("books-container");
if (!container) return;
if (books.length === 0) {
container.innerHTML = `<div id="empty-state" class="col-span-full text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>`;
return;
}
container.innerHTML = books
.map(
(book) => `
<a href="/media/${book.media_item_id}">
<div class="card p-4 rounded-lg border hover:shadow-lg transition-shadow"
style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex gap-4">
<div class="flex-shrink-0 pt-1">
<input type="checkbox" onchange="toggleBookForRemoval('${book.media_item_id}')" class="w-5 h-5"/>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-lg mb-1 line-clamp-2" style="color: var(--text-primary)">${book.title}</h3>
${book.author ? `<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">by ${book.author}</p>` : ""}
</div>
<div class="flex-shrink-0 w-16 sm:w-20">
<img src="${book.cover_image_path || "/static/placeholder-book.svg"}" alt="Cover"
class="w-full aspect-[3/4] object-cover rounded shadow-md"
onerror="this.src='/static/placeholder-book.svg'"/>
</div>
</div>
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<button @click="removeBook('${book.media_item_id}')" class="px-3 py-1 text-sm border rounded hover:opacity-80"
style="border-color: var(--border); color: var(--text-secondary);">🗑️ Remove from Collection</button>
</div>
</div>
</a>
`,
)
.join("");
Alpine.initTree(container);
}
async function loadCollectionRules(collectionId: string): Promise<void> {
@@ -240,7 +310,6 @@ function renderTestResults(results: unknown[]): void {
container.innerHTML = `<p class="p-2 text-sm" style="color: var(--text-secondary)">${Array.isArray(results) ? results.length : 0} matching books</p>`;
}
// Add authorization header to all HTMX requests
function setupHTMXAuth(): void {
document.body.addEventListener("htmx:configRequest", function (evt: Event) {
const token = localStorage.getItem("token");
@@ -249,27 +318,20 @@ function setupHTMXAuth(): void {
}
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
return;
}
}
// Only navigate if clicking the card body
const href = element.getAttribute("data-href");
if (href) {
window.location.href = href;
}
}
// ============================================================================
// Collection Modal UI Helpers
// ============================================================================
const borderClasses: Record<string, string> = {
blue: "border-blue-500",
red: "border-red-500",
@@ -278,7 +340,6 @@ const borderClasses: Record<string, string> = {
purple: "border-purple-500",
};
// Color selection for create/edit modal
function selectColor(color: string): void {
const colorInput = document.getElementById(
"collection-color",
@@ -286,7 +347,6 @@ function selectColor(color: string): void {
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";
@@ -299,7 +359,7 @@ function selectColor(color: string): void {
selectedBtn.style.outlineOffset = "3px";
}
}
// Close modal (removes from DOM)
function closeCollectionModal(): void {
const modal = document.querySelector(".fixed.inset-0");
if (modal) {
@@ -307,7 +367,6 @@ function closeCollectionModal(): void {
}
}
// Initialize color selection on page load
function initColorSelection(): void {
const colorInput = document.getElementById(
"collection-color",
@@ -316,21 +375,17 @@ function initColorSelection(): void {
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]);
}
});
}
// Initialize icon grid when modal is loaded via HTMX
function setupHTMXModalInit(): void {
document.body.addEventListener("htmx:afterSwap", function (evt: CustomEvent) {
const target = evt.detail.target;
@@ -342,10 +397,8 @@ function setupHTMXModalInit(): void {
}
function showAllIcons(): void {
// Populate grid if empty
populateIconGrid();
// Clear search filter
const searchInput = document.getElementById(
"icon-search",
) as HTMLInputElement;
@@ -353,7 +406,6 @@ function showAllIcons(): void {
searchInput.value = "";
}
// Show all icons
const iconGrid = document.getElementById("icon-grid");
if (!iconGrid) return;
@@ -363,9 +415,7 @@ function showAllIcons(): void {
});
}
// 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"],
@@ -379,8 +429,6 @@ const iconData: Record<string, string[]> = {
"📗": ["book", "read", "green"],
"📘": ["book", "read", "blue"],
"📙": ["book", "read", "orange"],
// Favorites & Activities
"⭐": ["star", "favorite", "like", "rating"],
"❤️": ["heart", "love", "favorite", "like"],
"🔥": ["fire", "hot", "popular", "trending", "flame"],
@@ -392,8 +440,6 @@ const iconData: Record<string, string[]> = {
"❌": ["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"],
@@ -403,14 +449,11 @@ const iconData: Record<string, string[]> = {
"✈️": ["plane", "airplane", "fly", "travel"],
"🎮": ["game", "play", "video", "gaming"],
};
// Helper: Get just the emoji list
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";
@@ -433,7 +476,6 @@ function selectIcon(icon: string): void {
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 = "";
@@ -447,12 +489,12 @@ function selectIcon(icon: string): void {
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");
@@ -462,7 +504,6 @@ function filterIcons(searchTerm: string): void {
const emoji = (btn as HTMLElement).textContent || "";
const keywords = iconData[emoji] || [];
// Search in keywords OR emoji itself
const matches =
searchTerm === "" ||
emoji.includes(searchTerm) ||
@@ -474,7 +515,61 @@ function filterIcons(searchTerm: string): void {
});
}
// Export functions globally
function isDetailPage(): boolean {
const dataEl = document.getElementById("collection-data");
return !!dataEl?.dataset.id;
}
function initCollectionsPage(): void {
if (isDetailPage()) {
const dataEl = document.getElementById("collection-data");
const collId = dataEl?.dataset.id || "";
initLibrarySwitcher({
onSwitch: async (libraryId) => {
const dataEl = document.getElementById("collection-data");
if (dataEl) dataEl.dataset.libraryId = libraryId;
await switchWithTransition("books-container", async () => {
const param = libraryId ? `?library_id=${libraryId}` : "";
const response = await fetch(
`/api/collections/${collId}${param}`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
"Content-Type": "application/json",
},
},
);
if (!response.ok) throw new Error("Failed to load collection");
const data = await response.json();
renderCollectionBooks(data.books || []);
});
},
});
} else {
initLibrarySwitcher({
onSwitch: async (libraryId) => {
await switchWithTransition("collections-list", async () => {
const param = libraryId ? `?library_id=${libraryId}` : "";
const response = await fetch(`/api/collections${param}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
"Content-Type": "application/json",
},
});
if (!response.ok) throw new Error("Failed to load collections");
const data = await response.json();
renderCollectionsGrid(data.collections || []);
});
},
});
}
initializeCollectionWebSocket();
setupHTMXModalInit();
}
export {
closeCollectionModal,
createRule,
@@ -482,6 +577,7 @@ export {
filterIcons,
initColorSelection,
initializeCollectionWebSocket,
initCollectionsPage,
loadCollectionRules,
loadCollections,
navigateToCollection,
@@ -495,13 +591,13 @@ export {
};
Alpine.data("collections", () => ({
// Collection Methods
closeCollectionModal,
createRule,
deleteRule,
filterIcons,
initColorSelection,
initializeCollectionWebSocket,
initCollectionsPage,
loadCollectionRules,
loadCollections,
navigateToCollection,
+35 -103
View File
@@ -1,9 +1,7 @@
import { Alpine } from "./alpine";
import { apiPost, apiPut } from "./api";
import { showToast } from "./toast";
// Dashboard functionality with unified collections architecture
// Procedural/imperative style (no OOP)
import { initLibrarySwitcher, switchWithTransition } from "./library-switcher";
const SCROLL_AMOUNT = 300;
@@ -40,7 +38,7 @@ async function openDashboardSettings(): Promise<void> {
} else {
showToast("Failed to load library preferences", "error");
console.error("API Error:", response.status, response.statusText);
return; // Don't open modal with stale data.
return;
}
const modal = document.getElementById(
@@ -64,7 +62,6 @@ function applyPreferencesToModal(prefs: any): void {
display.textContent = String(prefs.items_per_section);
}
// Update checkboxes and reorder items
const items = collectionList.querySelectorAll(
"[data-collection-id]",
) as NodeListOf<HTMLElement>;
@@ -76,11 +73,9 @@ function applyPreferencesToModal(prefs: any): void {
'input[type="checkbox"]',
) as HTMLInputElement;
// Update checkbox state
const isHidden = prefs.hidden_collections.includes(collectionId);
if (checkbox) checkbox.checked = !isHidden;
// Sort according to collection_order
const orderIndex = prefs.collection_order.indexOf(collectionId);
if (orderIndex !== -1) {
orderedItems[orderIndex] = item;
@@ -89,7 +84,6 @@ function applyPreferencesToModal(prefs: any): void {
}
});
// Reorder in DOM
orderedItems.forEach((item) => collectionList.appendChild(item));
}
@@ -99,6 +93,23 @@ function closeDashboardSettings(): void {
) as HTMLElement;
modal?.classList.add("hidden");
}
async function fetchAndRenderSections(libraryId: string): Promise<void> {
const response = await fetch(
`/api/dashboard/sections?library_id=${libraryId}`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
"Content-Type": "application/json",
},
},
);
if (!response.ok) throw new Error("Failed to load sections");
const data = await response.json();
renderDashboardCollections(data.sections);
localStorage.setItem("selectedLibrary", libraryId);
}
async function saveDashboardSettings(): Promise<void> {
const collectionList = document.getElementById(
"collection-list",
@@ -138,33 +149,18 @@ async function saveDashboardSettings(): Promise<void> {
if (response.ok) {
showToast("Dashboard settings saved", "success");
closeDashboardSettings();
// Fetch updated sections and re-render (like switchLibrary does)
if (!libraryId) {
showToast("Unable to refresh dashboard - no library selected", "error");
return;
}
const sectionResponse = await fetch(
`/api/dashboard/sections?library_id=${libraryId}`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
"Content-Type": "application/json",
},
},
);
if (sectionResponse.ok) {
const data = await sectionResponse.json();
renderDashboardCollections(data.sections);
localStorage.setItem("selectedLibrary", libraryId);
} else {
showToast("Failed to refresh sections", "error");
}
await fetchAndRenderSections(libraryId);
}
} catch (error) {
showToast("Failed to save settings", "error");
console.error("Save dashboard settings error:", error);
}
}
async function restoreSystemCollection(
collectionName: string,
collectionTitle: string,
@@ -192,50 +188,6 @@ async function restoreSystemCollection(
}
}
async function switchLibrary(libraryId: string): Promise<void> {
const container = document.getElementById(
"collections-container",
) as HTMLElement;
const loading = document.getElementById("loading-spinner") as HTMLElement;
if (!container || !loading) return;
try {
// Step 1: Fade out current content (150ms)
container.classList.add("opacity-0", "transition-opacity", "duration-150");
// Wait for fade-out to complete
await new Promise((resolve) => setTimeout(resolve, 150));
// Step 2: Show loading spinner
loading.classList.remove("hidden");
// Step 3: Fetch new data
const response = await fetch(
`/api/dashboard/sections?library_id=${libraryId}`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
throw new Error("Failed to load sections");
}
const data = await response.json();
renderDashboardCollections(data.sections);
localStorage.setItem("selectedLibrary", libraryId);
} catch (error) {
showToast("Failed to load library", "error");
console.error("Switch library error:", error);
} finally {
loading.classList.add("hidden");
}
}
function renderSectionHTML(section: SectionData): string {
return `
<div class="dashboard-collection mb-8" data-collection-id="${section.id}">
@@ -264,8 +216,8 @@ function renderSectionHTML(section: SectionData): string {
<div id="carousel-track-${section.id}"
class="carousel-track flex gap-4 overflow-x-auto
scroll-smooth snap-x snap-mandatory
px-12 pb-4"
scroll-smooth snap-x snap-mandatory
px-12 pb-4"
style="scrollbar-width: none; -ms-overflow-style: none;">
${
section.items.length > 0
@@ -297,23 +249,17 @@ function renderDashboardCollections(sections: SectionData[]): void {
) as HTMLElement;
if (!container) return;
// Preserve wood paneling attribute
const currentWood = container.getAttribute("data-wood");
// Replace content while still invisible (opacity-0 from switchLibrary)
container.innerHTML = sections.map((section) => renderSectionHTML(section)).join("");
// Step 5: Fade in new content (300ms for smoother entrance)
container.classList.remove("duration-150");
container.classList.add("duration-300");
// Trigger reflow to ensure transition happens
void container.offsetHeight;
// Fade to visible
container.classList.remove("opacity-0");
// Clean up transition classes after animation completes
setTimeout(() => {
container.classList.remove("transition-opacity", "duration-300");
}, 300);
@@ -352,21 +298,20 @@ function renderBookCard(book: BookInfo): string {
`;
}
async function reloadPage(): Promise<void> {
//Get current library from dropdown
const librarySelect = document.getElementById(
"library-select",
) as HTMLSelectElement;
const currentLibraryId = librarySelect?.value;
const libraryId = librarySelect?.value;
if (!currentLibraryId) {
if (!libraryId) {
showToast("No library selected", "error");
return;
}
// Reuse switchLibrary logic - it handles the fade transition
await switchLibrary(currentLibraryId);
await switchWithTransition("collections-container", () =>
fetchAndRenderSections(libraryId),
);
}
function updateItemsCount(
@@ -427,6 +372,13 @@ function initDragAndDrop(): void {
function initDashboard() {
initDragAndDrop();
initLibrarySwitcher({
onSwitch: (libraryId) =>
switchWithTransition("collections-container", () =>
fetchAndRenderSections(libraryId),
),
});
document.addEventListener("click", (e: Event) => {
const target = e.target as HTMLElement;
const actionElem = target.closest("[data-action]") as HTMLElement;
@@ -466,18 +418,10 @@ function initDashboard() {
break;
}
case "reload-page":
reloadPage();
break;
case "switch-library": {
const select = target as HTMLSelectElement;
if (select.value) switchLibrary(select.value);
break;
}
}
});
document.addEventListener("input", (e: Event) => {
@@ -495,18 +439,6 @@ function initDashboard() {
}
});
const librarySelect = document.getElementById(
"library-select",
) as HTMLSelectElement;
if (librarySelect) {
librarySelect.addEventListener("change", (e) => {
const target = e.target as HTMLSelectElement;
if (target.value) {
switchLibrary(target.value);
}
});
}
window.addEventListener("bookhoard:scan-complete", async () => {
console.log("[dashboard] scan-complete event received");
const librarySelect = document.getElementById(