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.
528 lines
18 KiB
TypeScript
528 lines
18 KiB
TypeScript
import { Alpine } from "./alpine";
|
||
import { apiPost, apiPut } from "./api";
|
||
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(
|
||
`carousel-track-${collectionId}`,
|
||
) as HTMLElement;
|
||
if (!track) return;
|
||
|
||
const scrollAmount = direction * SCROLL_AMOUNT;
|
||
track.scrollBy({ left: scrollAmount, behavior: "smooth" });
|
||
}
|
||
|
||
async function openDashboardSettings(): Promise<void> {
|
||
const librarySelect = document.getElementById(
|
||
"library-select",
|
||
) as HTMLSelectElement;
|
||
const libraryId = librarySelect?.value;
|
||
if (!libraryId) {
|
||
showToast("Select a specific library to customize preferences", "error");
|
||
return;
|
||
}
|
||
|
||
const response = await fetch(
|
||
`/api/dashboard/preferences?library_id=${libraryId}`,
|
||
{
|
||
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
||
},
|
||
);
|
||
|
||
if (response.ok) {
|
||
const prefs = await response.json();
|
||
applyPreferencesToModal(prefs);
|
||
} else {
|
||
showToast("Failed to load library preferences", "error");
|
||
console.error("API Error:", response.status, response.statusText);
|
||
return;
|
||
}
|
||
|
||
const modal = document.getElementById(
|
||
"dashboard-settings-modal",
|
||
) as HTMLElement;
|
||
modal?.classList.remove("hidden");
|
||
}
|
||
|
||
function applyPreferencesToModal(prefs: any): void {
|
||
const collectionList = document.getElementById(
|
||
"collection-list",
|
||
) as HTMLElement;
|
||
if (!collectionList) return;
|
||
|
||
const slider = document.querySelector(
|
||
'#dashboard-settings-modal input[type="range"]',
|
||
) as HTMLInputElement;
|
||
const display = document.getElementById("items-count-display") as HTMLElement;
|
||
if (slider && display) {
|
||
slider.value = String(prefs.items_per_section);
|
||
display.textContent = String(prefs.items_per_section);
|
||
}
|
||
|
||
const items = collectionList.querySelectorAll(
|
||
"[data-collection-id]",
|
||
) as NodeListOf<HTMLElement>;
|
||
const orderedItems: HTMLElement[] = [];
|
||
|
||
items.forEach((item) => {
|
||
const collectionId = item.dataset.collectionId;
|
||
const checkbox = item.querySelector(
|
||
'input[type="checkbox"]',
|
||
) as HTMLInputElement;
|
||
|
||
const isHidden = prefs.hidden_collections.includes(collectionId);
|
||
if (checkbox) checkbox.checked = !isHidden;
|
||
|
||
const orderIndex = prefs.collection_order.indexOf(collectionId);
|
||
if (orderIndex !== -1) {
|
||
orderedItems[orderIndex] = item;
|
||
} else {
|
||
orderedItems.push(item);
|
||
}
|
||
});
|
||
|
||
orderedItems.forEach((item) => collectionList.appendChild(item));
|
||
}
|
||
|
||
function closeDashboardSettings(): void {
|
||
const modal = document.getElementById(
|
||
"dashboard-settings-modal",
|
||
) as HTMLElement;
|
||
modal?.classList.add("hidden");
|
||
}
|
||
|
||
async function fetchAndRenderSections(libraryId: string): Promise<void> {
|
||
const param = libraryId ? `library_id=${libraryId}` : "";
|
||
const response = await fetch(
|
||
`/api/dashboard/sections?${param}`,
|
||
{
|
||
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);
|
||
}
|
||
|
||
async function saveDashboardSettings(): Promise<void> {
|
||
const collectionList = document.getElementById(
|
||
"collection-list",
|
||
) as HTMLElement;
|
||
if (!collectionList) return;
|
||
const collectionItems = collectionList.querySelectorAll(
|
||
"[data-collection-id]",
|
||
) as NodeListOf<HTMLElement>;
|
||
const hiddenCollections: string[] = [];
|
||
const collectionOrder: string[] = [];
|
||
collectionItems.forEach((item) => {
|
||
const collectionId = item.dataset.collectionId;
|
||
const checkbox = item.querySelector(
|
||
'input[type="checkbox"]',
|
||
) as HTMLInputElement;
|
||
if (collectionId) {
|
||
collectionOrder.push(collectionId);
|
||
if (checkbox && !checkbox.checked) {
|
||
hiddenCollections.push(collectionId);
|
||
}
|
||
}
|
||
});
|
||
const itemsPerCollection =
|
||
(document.querySelector("#items-count-display") as HTMLElement)
|
||
?.textContent || "20";
|
||
try {
|
||
const librarySelect = document.getElementById(
|
||
"library-select",
|
||
) as HTMLSelectElement;
|
||
const libraryId = librarySelect?.value || "";
|
||
const response = await apiPut("/dashboard/preferences", {
|
||
library_id: libraryId,
|
||
hidden_collections: hiddenCollections,
|
||
collection_order: collectionOrder,
|
||
items_per_section: parseInt(itemsPerCollection),
|
||
});
|
||
if (response.ok) {
|
||
showToast("Dashboard settings saved", "success");
|
||
closeDashboardSettings();
|
||
if (!libraryId) {
|
||
showToast("Select a specific library to customize preferences", "error");
|
||
return;
|
||
}
|
||
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,
|
||
): Promise<void> {
|
||
if (
|
||
!confirm(
|
||
`Are you sure you want to reset "${collectionTitle}" to its default state? Any customizations will be lost.`,
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await apiPost("/dashboard/restore-system-collection", {
|
||
collection_name: collectionName,
|
||
});
|
||
|
||
if (response.ok) {
|
||
showToast(`"${collectionTitle}" restored to defaults`, "success");
|
||
setTimeout(() => window.location.reload(), 1000);
|
||
}
|
||
} catch (error) {
|
||
showToast("Failed to restore system collection", "error");
|
||
console.error("Restore system collection error:", error);
|
||
}
|
||
}
|
||
|
||
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">
|
||
<span class="text-2xl">${section.icon}</span>
|
||
<div>
|
||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">${section.title}</h2>
|
||
${section.description ? `<p class="text-sm" style="color: var(--text-secondary)">${section.description}</p>` : ""}
|
||
</div>
|
||
</div>
|
||
${section.view_all_url ? `<a href="${section.view_all_url}" class="text-sm font-medium hover:underline" style="color: var(--accent);">View All →</a>` : ""}
|
||
</div>
|
||
|
||
<div class="carousel-container relative group">
|
||
<button class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10
|
||
w-12 h-full bg-gradient-to-r from-gray-900 to-transparent
|
||
flex items-center justify-start opacity-0 pointer-events-none
|
||
group-hover:pointer-events-auto group-hover:opacity-100
|
||
transition-opacity duration-200"
|
||
data-action="scroll-carousel"
|
||
data-collection-id="${section.id}"
|
||
data-direction="-1"
|
||
aria-label="Scroll left">
|
||
<span class="text-3xl pl-2" style="color: var(--text-primary);">‹</span>
|
||
</button>
|
||
|
||
<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"
|
||
style="scrollbar-width: none; -ms-overflow-style: none;">
|
||
${
|
||
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>'
|
||
}
|
||
</div>
|
||
|
||
<button class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10
|
||
w-12 h-full bg-gradient-to-l from-gray-900 to-transparent
|
||
flex items-center justify-end opacity-0 pointer-events-none
|
||
group-hover:pointer-events-auto group-hover:opacity-100
|
||
transition-opacity duration-200"
|
||
data-action="scroll-carousel"
|
||
data-collection-id="${section.id}"
|
||
data-direction="1"
|
||
aria-label="Scroll right">
|
||
<span class="text-3xl pr-2" style="color: var(--text-primary);">›</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderDashboardCollections(sections: SectionData[]): void {
|
||
const container = document.getElementById(
|
||
"collections-container",
|
||
) as HTMLElement;
|
||
if (!container) return;
|
||
|
||
const currentWood = container.getAttribute("data-wood");
|
||
|
||
container.innerHTML = sections.map((section) => renderSectionHTML(section)).join("");
|
||
|
||
container.classList.remove("duration-150");
|
||
container.classList.add("duration-300");
|
||
|
||
void container.offsetHeight;
|
||
|
||
container.classList.remove("opacity-0");
|
||
|
||
setTimeout(() => {
|
||
container.classList.remove("transition-opacity", "duration-300");
|
||
}, 300);
|
||
if (currentWood) {
|
||
container.setAttribute("data-wood", currentWood);
|
||
}
|
||
}
|
||
|
||
const BOOK_OPEN_ICON =
|
||
'<svg class="reader-icon h-5 w-5" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
||
'<path d="M12 7v14"></path>' +
|
||
'<path d="M3 4h6a3 3 0 0 1 3 3v14a2 2 0 0 0-2-2H3z"></path>' +
|
||
'<path d="M21 4h-6a3 3 0 0 0-3 3v14a2 2 0 0 1 2-2h7z"></path>' +
|
||
"</svg>";
|
||
|
||
function renderBookCard(book: BookInfo): string {
|
||
const coverUrl = book.cover_image_path || "/static/placeholder-book.svg";
|
||
const actionHref = book.has_conflict
|
||
? `/media/${book.media_item_id}`
|
||
: `/readers/${book.media_item_id}`;
|
||
const actionLabel = book.has_conflict
|
||
? `Resolve progress conflict for ${book.title}`
|
||
: `Read ${book.title}`;
|
||
const actionTitle = book.has_conflict
|
||
? "Resolve progress conflict"
|
||
: "Read";
|
||
|
||
return `
|
||
<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);">
|
||
<img src="${coverUrl}"
|
||
alt="${book.title}"
|
||
class="w-full h-full object-cover"
|
||
loading="lazy"
|
||
onerror="this.src='/static/placeholder-book.svg'">
|
||
</div>
|
||
<div class="book-card-meta">
|
||
<h3 class="font-semibold text-sm leading-snug line-clamp-2" style="color: var(--text-primary)" title="${book.title}">
|
||
${book.title}
|
||
</h3>
|
||
${book.author ? `<p class="text-xs mt-0.5 line-clamp-1" style="color: var(--text-secondary)" title="${book.author}">${book.author}</p>` : ""}
|
||
</div>
|
||
</a>
|
||
<div class="book-card-action">
|
||
<a href="${actionHref}"
|
||
class="book-card-action-btn"
|
||
aria-label="${actionLabel}"
|
||
title="${actionTitle}">${BOOK_OPEN_ICON}</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function reloadPage(): Promise<void> {
|
||
const librarySelect = document.getElementById(
|
||
"library-select",
|
||
) as HTMLSelectElement;
|
||
const libraryId = librarySelect?.value || "";
|
||
|
||
await switchWithTransition("collections-container", () =>
|
||
fetchAndRenderSections(libraryId),
|
||
);
|
||
}
|
||
|
||
function updateItemsCount(
|
||
input: HTMLInputElement,
|
||
displayTarget: string,
|
||
): void {
|
||
const displayElement = document.getElementById(displayTarget) as HTMLElement;
|
||
if (displayElement) {
|
||
displayElement.textContent = input.value;
|
||
}
|
||
}
|
||
|
||
function initDragAndDrop(): void {
|
||
const collectionList = document.getElementById(
|
||
"collection-list",
|
||
) as HTMLElement;
|
||
if (!collectionList) return;
|
||
|
||
let draggedItem: HTMLElement | null = null;
|
||
|
||
collectionList.addEventListener("dragstart", (e: Event) => {
|
||
const target = e.target as HTMLElement;
|
||
if (target.classList.contains("collection-item")) {
|
||
draggedItem = target;
|
||
target.style.opacity = "0.5";
|
||
}
|
||
});
|
||
|
||
collectionList.addEventListener("dragend", (e: Event) => {
|
||
const target = e.target as HTMLElement;
|
||
if (target.classList.contains("collection-item")) {
|
||
target.style.opacity = "1";
|
||
draggedItem = null;
|
||
}
|
||
});
|
||
|
||
collectionList.addEventListener("dragover", (e: Event) => {
|
||
e.preventDefault();
|
||
const target = e.target as HTMLElement;
|
||
if (
|
||
target.classList.contains("collection-item") &&
|
||
target !== draggedItem &&
|
||
draggedItem
|
||
) {
|
||
const rect = target.getBoundingClientRect();
|
||
const midY = rect.top + rect.height / 2;
|
||
if ((e as DragEvent).clientY < midY) {
|
||
target.parentNode?.insertBefore(draggedItem, target);
|
||
} else {
|
||
if (target.parentNode) {
|
||
target.parentNode.insertBefore(draggedItem, target.nextSibling);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
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;
|
||
const action = actionElem?.getAttribute("data-action");
|
||
|
||
switch (action) {
|
||
case "scroll-carousel": {
|
||
const collectionId =
|
||
target.dataset.collectionId || actionElem?.dataset.collectionId;
|
||
const direction = parseInt(
|
||
target.dataset.direction || actionElem?.dataset.direction || "0",
|
||
);
|
||
if (collectionId) scrollCarousel(collectionId, direction);
|
||
break;
|
||
}
|
||
|
||
case "open-dashboard-settings":
|
||
openDashboardSettings();
|
||
break;
|
||
|
||
case "close-dashboard-settings":
|
||
closeDashboardSettings();
|
||
break;
|
||
|
||
case "save-dashboard-settings":
|
||
saveDashboardSettings();
|
||
break;
|
||
|
||
case "restore-system-collection": {
|
||
const colName =
|
||
actionElem?.dataset.collectionName || target.dataset.collectionName;
|
||
const colTitle =
|
||
actionElem?.dataset.collectionTitle ||
|
||
target.dataset.collectionTitle ||
|
||
"System Collection";
|
||
if (colName) restoreSystemCollection(colName, colTitle);
|
||
break;
|
||
}
|
||
|
||
case "reload-page":
|
||
reloadPage();
|
||
break;
|
||
}
|
||
});
|
||
|
||
document.addEventListener("input", (e: Event) => {
|
||
const target = e.target as HTMLElement;
|
||
const actionElem = target.closest("[data-input-action]") as HTMLElement;
|
||
const action = actionElem?.getAttribute("data-input-action");
|
||
|
||
switch (action) {
|
||
case "update-items-count": {
|
||
const input = target as HTMLInputElement;
|
||
const displayTarget = input.getAttribute("target");
|
||
if (displayTarget) updateItemsCount(input, displayTarget);
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
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",
|
||
},
|
||
},
|
||
);
|
||
if (!response.ok) return;
|
||
const data = await response.json();
|
||
|
||
const container = document.getElementById(
|
||
"collections-container",
|
||
) as HTMLElement;
|
||
if (!container) return;
|
||
|
||
const freshSectionIds = new Set(
|
||
(data.sections as SectionData[]).map((s) => s.id),
|
||
);
|
||
|
||
for (const section of data.sections as SectionData[]) {
|
||
const track = document.getElementById(
|
||
`carousel-track-${section.id}`,
|
||
);
|
||
|
||
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);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
export { initDashboard };
|
||
|
||
Alpine.data("dashboard", () => ({
|
||
initDashboard,
|
||
}));
|