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:
2026-05-16 19:31:34 -04:00
parent 4f7794767d
commit 37e092c2a8
+95 -16
View File
@@ -236,19 +236,8 @@ async function switchLibrary(libraryId: string): Promise<void> {
}
}
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) => `
function renderSectionHTML(section: SectionData): string {
return `
<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 gap-3">
@@ -299,9 +288,20 @@ function renderDashboardCollections(sections: SectionData[]): void {
</button>
</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)
container.classList.remove("duration-150");
@@ -329,6 +329,7 @@ function renderBookCard(book: BookInfo): string {
<a href="/media/${book.media_item_id}">
<div class="book-card flex-shrink-0 w-36 rounded-lg overflow-hidden snap-start cursor-pointer
transition-transform duration-200 hover:scale-105"
data-media-item-id="${book.media_item_id}"
tabindex="0"
role="button"
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 };