Files
bookhoard/web/src/dashboard.ts
T
john-okeefe f67232a20b fix(ui): show read action on JS-rendered dashboard cards
The dashboard re-renders its sections client-side (library switch,
refresh, saving settings) via renderBookCard in dashboard.ts, which was
still the old markup with no .book-card-action overlay. So the read
button appeared on the server-rendered cards but vanished as soon as the
dashboard re-rendered, while the bookshelf (always templ-rendered) kept
working.

- Rewrite renderBookCard to match the templ BookCard: detail link plus
  the play/read action overlay, routing to the reader or the detail page
  when the book has an active conflict.
- Add has_conflict to the BookInfo TS type and stamp it in the dashboard
  sections API (GetSections) so client-rendered cards can route correctly.
- Add pointer-events-none / group-hover:pointer-events-auto to the
  client-rendered carousel nav buttons so they no longer swallow hover
  over edge cards, matching the templ fix.
2026-08-06 08:40:06 -04:00

541 lines
18 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";
import { initLibrarySwitcher, switchWithTransition } from "./library-switcher";
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("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">
<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;
}
}
});
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);
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 }))));
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,
}));