feat(dashboard): dynamic scan-complete refresh without page reload
When a scan completes, dynamically update the dashboard carousels instead of requiring a full page reload: - Listen for bookhoard:scan-complete custom event dispatched by header - Fetch updated sections from /api/dashboard/sections - Diff existing book cards by data-media-item-id attribute - Prepend new items to carousel tracks (afterbegin) to match API sort order - Create entirely new section DOM for sections that don't yet exist on page - Remove 'No items' placeholder when items are added - Scroll carousel to left (scrollLeft=0) so newly prepended items are visible Also: - Extract renderSectionHTML() helper from renderDashboardCollections() for reuse - Add data-media-item-id attribute to book card template for DOM diffing - Add diagnostic console.log statements for debugging scan-complete flow
This commit is contained in:
+95
-16
@@ -236,19 +236,8 @@ async function switchLibrary(libraryId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDashboardCollections(sections: SectionData[]): void {
|
function renderSectionHTML(section: SectionData): string {
|
||||||
const container = document.getElementById(
|
return `
|
||||||
"collections-container",
|
|
||||||
) 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) => `
|
|
||||||
<div class="dashboard-collection mb-8" data-collection-id="${section.id}">
|
<div class="dashboard-collection mb-8" data-collection-id="${section.id}">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -299,9 +288,20 @@ function renderDashboardCollections(sections: SectionData[]): void {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`;
|
||||||
)
|
}
|
||||||
.join("");
|
|
||||||
|
function renderDashboardCollections(sections: SectionData[]): void {
|
||||||
|
const container = document.getElementById(
|
||||||
|
"collections-container",
|
||||||
|
) 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)
|
// Step 5: Fade in new content (300ms for smoother entrance)
|
||||||
container.classList.remove("duration-150");
|
container.classList.remove("duration-150");
|
||||||
@@ -329,6 +329,7 @@ function renderBookCard(book: BookInfo): string {
|
|||||||
<a href="/media/${book.media_item_id}">
|
<a href="/media/${book.media_item_id}">
|
||||||
<div class="book-card flex-shrink-0 w-36 rounded-lg overflow-hidden snap-start cursor-pointer
|
<div class="book-card flex-shrink-0 w-36 rounded-lg overflow-hidden snap-start cursor-pointer
|
||||||
transition-transform duration-200 hover:scale-105"
|
transition-transform duration-200 hover:scale-105"
|
||||||
|
data-media-item-id="${book.media_item_id}"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="button"
|
role="button"
|
||||||
aria-label="View ${book.title}">
|
aria-label="View ${book.title}">
|
||||||
@@ -505,6 +506,84 @@ function initDashboard() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.addEventListener("bookhoard:scan-complete", async () => {
|
||||||
|
console.log("[dashboard] scan-complete event received");
|
||||||
|
const librarySelect = document.getElementById(
|
||||||
|
"library-select",
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
const libraryId = librarySelect?.value;
|
||||||
|
console.log("[dashboard] libraryId:", libraryId);
|
||||||
|
if (!libraryId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/dashboard/sections?library_id=${libraryId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
console.log("[dashboard] fetch status:", response.status);
|
||||||
|
if (!response.ok) return;
|
||||||
|
const data = await response.json();
|
||||||
|
console.log("[dashboard] sections:", data.sections?.length, JSON.stringify(data.sections?.map((s: SectionData) => ({ id: s.id, items: s.items?.length }))));
|
||||||
|
|
||||||
|
const container = document.getElementById(
|
||||||
|
"collections-container",
|
||||||
|
) as HTMLElement;
|
||||||
|
if (!container) {
|
||||||
|
console.log("[dashboard] no collections-container found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const section of data.sections as SectionData[]) {
|
||||||
|
const track = document.getElementById(
|
||||||
|
`carousel-track-${section.id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!track) {
|
||||||
|
console.log("[dashboard] creating new section:", section.id, section.title);
|
||||||
|
container.insertAdjacentHTML(
|
||||||
|
"beforeend",
|
||||||
|
renderSectionHTML(section),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = new Set(
|
||||||
|
Array.from(track.querySelectorAll<HTMLElement>("[data-media-item-id]")).map(
|
||||||
|
(el) => el.dataset.mediaItemId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log("[dashboard] section:", section.id, "existing:", existing.size, "api items:", section.items.length);
|
||||||
|
|
||||||
|
let added = false;
|
||||||
|
const newItems: string[] = [];
|
||||||
|
for (const item of section.items) {
|
||||||
|
if (existing.has(item.media_item_id)) continue;
|
||||||
|
newItems.push(renderBookCard(item));
|
||||||
|
added = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (added) {
|
||||||
|
track.insertAdjacentHTML("afterbegin", newItems.join(""));
|
||||||
|
track.scrollLeft = 0;
|
||||||
|
|
||||||
|
const placeholder = track.querySelector<HTMLElement>(
|
||||||
|
'.text-center.py-8',
|
||||||
|
);
|
||||||
|
if (placeholder) {
|
||||||
|
placeholder.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[dashboard] scan-complete handler error:", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { initDashboard };
|
export { initDashboard };
|
||||||
|
|||||||
Reference in New Issue
Block a user