THE CLASS: the /api JWT middleware's TokenLookup is
"cookie:token,header:Authorization:Bearer" — the token cookie always
rides same-origin fetches, so a signed-in user never needs the
localStorage bearer. But a dozen call sites gated their fetch on
`localStorage.getItem("token")` and early-returned when absent. The
global 401 interceptor CLEARS that localStorage entry on any session
rotation/blip — after which every gated write silently did nothing:
theme changes reverted on reload (the reported "can no longer change
themes", on top of the interpolation bug d5dbaa48 fixed), chain-order
selects, collection deletion, Add-to-collection submits, filter
save/load/delete, conflict actions, linking actions, stats charts,
tag suggestions, device management, admin scanner actions — all dead
for perfectly authenticated cookie users.
Fix: storage.ts exports authHeaders() (bearer when present, cookie
carries otherwise) and every gate/header site now uses it — theme.ts,
collections.ts (htmx auth, doRemoveBook/doBulkRemove, setChainOrder),
bookPicker submit, filter_builder suggestions + match count,
collection-rules apply hook, bookshelf save/load/delete filter,
conflicts x4, linking x3, admin scanner actions + polls (requireToken
removed — its "Session expired" toast fired for cookie users too),
dashboard preferences/sections, custom-section-builder, header
scanner poll, stats x3, tag suggestions, profile delete-account
(which also wrongly redirected cookie users to /login), and
device-management x4. Reads keep their graceful-empty behavior; the
bearers still ride along when present.
Pinned: the knob-matrix theme spec now REMOVES localStorage.token and
asserts the theme PUT still fires (cookie-only) and the class flips —
the exact strand that started this.
Found by noticing the panel browser's Nord click switched the class
but produced no PUT: the persistence half was gated on a token the
401 interceptor had cleared.
584 lines
24 KiB
TypeScript
584 lines
24 KiB
TypeScript
import { Alpine } from "./alpine";
|
||
import { authHeaders } from "./storage";
|
||
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: authHeaders(),
|
||
});
|
||
|
||
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: {
|
||
...authHeaders(),
|
||
"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;
|
||
}
|
||
}
|
||
});
|
||
|
||
// The week-widget popover floats over the page content (absolute
|
||
// panel inside the library bar), so a click anywhere outside the open
|
||
// <details> closes it — same for Escape. The element is re-queried on
|
||
// every event because htmx replaces #week-widget on library switches.
|
||
document.addEventListener("click", (e: Event) => {
|
||
const widget = document.getElementById("week-widget") as HTMLDetailsElement | null;
|
||
if (widget?.open && !widget.contains(e.target as Node)) {
|
||
widget.open = false;
|
||
}
|
||
});
|
||
|
||
document.addEventListener("keydown", (e: KeyboardEvent) => {
|
||
if (e.key !== "Escape") return;
|
||
const widget = document.getElementById("week-widget") as HTMLDetailsElement | null;
|
||
if (widget?.open) widget.open = false;
|
||
});
|
||
|
||
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: {
|
||
...authHeaders(),
|
||
"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,
|
||
}));
|