fix: Dashboard modal and slider library-specific behavior

Fix multiple issues with dashboard customization modal and slider
not working correctly per library.

Frontend changes in web/src/dashboard.ts:
- Fix openDashboardSettings() to use current library ID
  - Add library_id parameter to dashboard preferences API call
  - Show toast error message on API failure instead of opening modal
  - Prevent opening modal with stale/inaccurate data

- Fix slider query parameter mismatch
  - Change from 'libraryId' to 'library_id' to match backend API
  - Fix DOM query from collectionList.querySelector to document.querySelector
  - Ensure slider targets correct input element

- Fix saveDashboardSettings() to refresh current library
  - Fetch current library data before saving preferences
  - Use library_id from current library, not from URL
  - Show toast error message on save failure
  - Keep modal open on error for user to retry

- Add localStorage persistence for selected library
  - Store selectedLibrary in localStorage after switching
  - Enables persistence across page refreshes

- Improve switchLibrary() with fade transitions
  - Add fade-out (150ms) before data fetch
  - Add fade-in (300ms) after rendering new library
  - Provide smooth visual feedback during library switches

- Apply preferences dynamically to modal
  - Use applyPreferencesToModal() to update slider and toggles
  - Ensure modal reflects current library's settings

Backend changes in internal/router/dashboard.go:
- Update GetDashboardPreferences to use library_id query parameter
  - Matches frontend API call parameter naming

Template changes in templates/dashboard.templ:
- Remove duplicate renderDashboardCollections() inline script
  - Functionality now handled by dashboard.ts

