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.
333 lines
13 KiB
TypeScript
333 lines
13 KiB
TypeScript
// 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 {};
|