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:
+35
-103
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user