Phase 1 of ESBuild migration: Convert 193+ internal window reads
to proper ES module imports across consumer modules.
Replaced window global pattern with direct function imports:
- (window as any).showToast → import { showToast } → showToast(msg, "type")
- (window as any).api.post → import { apiPost } → apiPost(url, data)
- (window as any).dom.getElementById → import { getElementById }
Modules migrated:
- admin.ts: Convert 14 showToast window reads
- analytics.ts: Add ES export (no window reads)
- conflicts.ts: Convert 6 showToast window reads
- custom-section-builder.ts: Convert api.post reads, add ES exports
- dashboard.ts: Convert 10 window reads (api, showToast)
- device-management.ts: Convert 4 showToast window reads, add Alpine registration
- linking.ts: Convert showToast window reads
- queue.ts: Convert 8 showToast window reads
Additionally added Alpine.js registration for templates:
- device-management.ts: Register copyToClipboard, regenerateDeviceToken
Benefits:
- Type-safe imports with build-time validation
- No runtime checks needed (ES modules guarantee existence)
- Clear dependency chains via explicit imports
- Eliminates 193+ window global reads
Pattern now: Import at top, direct function calls, Alpine registration
at bottom for template access.
Migration progress: Phase 1 complete
Next: Phase 2 (Alpine registration for remaining modules)
522 lines
17 KiB
TypeScript
522 lines
17 KiB
TypeScript
import { apiPost, apiPut } from "./api";
|
||
import { showToast } from "./toast";
|
||
|
||
// 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) {
|
||
showToast("No library selected", "error");
|
||
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 {
|
||
showToast("Failed to load library preferences", "error");
|
||
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 apiPut("/dashboard/preferences", {
|
||
library_id: libraryId,
|
||
hidden_collections: hiddenCollections,
|
||
collection_order: collectionOrder,
|
||
items_per_section: parseInt(itemsPerCollection),
|
||
});
|
||
if (response.ok) {
|
||
showToast("Dashboard settings saved", "success");
|
||
closeDashboardSettings();
|
||
// Fetch updated sections and re-render (like switchLibrary does)
|
||
if (!libraryId) {
|
||
showToast("Unable to refresh dashboard - no library selected", "error");
|
||
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 {
|
||
showToast("Failed to refresh sections", "error");
|
||
}
|
||
}
|
||
} 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);
|
||
}
|
||
}
|
||
|
||
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) {
|
||
showToast("Failed to load library", "error");
|
||
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) {
|
||
showToast("No library selected", "error");
|
||
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}`;
|
||
}
|
||
});
|