Files
bookhoard/web/src/dashboard.ts
T
John O'Keefe b1de0da512 feat(web): dashboard All Libraries view saves the global scope
The All Libraries entry in the library switcher could render the
cross-library dashboard but rejected its own settings: the modal
refused to open without a library ('Select a specific library...') and
a save sent library_id: '' (server 400). The server now stores a
global scope (separate table; null/absent library_id, empty string
still 400), so:

- openDashboardSettings allows the All view and fetches prefs without
  the library param (reads the global row; previously a loose NULL
  OR-clause matched an arbitrary library's row).
- saveDashboardSettings sends library_id: null from All Libraries.
- The settings modal gains a scope label (#dashboard-scope-label)
  showing which library — or All Libraries — a save will customize
  (user requirement: always know the scope).

Verified end-to-end in headless Chromium: reorder on All Libraries →
save (null) → reload → modal re-opens with the persisted order and the
All Libraries scope label; per-library save lands in its own row
without touching global (SQL-checked).
2026-10-07 19:51:59 -04:00

537 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;
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 || "";
// Empty select = the All Libraries view: its preferences are the GLOBAL
// scope — the server returns that row when library_id is absent.
const query = libraryId ? `?library_id=${encodeURIComponent(libraryId)}` : "";
const response = await fetch(
`/api/dashboard/preferences${query}`,
{
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
},
);
if (response.ok) {
const prefs = await response.json();
applyPreferencesToModal(prefs);
// Scope indicator: make it unmistakable what a save will customize.
const scopeEl = document.getElementById("dashboard-scope-label");
if (scopeEl) {
const optionText =
librarySelect?.selectedOptions[0]?.textContent || libraryId;
scopeEl.textContent = (
libraryId ? optionText : optionText || "All Libraries"
)
.replace(/\s*\(\d+\)$/, "")
.trim();
}
} 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", {
// Empty select = the All Libraries view: an explicit JSON null is
// the global scope (empty string is rejected by the server as a
// lost value — never a scope).
library_id: libraryId || null,
hidden_collections: hiddenCollections,
collection_order: collectionOrder,
items_per_section: parseInt(itemsPerCollection),
});
if (response.ok) {
showToast("Dashboard settings saved", "success");
closeDashboardSettings();
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,
}));