Files
bookhoard/web/src/dashboard.ts
T
John O'Keefe 77cd268f4e style: format web/src with oxfmt defaults (via vp fmt)
One-time mechanical sweep, isolated from any functional change.
- oxfmt defaults: double quotes, semicolons, 2-space indent, LF,
  printWidth 100, trailing commas, bracket spacing, arrow parens.
- vite.config.ts gains an fmt.ignorePatterns block for web/static:
  vendored/minified assets (htmx.min.js, compiled css) must never
  be reformatted.
- Verified: rebuild after the sweep produces byte-identical JS/CSS
  bundles; only sourcemaps changed (they embed sourcesContent).
2026-10-21 13:18:24 -04:00

566 lines
23 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 {
// A2c §3.5: a fully-read playlist (hide_finished drained the queue)
// renders its completion state instead of book cards.
if (section.complete) {
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}
<span class="badge ml-2" style="background-color: var(--accent-muted); color: var(--accent);">✓ Playlist complete</span>
</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="card p-6 text-sm" style="color: var(--text-secondary)">Every book in this playlist is finished. Time to add the next one.</div>
</div>`;
}
const playlistBadge = section.is_playlist
? `<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);" title="Curated reading order">📖 Playlist</span>`
: "";
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}${playlistBadge}</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>";
const HEADPHONES_ICON =
'<svg class="reader-icon h-4 w-4" 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="M4 14v-2a8 8 0 0 1 16 0v2"></path>' +
'<rect x="3" y="14" width="4" height="7" rx="1.5"></rect>' +
'<rect x="17" y="14" width="4" height="7" rx="1.5"></rect>' +
"</svg>";
const COMIC_ICON =
'<svg class="reader-icon h-4 w-4" 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="M4 4h16v12l-3 4H7l-3-4z"></path>' +
'<path d="M4 9h16M9 9v11"></path>' +
"</svg>";
const MANGA_ICON =
'<svg class="reader-icon h-4 w-4" 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="M4 4h16v13a3 3 0 0 1-3 3H4z"></path>' +
'<path d="M4 4v16M10 4h10v8"></path>' +
"</svg>";
// Media-type identifier for mixed grids, from the server-resolved badge
// (format_group + library type — comic vs manga is a LIBRARY distinction):
// '' for books (unbadged default)
function badgeIconFor(badge: string): string {
if (badge === "audiobook") return HEADPHONES_ICON;
if (badge === "comic") return COMIC_ICON;
if (badge === "manga") return MANGA_ICON;
return "";
}
function badgeTitleFor(badge: string): string {
if (badge === "audiobook") return "Audiobook";
if (badge === "comic") return "Comic";
if (badge === "manga") return "Manga";
return "";
}
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 isAudio = book.format_group === "audiobook";
const actionLabel = book.has_conflict
? `Resolve progress conflict for ${book.title}`
: isAudio
? `Listen to ${book.title}`
: `Read ${book.title}`;
const actionTitle = book.has_conflict ? "Resolve progress conflict" : isAudio ? "Listen" : "Read";
const actionIcon = isAudio ? HEADPHONES_ICON : BOOK_OPEN_ICON;
const badgeIcon = badgeIconFor(book.badge || "");
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 relative 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'">
${badgeIcon ? `<span class="absolute top-2 right-2 z-10 flex items-center justify-center h-7 w-7 rounded-full shadow-sm" style="background-color: color-mix(in srgb, var(--bg-primary) 78%, transparent); color: var(--text-primary); backdrop-filter: blur(3px);" title="${badgeTitleFor(book.badge || "")}">${badgeIcon}</span>` : ""}
</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}">${actionIcon}</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);
}
}
}
});
}
// The week widget re-fetches itself when the body sees `week-refresh`
// (hx-trigger on #week-widget). Resolve once that swap has landed so the
// library switch fades in with the refreshed widget already in place.
// Safety timeout keeps a stalled request from holding the switch hostage,
// and a missing widget (stats disabled) resolves immediately.
function refreshWeekWidget(): Promise<void> {
return new Promise((resolve) => {
if (!document.getElementById("week-widget")) {
resolve();
return;
}
let settled = false;
const finish = (): void => {
if (settled) return;
settled = true;
document.removeEventListener("htmx:afterSwap", onSwap);
resolve();
};
const onSwap = (evt: Event): void => {
const target = (evt as CustomEvent).detail?.target as HTMLElement | undefined;
if (target && target.id === "week-widget") finish();
};
document.addEventListener("htmx:afterSwap", onSwap);
setTimeout(finish, 4000);
document.body.dispatchEvent(new CustomEvent("week-refresh"));
});
}
function initDashboard() {
initDragAndDrop();
initLibrarySwitcher({
onSwitch: (libraryId) =>
switchWithTransition("collections-container", async () => {
await fetchAndRenderSections(libraryId);
// The "This Week" widget tracks the switched library: htmx
// re-fetches its fragment with the current #library-select value.
// Await the widget's swap so it lands behind the spinner —
// otherwise it pops in during/after the fade-in and the switch
// reads as two disjoint updates.
await refreshWeekWidget();
}),
});
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,
}));