Files
bookhoard/web/src/dashboard.ts
T
john-okeefe 37e092c2a8 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
2026-05-16 19:31:34 -04:00

594 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Alpine } from "./alpine";
import { apiPost, apiPut } from "./api";
import { showToast } from "./toast";
// Dashboard functionality with unified collections architecture
// Procedural/imperative style (no OOP)
const SCROLL_AMOUNT = 300;
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("No library selected", "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; // Don't open modal with stale data.
}
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);
}
// Update checkboxes and reorder items
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;
// 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;
} else {
orderedItems.push(item);
}
});
// Reorder in DOM
orderedItems.forEach((item) => collectionList.appendChild(item));
}
function closeDashboardSettings(): void {
const modal = document.getElementById(
"dashboard-settings-modal",
) as HTMLElement;
modal?.classList.add("hidden");
}
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();
// 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");
}
}
} 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);
}
}
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}">
<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 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 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;
// 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);
if (currentWood) {
container.setAttribute("data-wood", currentWood);
}
}
function renderBookCard(book: BookInfo): string {
const coverUrl = book.cover_image_path || "/static/placeholder-book.svg";
return `
<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}">
<div class="aspect-[2/3] overflow-hidden shadow-lg
bg-gradient-to-br from-gray-700 to-gray-900">
<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-text px-2 py-1 bg-[color-mix(in_srgb,var(--wood-border)_40%,transparent)]">
<h3 class="font-semibold text-base line-clamp-2" style="color: var(--text-primary)">
${book.title}
</h3>
${book.author ? `<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">${book.author}</p>` : ""}
</div>
</div>
</a>
`;
}
async function reloadPage(): Promise<void> {
//Get current library from dropdown
const librarySelect = document.getElementById(
"library-select",
) as HTMLSelectElement;
const currentLibraryId = librarySelect?.value;
if (!currentLibraryId) {
showToast("No library selected", "error");
return;
}
// Reuse switchLibrary logic - it handles the fade transition
await switchLibrary(currentLibraryId);
}
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();
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;
case "switch-library": {
const select = target as HTMLSelectElement;
if (select.value) switchLibrary(select.value);
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;
}
}
});
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(
"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 };
Alpine.data("dashboard", () => ({
initDashboard,
}));