These fixes ensure that:
1. Dashboard settings work correctly per library
2. Slider reflects and updates the correct library's item limit
3. Toggles show accurate visibility state for each library
4. Library switches provide smooth visual feedback
5. Errors are properly surfaced to users via toast messages
This commit is contained in:
2026-03-01 00:29:00 -05:00
parent 0b666f3fdd
commit 62d3d50140
4 changed files with 271 additions and 112 deletions
+181 -47
View File
@@ -13,51 +13,103 @@ function scrollCarousel(collectionId: string, direction: number): void {
track.scrollBy({ left: scrollAmount, behavior: "smooth" });
}
function openDashboardSettings(): void {
async function openDashboardSettings(): Promise<void> {
const librarySelect = document.getElementById(
"library-select",
) as HTMLSelectElement;
const libraryId = librarySelect?.value;
if (!libraryId) {
(window as any).showToast.error("No library selected");
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 {
(window as any).showToast.error("Failed to load library preferences");
console.error("API Error:", response.status, response.statusText);
return; // Don't open modal with stale data.
}
const modal = document.getElementById(
"dashboard-settings-modal",
) as HTMLElement;
if (modal) {
modal.classList.remove("hidden");
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);
}
// Update checkboxes and reorder items
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;
// Update checkbox state
const isHidden = prefs.hidden_collections.includes(collectionId);
if (checkbox) checkbox.checked = !isHidden;
// Sort according to collection_order
const orderIndex = prefs.collection_order.indexOf(collectionId);
if (orderIndex !== -1) {
orderedItems[orderIndex] = item;
} else {
orderedItems.push(item);
}
});
// Reorder in DOM
orderedItems.forEach((item) => collectionList.appendChild(item));
}
function closeDashboardSettings(): void {
const modal = document.getElementById(
"dashboard-settings-modal",
) as HTMLElement;
if (modal) {
modal.classList.add("hidden");
}
modal?.classList.add("hidden");
}
function toggleCollectionVisibility(collectionId: string): void {
const checkbox = document.querySelector(
`input[data-collection-id="${collectionId}"]`,
) as HTMLInputElement;
if (checkbox) {
checkbox.checked = !checkbox.checked;
}
}
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) {
@@ -65,31 +117,52 @@ async function saveDashboardSettings(): Promise<void> {
}
}
});
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 (window as any).api.put("/dashboard/preferences", {
library_id:
new URLSearchParams(window.location.search).get("library_id") || "",
library_id: libraryId,
hidden_collections: hiddenCollections,
collection_order: collectionOrder,
items_per_section: parseInt(itemsPerCollection),
});
if (response.ok) {
(window as any).showToast.success("Dashboard settings saved");
closeDashboardSettings();
window.location.reload();
// Fetch updated sections and re-render (like switchLibrary does)
if (!libraryId) {
(window as any).showToast.error(
"Unable to refresh dashboard - no library selected",
);
return;
}
const sectionResponse = await fetch(
`/api/dashboard/sections?library_id=${libraryId}`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
"Content-Type": "application/json",
},
},
);
if (sectionResponse.ok) {
const data = await sectionResponse.json();
renderDashboardCollections(data.sections);
localStorage.setItem("selectedLibraryId", libraryId);
} else {
(window as any).showToast.error("Failed to refresh sections");
}
}
} catch (error) {
(window as any).showToast.error("Failed to save settings");
console.error("Save dashboard settings error:", error);
}
}
async function restoreSystemCollection(
collectionName: string,
collectionTitle: string,
@@ -130,9 +203,17 @@ async function switchLibrary(libraryId: string): Promise<void> {
if (!container || !loading) return;
loading.classList.remove("hidden");
try {
// Step 1: Fade out current content (150ms)
container.classList.add("opacity-0", "transition-opacity", "duration-150");
// Wait for fade-out to complete
await new Promise((resolve) => setTimeout(resolve, 150));
// Step 2: Show loading spinner
loading.classList.remove("hidden");
// Step 3: Fetch new data
const response = await fetch(
`/api/dashboard/sections?library_id=${libraryId}`,
{
@@ -149,6 +230,7 @@ async function switchLibrary(libraryId: string): Promise<void> {
const data = await response.json();
renderDashboardCollections(data.sections);
localStorage.setItem("selectedLibrary", libraryId);
} catch (error) {
(window as any).showToast.error("Failed to load library");
console.error("Switch library error:", error);
@@ -166,6 +248,7 @@ function renderDashboardCollections(sections: SectionData[]): void {
// Preserve wood paneling attribute
const currentWood = container.getAttribute("data-wood");
// Replace content while still invisible (opacity-0 from switchLibrary)
container.innerHTML = sections
.map(
(section) => `
@@ -223,6 +306,20 @@ function renderDashboardCollections(sections: SectionData[]): void {
)
.join("");
// Step 5: Fade in new content (300ms for smoother entrance)
container.classList.remove("duration-150");
container.classList.add("duration-300");
// Trigger reflow to ensure transition happens
void container.offsetHeight;
// Fade to visible
container.classList.remove("opacity-0");
// Clean up transition classes after animation completes
setTimeout(() => {
container.classList.remove("transition-opacity", "duration-300");
}, 300);
if (currentWood) {
container.setAttribute("data-wood", currentWood);
}
@@ -232,14 +329,14 @@ function renderBookCard(book: BookInfo): string {
const coverUrl = book.cover_image_path || "/static/placeholder-book.svg";
return `
<div class="book-card flex-shrink-0 w-32 snap-start cursor-pointer
<div class="book-card flex-shrink-0 w-36 rounded-lg overflow-hidden snap-start cursor-pointer
transition-transform duration-200 hover:scale-105"
data-action="view-book"
data-book-id="${book.media_item_id}"
tabindex="0"
role="button"
aria-label="View ${book.title}">
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2
<div class="aspect-[2/3] overflow-hidden shadow-lg
bg-gradient-to-br from-gray-700 to-gray-900">
<img src="${coverUrl}"
alt="${book.title}"
@@ -247,10 +344,12 @@ function renderBookCard(book: BookInfo): string {
loading="lazy"
onerror="this.src='/static/placeholder-book.svg'">
</div>
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">
${book.title}
</h3>
${book.author ? `<p class="text-xs line-clamp-1" style="color: var(--text-secondary)">${book.author}</p>` : ""}
<div class="book-card-text px-2 py-1 bg-[color-mix(in_srgb,var(--wood-border)_60%,transparent)]">
<h3 class="font-semibold text-base line-clamp-2" style="color: var(--text-primary)">
${book.title}
</h3>
${book.author ? `<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">${book.author}</p>` : ""}
</div>
</div>
`;
}
@@ -259,14 +358,29 @@ function viewBook(bookId: string): void {
console.log("View book:", bookId);
}
function reloadPage(): void {
window.location.reload();
async function reloadPage(): Promise<void> {
//Get current library from dropdown
const librarySelect = document.getElementById(
"library-select",
) as HTMLSelectElement;
const currentLibraryId = librarySelect?.value;
if (!currentLibraryId) {
(window as any).showToast.error("No library selected");
return;
}
// Reuse switchLibrary logic - it handles the fade transition
await switchLibrary(currentLibraryId);
}
function updateItemsCount(input: HTMLInputElement, targetId: string): void {
const display = document.getElementById(targetId) as HTMLElement;
if (display) {
display.textContent = input.value;
function updateItemsCount(
input: HTMLInputElement,
displayTarget: string,
): void {
const displayElement = document.getElementById(displayTarget) as HTMLElement;
if (displayElement) {
displayElement.textContent = input.value;
}
}
@@ -342,13 +456,6 @@ document.addEventListener("DOMContentLoaded", () => {
closeDashboardSettings();
break;
case "toggle-collection-visibility": {
const checkbox = target as HTMLInputElement;
const colId = checkbox.dataset.collectionId;
if (colId) toggleCollectionVisibility(colId);
break;
}
case "save-dashboard-settings":
saveDashboardSettings();
break;
@@ -379,7 +486,15 @@ document.addEventListener("DOMContentLoaded", () => {
if (select.value) switchLibrary(select.value);
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");
@@ -388,4 +503,23 @@ document.addEventListener("DOMContentLoaded", () => {
}
}
});
// Load saved library on page load
const librarySelect = document.getElementById(
"library-select",
) as HTMLSelectElement;
if (librarySelect) {
librarySelect.addEventListener("change", (e) => {
const target = e.target as HTMLSelectElement;
if (target.value) {
switchLibrary(target.value);
}
});
}
const savedLibrary = localStorage.getItem("selectedLibrary");
const currentLibrary = new URLSearchParams(window.location.search).get(
"library_id",
);
if (savedLibrary && savedLibrary !== currentLibrary) {
window.location.href = `/dashboard?library_id=${savedLibrary}`;
}
});