fix(dashboard): stop duplicating items after scan completion
Release / build-and-push (push) Successful in 5m9s
Release / build-and-push (push) Successful in 5m9s
The scan-complete handler in dashboard.ts attempted to deduplicate book
cards by querying [data-media-item-id], but neither the client-side
renderBookCard nor the server-side BookCard template ever set that
attribute. As a result the dedup Set was always empty, every item from
the API response was treated as new, and all items were prepended via
insertAdjacentHTML('afterbegin', ...) on every 5-minute scan — causing
visible duplication (doubling, tripling) that only cleared on page
refresh.
Fix by replacing the fragile dedup-and-prepend logic with a per-track
full innerHTML replace. This is simpler, correctly handles items that
should be removed after a scan (the old code never removed anything),
and also removes stale sections no longer returned by the API.
Additional hardening:
- Add data-media-item-id to both renderBookCard (dashboard.ts) and the
server-side card wrapper (dashboard.templ) so server-rendered and
JS-rendered cards are structurally identical.
- Guard the bookhoard:scan-complete listener registration with a
module-level boolean (scanListenerRegistered) so the handler cannot
accumulate if Alpine ever re-inits the body subtree.
- Remove debug console.log statements from the scan handler.
This commit is contained in:
+54
-67
@@ -4,6 +4,7 @@ import { showToast } from "./toast";
|
||||
import { initLibrarySwitcher, switchWithTransition } from "./library-switcher";
|
||||
|
||||
const SCROLL_AMOUNT = 300;
|
||||
let scanListenerRegistered = false;
|
||||
|
||||
function scrollCarousel(collectionId: string, direction: number): void {
|
||||
const track = document.getElementById(
|
||||
@@ -290,7 +291,7 @@ function renderBookCard(book: BookInfo): string {
|
||||
: "Read";
|
||||
|
||||
return `
|
||||
<div class="flex-shrink-0 w-36 sm:w-40 snap-start">
|
||||
<div class="flex-shrink-0 w-36 sm:w-40 snap-start" data-media-item-id="${book.media_item_id}">
|
||||
<div class="book-card relative w-full h-full rounded-xl overflow-hidden cursor-pointer">
|
||||
<a href="/media/${book.media_item_id}" class="block h-full">
|
||||
<div class="book-card-cover aspect-[2/3] overflow-hidden" style="background-color: color-mix(in srgb, var(--text-primary) 8%, transparent);">
|
||||
@@ -454,83 +455,69 @@ 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 (!scanListenerRegistered) {
|
||||
scanListenerRegistered = true;
|
||||
window.addEventListener("bookhoard:scan-complete", async () => {
|
||||
const librarySelect = document.getElementById(
|
||||
"library-select",
|
||||
) as HTMLSelectElement;
|
||||
const libraryId = librarySelect?.value || "";
|
||||
|
||||
try {
|
||||
const param = libraryId ? `library_id=${libraryId}` : "";
|
||||
const response = await fetch(
|
||||
`/api/dashboard/sections?${param}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
"Content-Type": "application/json",
|
||||
try {
|
||||
const param = libraryId ? `library_id=${libraryId}` : "";
|
||||
const response = await fetch(
|
||||
`/api/dashboard/sections?${param}`,
|
||||
{
|
||||
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 }))));
|
||||
);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
|
||||
const container = document.getElementById(
|
||||
"collections-container",
|
||||
) as HTMLElement;
|
||||
if (!container) {
|
||||
console.log("[dashboard] no collections-container found");
|
||||
return;
|
||||
}
|
||||
const container = document.getElementById(
|
||||
"collections-container",
|
||||
) as HTMLElement;
|
||||
if (!container) return;
|
||||
|
||||
for (const section of data.sections as SectionData[]) {
|
||||
const track = document.getElementById(
|
||||
`carousel-track-${section.id}`,
|
||||
const freshSectionIds = new Set(
|
||||
(data.sections as SectionData[]).map((s) => s.id),
|
||||
);
|
||||
|
||||
if (!track) {
|
||||
console.log("[dashboard] creating new section:", section.id, section.title);
|
||||
container.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
renderSectionHTML(section),
|
||||
for (const section of data.sections as SectionData[]) {
|
||||
const track = document.getElementById(
|
||||
`carousel-track-${section.id}`,
|
||||
);
|
||||
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();
|
||||
if (!track) {
|
||||
container.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
renderSectionHTML(section),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
track.innerHTML = section.items.length > 0
|
||||
? section.items.map((item) => renderBookCard(item)).join("")
|
||||
: '<div class="text-center py-8 w-full" style="color: var(--text-secondary);"><p>No items in this collection</p></div>';
|
||||
}
|
||||
|
||||
const existingSections = container.querySelectorAll<HTMLElement>(
|
||||
".dashboard-collection",
|
||||
);
|
||||
existingSections.forEach((sec) => {
|
||||
const secId = sec.getAttribute("data-collection-id");
|
||||
if (secId && !freshSectionIds.has(secId)) {
|
||||
sec.remove();
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[dashboard] scan-complete handler error:", err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[dashboard] scan-complete handler error:", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { initDashboard };
|
||||
|
||||
Reference in New Issue
Block a user