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.
541 lines
18 KiB
TypeScript
541 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;
|
||
|
||
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,
|
||
}));
|