Files
bookhoard/web/src/dashboard.ts
T
john-okeefe a1a14c2af8 feat(dashboard): implement Phase 9 dashboard template and TypeScript for Carousel-style dashboard
Replace library browser with Carousel-style collections carousel:

Template Changes (templates/dashboard.templ):
Complete rewrite from library browser to collections carousel:

1. Dashboard Main Template:
   - Sticky library selector dropdown
   - Customize dashboard button (settings modal)
   - Refresh button
   - Loading spinner for async operations
   - Collections container with carousels

2. CollectionCarousel Component:
   - Collection header with icon, title, description
   - View All link for system collections
   - Horizontal scrollable carousel track
   - Left/right navigation buttons
   - Book cards with cover images
   - Empty state handling

3. BookCard Component:
   - Aspect ratio [2/3] book cover
   - Cover image with fallback to placeholder
   - Title and author display
   - Click handler for viewing book details
   - Hover scale animation

4. DashboardSettingsModal Component:
   - Draggable collection list for reordering
   - Toggle switches for collection visibility
   - "System" badges for system collections
   - "Restore" buttons for system collections
   - Items per section slider (10-50, step 5)
   - Save/Cancel buttons

Template Features:
- Uses IsSystem boolean instead of Type string
- data-is-system attribute for JavaScript
- data-collection-id for DOM manipulation
- Supports drag-and-drop reordering
- Settings modal with live preview

TypeScript Implementation (web/src/dashboard.ts):

Core Functions:
- scrollCarousel: Smooth horizontal scrolling
- openDashboardSettings/closeDashboardSettings: Modal control
- toggleCollectionVisibility: Toggle visibility switches
- saveDashboardSettings: Save preferences to API
  * Collects hidden_collections and collection_order
  * Calls PUT /api/dashboard/preferences
  * Reloads page on success
- restoreSystemCollection: Reset system collection to defaults
  * Confirmation dialog
  * Calls POST /api/dashboard/restore-system-collection
  * Shows toast notifications
- switchLibrary: Switch between libraries
  * Async fetch from API
  * Re-renders collections
- renderCollections: Client-side rendering of collections
- renderBookCard: Generate book card HTML
- viewBook: Placeholder for book detail view
- reloadPage: Refresh page
- updateItemsCount: Update slider display
- initDragAndDrop: Drag-and-drop event handlers

Event Handling:
- Event delegation for performance
- data-action attributes for handler routing
- Proper type checking and null safety
- Error handling with toast notifications

Type Safety:
- Uses SectionData and BookInfo from api.d.ts
- Proper TypeScript types throughout
- Null checks for DOM elements
- Type assertions where needed

This implements Phase 9: Dashboard Template with unified collections terminology and full TypeScript interactivity.
2026-02-19 21:11:53 -05:00

333 lines
13 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.
// Dashboard functionality with unified collections architecture
// Procedural/imperative style (no OOP)
import type { SectionData, BookInfo } from './types/api';
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' });
}
function openDashboardSettings(): void {
const modal = document.getElementById('dashboard-settings-modal') as HTMLElement;
if (modal) {
modal.classList.remove('hidden');
}
}
function closeDashboardSettings(): void {
const modal = document.getElementById('dashboard-settings-modal') as HTMLElement;
if (modal) {
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) {
hiddenCollections.push(collectionId);
}
}
});
const itemsPerCollection = (document.querySelector('#items-count-display') as HTMLElement)?.textContent || '20';
try {
const response = await (window as any).api.put('/dashboard/preferences', {
library_id: new URLSearchParams(window.location.search).get('library_id') || '',
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();
}
} 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;
loading.classList.remove('hidden');
try {
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();
renderCollections(data.sections);
} catch (error) {
(window as any).showToast.error('Failed to load library');
console.error('Switch library error:', error);
} finally {
loading.classList.add('hidden');
}
}
function renderCollections(sections: SectionData[]): void {
const container = document.getElementById('collections-container') as HTMLElement;
if (!container) return;
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('');
}
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
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
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>
<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>
`;
}
function viewBook(bookId: string): void {
console.log('View book:', bookId);
}
function reloadPage(): void {
window.location.reload();
}
function updateItemsCount(input: HTMLInputElement, targetId: string): void {
const display = document.getElementById(targetId) as HTMLElement;
if (display) {
display.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 'toggle-collection-visibility': {
const checkbox = target as HTMLInputElement;
const colId = checkbox.dataset.collectionId;
if (colId) toggleCollectionVisibility(colId);
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;
}
case 'update-items-count': {
const input = target as HTMLInputElement;
const displayTarget = input.getAttribute('target');
if (displayTarget) updateItemsCount(input, displayTarget);
break;
}
}
});
});
export {};