- Update wood-light border from harsh black (#2a2a2a) to lighter warm brown (#8b5a2b) for better harmony with light background - Update wood-dark border from #5c3317 to #7a5228 (slightly lighter medium brown) for improved visibility on dark backgrounds - Update wood-mahogany border from #5c3317 to #8b3a3a (medium red-brown) to enhance mahogany's characteristic reddish tones - Reduce background blend opacity from 60% to 40% to create more subtle text area background that complements new border colors These changes improve visual consistency between border colors and their respective wood paneling backgrounds while maintaining good text contrast across all wood themes.
526 lines
17 KiB
TypeScript
526 lines
17 KiB
TypeScript
// Dashboard functionality with unified collections architecture
|
||
// Procedural/imperative style (no OOP)
|
||
|
||
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) {
|
||
(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;
|
||
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;
|
||
modal?.classList.add("hidden");
|
||
}
|
||
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 (window as any).api.put("/dashboard/preferences", {
|
||
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();
|
||
// 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,
|
||
): 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 (window as any).api.post(
|
||
"/dashboard/restore-system-collection",
|
||
{
|
||
collection_name: collectionName,
|
||
},
|
||
);
|
||
|
||
if (response.ok) {
|
||
(window as any).showToast.success(
|
||
`"${collectionTitle}" restored to defaults`,
|
||
);
|
||
setTimeout(() => window.location.reload(), 1000);
|
||
}
|
||
} catch (error) {
|
||
(window as any).showToast.error("Failed to restore system collection");
|
||
console.error("Restore system collection error:", error);
|
||
}
|
||
}
|
||
|
||
async function switchLibrary(libraryId: string): Promise<void> {
|
||
const container = document.getElementById(
|
||
"collections-container",
|
||
) as HTMLElement;
|
||
const loading = document.getElementById("loading-spinner") as HTMLElement;
|
||
|
||
if (!container || !loading) return;
|
||
|
||
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}`,
|
||
{
|
||
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);
|
||
localStorage.setItem("selectedLibrary", libraryId);
|
||
} catch (error) {
|
||
(window as any).showToast.error("Failed to load library");
|
||
console.error("Switch library error:", error);
|
||
} finally {
|
||
loading.classList.add("hidden");
|
||
}
|
||
}
|
||
|
||
function renderDashboardCollections(sections: SectionData[]): void {
|
||
const container = document.getElementById(
|
||
"collections-container",
|
||
) as HTMLElement;
|
||
if (!container) return;
|
||
|
||
// Preserve wood paneling attribute
|
||
const currentWood = container.getAttribute("data-wood");
|
||
|
||
// Replace content while still invisible (opacity-0 from switchLibrary)
|
||
container.innerHTML = sections
|
||
.map(
|
||
(section) => `
|
||
<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 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 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>
|
||
`,
|
||
)
|
||
.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);
|
||
}
|
||
}
|
||
|
||
function renderBookCard(book: BookInfo): string {
|
||
const coverUrl = book.cover_image_path || "/static/placeholder-book.svg";
|
||
|
||
return `
|
||
<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] overflow-hidden shadow-lg
|
||
bg-gradient-to-br from-gray-700 to-gray-900">
|
||
<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-text px-2 py-1 bg-[color-mix(in_srgb,var(--wood-border)_40%,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>
|
||
`;
|
||
}
|
||
|
||
function viewBook(bookId: string): void {
|
||
console.log("View book:", bookId);
|
||
}
|
||
|
||
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,
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
initDragAndDrop();
|
||
|
||
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 "view-book": {
|
||
const bookId = target.dataset.bookId || actionElem?.dataset.bookId;
|
||
if (bookId) viewBook(bookId);
|
||
break;
|
||
}
|
||
|
||
case "reload-page":
|
||
reloadPage();
|
||
break;
|
||
|
||
case "switch-library": {
|
||
const select = target as HTMLSelectElement;
|
||
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");
|
||
if (displayTarget) updateItemsCount(input, displayTarget);
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
// 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}`;
|
||
}
|
||
});
|