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.
This commit is contained in:
+244
-573
@@ -1,6 +1,11 @@
|
||||
package templates
|
||||
|
||||
templ Dashboard(user User) {
|
||||
import (
|
||||
"bookhoard/internal/handlers"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
templ Dashboard(user User, sections []handlers.SectionData, libData []LibraryData, currentLibraryID string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -9,593 +14,259 @@ templ Dashboard(user User) {
|
||||
<title>Dashboard - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/toast.js"></script>
|
||||
<script src="/static/header.js"></script>
|
||||
<script src="/static/search.js"></script>
|
||||
<script src="/static/api.js"></script>
|
||||
<script src="/static/events.js"></script>
|
||||
<script src="/static/dashboard.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="theme-tokyo-night">
|
||||
<body class="theme-{ user.Theme }">
|
||||
@Header(user, "/dashboard")
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Header Section -->
|
||||
<div class="mb-8">
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold" style="color: var(--text-primary)">Your Media Library</h2>
|
||||
<p style="color: var(--text-secondary)">Browse and manage your media collections</p>
|
||||
</div>
|
||||
<!-- Sticky Library Selector -->
|
||||
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
|
||||
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library:</label>
|
||||
<select id="library-select" name="library_id"
|
||||
class="px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
||||
style="background-color: var(--bg-secondary); color: var(--text-primary);"
|
||||
data-action="switch-library">
|
||||
for _, lib := range libData {
|
||||
if lib.ID == currentLibraryID {
|
||||
<option value={ lib.ID } selected>{ lib.Name }</option>
|
||||
} else {
|
||||
<option value={ lib.ID }>{ lib.Name }</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Libraries Section -->
|
||||
<div id="libraries-container" class="mb-8">
|
||||
<!-- Libraries will be loaded here -->
|
||||
</div>
|
||||
|
||||
<!-- Media Items Section -->
|
||||
<div id="media-section" class="hidden">
|
||||
<!-- Search and Filter -->
|
||||
<div class="mb-6">
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<button onclick="backToLibraries()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
← Back to Libraries
|
||||
<div class="flex items-center gap-2">
|
||||
<button data-action="open-dashboard-settings"
|
||||
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
style="background-color: var(--bg-secondary);"
|
||||
title="Customize Dashboard">
|
||||
⚙️
|
||||
</button>
|
||||
<button data-action="reload-page"
|
||||
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
style="background-color: var(--bg-secondary);"
|
||||
title="Refresh">
|
||||
🔄
|
||||
</button>
|
||||
<input type="text" id="search-input" placeholder="Search media..." class="flex-1 px-4 py-2 border rounded-lg" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)" onkeyup="filterMedia()">
|
||||
<select id="sort-select" class="px-4 py-2 border rounded-lg" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)" onchange="loadMediaItems()">
|
||||
<option value="created_at DESC">Newest Added</option>
|
||||
<option value="created_at ASC">Oldest Added</option>
|
||||
<option value="title ASC">Title A-Z</option>
|
||||
<option value="title DESC">Title Z-A</option>
|
||||
<option value="author ASC">Author A-Z</option>
|
||||
<option value="author DESC">Author Z-A</option>
|
||||
<option value="series ASC">Series Order</option>
|
||||
<option value="date_published DESC">Newest Published</option>
|
||||
<option value="copyright_year DESC">Copyright Year (Newest)</option>
|
||||
<option value="page_count ASC">Shortest First</option>
|
||||
<option value="page_count DESC">Longest First</option>
|
||||
<option value="genre ASC">Genre A-Z</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters Panel -->
|
||||
<div class="filter-panel mb-6">
|
||||
<details>
|
||||
<summary class="cursor-pointer font-semibold mb-4 flex items-center gap-2" style="color: var(--text-primary)">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"></path>
|
||||
</svg>
|
||||
Filters
|
||||
</summary>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Author</label>
|
||||
<select id="filter-author" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)">
|
||||
<option value="">All Authors</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Genre</label>
|
||||
<select id="filter-genre" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)">
|
||||
<option value="">All Genres</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Series</label>
|
||||
<select id="filter-series" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)">
|
||||
<option value="">All Series</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Language</label>
|
||||
<select id="filter-language" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)">
|
||||
<option value="">All Languages</option>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Spanish</option>
|
||||
<option value="fr">French</option>
|
||||
<option value="de">German</option>
|
||||
<option value="ja">Japanese</option>
|
||||
<option value="zh">Chinese</option>
|
||||
<option value="ko">Korean</option>
|
||||
<option value="ru">Russian</option>
|
||||
<option value="it">Italian</option>
|
||||
<option value="pt">Portuguese</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Year Range</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="number" id="filter-year-min" placeholder="From" class="w-1/2 px-3 py-2 border rounded-lg" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)" min="1800" max="2100">
|
||||
<input type="number" id="filter-year-max" placeholder="To" class="w-1/2 px-3 py-2 border rounded-lg" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)" min="1800" max="2100">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" id="filter-has-cover" class="w-4 h-4 rounded">
|
||||
<span class="text-sm" style="color: var(--text-secondary)">Has Cover Image</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="applyFilters()" class="btn-primary px-4 py-2 rounded-lg font-medium">Apply Filters</button>
|
||||
<button onclick="clearFilters()" class="btn-secondary px-4 py-2 rounded-lg font-medium">Clear All</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Active Filters Display -->
|
||||
<div id="active-filters" class="hidden mb-4">
|
||||
<div class="flex flex-wrap gap-2" id="active-filters-container">
|
||||
<!-- Active filter chips added here dynamically -->
|
||||
<div id="loading-spinner" class="hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50"
|
||||
style="background-color: var(--bg-primary);">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2" style="border-color: var(--accent);"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media Grid -->
|
||||
<div id="media-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- Media items will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div id="loading" class="text-center py-8" style="color: var(--text-secondary)">
|
||||
Loading your media libraries...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let libraries = [];
|
||||
let currentLibrary = null;
|
||||
let mediaItems = [];
|
||||
|
||||
function loadLibraries() {
|
||||
const loading = document.getElementById('loading');
|
||||
const librariesContainer = document.getElementById('libraries-container');
|
||||
|
||||
loading.style.display = 'block';
|
||||
librariesContainer.innerHTML = '';
|
||||
|
||||
fetch('/api/libraries/visible', {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token'),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
loading.style.display = 'none';
|
||||
libraries = data;
|
||||
renderLibraries();
|
||||
})
|
||||
.catch(error => {
|
||||
loading.style.display = 'none';
|
||||
console.error('Error loading libraries:', error);
|
||||
librariesContainer.innerHTML = '<div class="text-center py-8" style="color: var(--text-secondary)">Error loading libraries. Please try again.</div>';
|
||||
});
|
||||
<!-- Collections Container -->
|
||||
<main id="collections-container" class="max-w-7xl mx-auto px-4 py-8">
|
||||
for _, section := range sections {
|
||||
@CollectionCarousel(section)
|
||||
}
|
||||
</main>
|
||||
|
||||
function renderLibraries() {
|
||||
const container = document.getElementById('libraries-container');
|
||||
|
||||
if (libraries.length === 0) {
|
||||
container.innerHTML = '<div class="text-center py-8" style="color: var(--text-secondary)">No libraries available. Please contact an administrator.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">' +
|
||||
libraries.map(library => {
|
||||
const icon = getLibraryIcon(library.type_name);
|
||||
return `<div class="card p-6 rounded-lg border hover:shadow-lg transition-shadow cursor-pointer" onclick="selectLibrary('${library.id}')" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div class="text-center">
|
||||
<div class="text-4xl mb-3">${icon}</div>
|
||||
<h3 class="font-semibold text-lg mb-2" style="color: var(--text-primary)">${library.name}</h3>
|
||||
<p class="text-sm mb-3" style="color: var(--text-secondary)">${library.description || ''}</p>
|
||||
<span class="inline-block px-3 py-1 text-sm rounded" style="background-color: var(--accent); color: var(--bg-primary)">${library.type_name}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function getLibraryIcon(typeName) {
|
||||
switch (typeName) {
|
||||
case 'ebooks': return '📚';
|
||||
case 'comics': return '📖';
|
||||
case 'manga': return '🗾';
|
||||
default: return '📁';
|
||||
}
|
||||
}
|
||||
|
||||
function selectLibrary(libraryId) {
|
||||
currentLibrary = libraries.find(l => l.id === libraryId);
|
||||
if (!currentLibrary) return;
|
||||
|
||||
const librariesContainer = document.getElementById('libraries-container');
|
||||
const mediaSection = document.getElementById('media-section');
|
||||
const loading = document.getElementById('loading');
|
||||
|
||||
// Hide libraries, show media section
|
||||
librariesContainer.classList.add('hidden');
|
||||
mediaSection.classList.remove('hidden');
|
||||
loading.style.display = 'block';
|
||||
|
||||
loadMediaItems();
|
||||
}
|
||||
|
||||
function backToLibraries() {
|
||||
const librariesContainer = document.getElementById('libraries-container');
|
||||
const mediaSection = document.getElementById('media-section');
|
||||
|
||||
librariesContainer.classList.remove('hidden');
|
||||
mediaSection.classList.add('hidden');
|
||||
currentLibrary = null;
|
||||
}
|
||||
|
||||
function loadMediaItems() {
|
||||
if (!currentLibrary) return;
|
||||
|
||||
const loading = document.getElementById('loading');
|
||||
const container = document.getElementById('media-container');
|
||||
|
||||
loading.style.display = 'block';
|
||||
container.innerHTML = '';
|
||||
|
||||
const sort = document.getElementById('sort-select').value;
|
||||
|
||||
fetch(`/api/media-items?library_id=${currentLibrary.id}&limit=12&offset=0&sort=${sort}`, {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token'),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
loading.style.display = 'none';
|
||||
mediaItems = data;
|
||||
renderMediaItems();
|
||||
})
|
||||
.catch(error => {
|
||||
loading.style.display = 'none';
|
||||
console.error('Error loading media items:', error);
|
||||
container.innerHTML = '<div class="col-span-full text-center py-8" style="color: var(--text-secondary)">Error loading media items. Please try again.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
async function renderMediaItems() {
|
||||
const container = document.getElementById('media-container');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (mediaItems.length === 0) {
|
||||
container.innerHTML = '<div class="col-span-full text-center py-8" style="color: var(--text-secondary)">No media items found in this library.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of mediaItems) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card p-4 rounded-lg border hover:shadow-lg transition-shadow';
|
||||
card.style.cssText = 'background-color: var(--bg-secondary); border-color: var(--border);';
|
||||
|
||||
const coverUrl = item.cover_image_path ? item.cover_image_path : '/static/placeholder-book.svg';
|
||||
|
||||
// Get user's rating for this media item
|
||||
const rating = await getMediaRating(item.id);
|
||||
const ratingStars = renderRatingStars(item.id, rating);
|
||||
|
||||
card.innerHTML = '<div class="flex flex-col h-full"><div class="flex-1"><div class="aspect-w-3 aspect-h-4 mb-3 overflow-hidden rounded"><img src="' + coverUrl + '" alt="Cover" class="w-full h-48 object-cover rounded" onerror="this.src=\'/static/placeholder-book.svg\'"></div><h3 class="font-semibold text-lg mb-1 line-clamp-2" style="color: var(--text-primary)">' + item.title + '</h3>' + (item.author ? '<p class="text-sm mb-2" style="color: var(--text-secondary)">by ' + item.author + '</p>' : '') + '<div class="mb-2" id="rating-' + item.id + '">' + ratingStars + '</div><div class="flex justify-between items-center mt-4"><div class="flex space-x-2"><button onclick="viewMediaItem(\'' + item.id + '\')" class="px-3 py-1 text-sm border rounded hover:opacity-80" style="border-color: var(--border); color: var(--text-secondary)">View</button></div></div></div>';
|
||||
|
||||
container.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
async function getMediaRating(mediaId) {
|
||||
try {
|
||||
const response = await fetch(`/api/media-items/${mediaId}/rating`, {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token'),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return data.rating || 0;
|
||||
}
|
||||
return 0;
|
||||
} catch (error) {
|
||||
console.error('Error fetching rating:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert 10-point rating to 5-point with half-star precision
|
||||
function convertToFivePoint(rating) {
|
||||
return rating / 2;
|
||||
}
|
||||
|
||||
// Convert 5-point with half-stars back to 10-point
|
||||
function convertToTenPoint(fivePointRating) {
|
||||
return Math.round(fivePointRating * 2);
|
||||
}
|
||||
|
||||
function renderRatingStars(mediaId, currentRating) {
|
||||
const fivePointRating = convertToFivePoint(currentRating);
|
||||
let stars = '';
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const filled = i <= Math.floor(fivePointRating);
|
||||
const halfFilled = !filled && (i - 0.5) <= fivePointRating;
|
||||
let starHtml = '';
|
||||
|
||||
if (filled) {
|
||||
starHtml = '★';
|
||||
} else if (halfFilled) {
|
||||
starHtml = '⭐'; // Using different symbol for half-star
|
||||
} else {
|
||||
starHtml = '☆';
|
||||
}
|
||||
|
||||
const starClass = filled ? 'text-yellow-400' :
|
||||
halfFilled ? 'text-yellow-200' : 'text-gray-400';
|
||||
|
||||
stars += `<span onclick="openRatingDialog('${mediaId}', ${currentRating})" class="cursor-pointer text-xl ${starClass} hover:text-yellow-300 transition-colors" title="Click to rate (supports half-stars)">${starHtml}</span>`;
|
||||
}
|
||||
|
||||
// Add rating text
|
||||
stars += `<span class="text-sm ml-2" style="color: var(--text-secondary)">${currentRating > 0 ? fivePointRating.toFixed(1) + '/5' : 'Not rated'}</span>`;
|
||||
|
||||
return stars;
|
||||
}
|
||||
|
||||
function openRatingDialog(mediaId, currentRating) {
|
||||
const fivePointRating = convertToFivePoint(currentRating);
|
||||
const newRating = prompt(`Rate this media (1-5, supports 0.5 increments):`, currentRating > 0 ? fivePointRating.toFixed(1) : '');
|
||||
|
||||
if (newRating === null) return; // User cancelled
|
||||
|
||||
const rating = parseFloat(newRating);
|
||||
if (isNaN(rating) || rating < 0.5 || rating > 5) {
|
||||
alert('Please enter a valid rating between 0.5 and 5');
|
||||
return;
|
||||
}
|
||||
|
||||
// Round to nearest 0.5
|
||||
const roundedRating = Math.round(rating * 2) / 2;
|
||||
setRating(mediaId, convertToTenPoint(roundedRating));
|
||||
}
|
||||
|
||||
async function setRating(mediaId, rating) {
|
||||
try {
|
||||
const response = await fetch(`/api/media-items/${mediaId}/rating`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token'),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ rating: rating })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const ratingContainer = document.getElementById(`rating-${mediaId}`);
|
||||
if (ratingContainer) {
|
||||
ratingContainer.innerHTML = renderRatingStars(mediaId, data.rating);
|
||||
}
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
console.error('Error setting rating:', errorText);
|
||||
alert('Failed to set rating: ' + errorText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting rating:', error);
|
||||
alert('Failed to set rating due to network error');
|
||||
}
|
||||
}
|
||||
|
||||
function filterMedia() {
|
||||
const searchTerm = document.getElementById('search-input').value.toLowerCase();
|
||||
const filteredItems = mediaItems.filter(item =>
|
||||
item.title.toLowerCase().includes(searchTerm) ||
|
||||
(item.author && item.author.toLowerCase().includes(searchTerm))
|
||||
);
|
||||
|
||||
// Temporarily replace mediaItems and re-render
|
||||
const tempItems = mediaItems;
|
||||
mediaItems = filteredItems;
|
||||
renderMediaItems();
|
||||
mediaItems = tempItems;
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const filters = {
|
||||
author: document.getElementById('filter-author').value,
|
||||
genre: document.getElementById('filter-genre').value,
|
||||
series: document.getElementById('filter-series').value,
|
||||
language: document.getElementById('filter-language').value,
|
||||
yearMin: parseInt(document.getElementById('filter-year-min').value) || 0,
|
||||
yearMax: parseInt(document.getElementById('filter-year-max').value) || 0,
|
||||
hasCover: document.getElementById('filter-has-cover').checked
|
||||
};
|
||||
|
||||
const sort = document.getElementById('sort-select').value;
|
||||
|
||||
// Update URL for shareability
|
||||
const params = new URLSearchParams();
|
||||
params.set('library_id', currentLibrary.id);
|
||||
params.set('sort', sort);
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value && value !== 0) {
|
||||
if (key === 'hasCover') {
|
||||
if (value === true) params.set(`has_cover`, 'true');
|
||||
} else {
|
||||
params.set(`${key}_filter`, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.history.pushState({}, '', `?${params.toString()}`);
|
||||
|
||||
loadMediaItemsWithFilters(filters, sort);
|
||||
displayActiveFilters(filters);
|
||||
}
|
||||
|
||||
function loadMediaItemsWithFilters(filters, sort) {
|
||||
if (!currentLibrary) return;
|
||||
|
||||
const loading = document.getElementById('loading');
|
||||
const container = document.getElementById('media-container');
|
||||
|
||||
loading.style.display = 'block';
|
||||
container.innerHTML = '';
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('library_id', currentLibrary.id);
|
||||
params.set('sort', sort);
|
||||
params.set('limit', 50);
|
||||
params.set('offset', 0);
|
||||
|
||||
// Only add filter params if they have values
|
||||
if (filters.author) params.set('author_filter', filters.author);
|
||||
if (filters.series) params.set('series_filter', filters.series);
|
||||
if (filters.genre) params.set('genre_filter', filters.genre);
|
||||
if (filters.language) params.set('language_filter', filters.language);
|
||||
if (filters.yearMin) params.set('year_min', filters.yearMin);
|
||||
if (filters.yearMax) params.set('year_max', filters.yearMax);
|
||||
if (filters.hasCover) params.set('has_cover', 'true');
|
||||
|
||||
fetch(`/api/media-items/filtered?${params.toString()}`, {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token'),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
loading.style.display = 'none';
|
||||
mediaItems = data.data || [];
|
||||
renderMediaItems();
|
||||
})
|
||||
.catch(error => {
|
||||
loading.style.display = 'none';
|
||||
console.error('Error loading filtered media items:', error);
|
||||
container.innerHTML = '<div class="col-span-full text-center py-8" style="color: var(--text-secondary)">Error loading media items. Please try again.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function displayActiveFilters(filters) {
|
||||
const container = document.getElementById('active-filters');
|
||||
const chipsContainer = document.getElementById('active-filters-container');
|
||||
chipsContainer.innerHTML = '';
|
||||
|
||||
let hasFilters = false;
|
||||
|
||||
const filterLabels = {
|
||||
author: 'Author',
|
||||
genre: 'Genre',
|
||||
series: 'Series',
|
||||
language: 'Language',
|
||||
yearMin: 'Year From',
|
||||
yearMax: 'Year To',
|
||||
hasCover: 'Has Cover'
|
||||
};
|
||||
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value && value !== 0) {
|
||||
hasFilters = true;
|
||||
const label = filterLabels[key] || key;
|
||||
const displayValue = key === 'hasCover' ? 'Yes' : value;
|
||||
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'px-3 py-1 rounded-full text-sm flex items-center gap-2 cursor-pointer';
|
||||
chip.style.cssText = 'background-color: var(--accent); color: var(--bg-primary)';
|
||||
chip.innerHTML = `${label}: ${displayValue} <button class="font-bold hover:opacity-80">×</button>`;
|
||||
chip.onclick = () => clearFilter(key);
|
||||
chipsContainer.appendChild(chip);
|
||||
}
|
||||
});
|
||||
|
||||
container.classList.toggle('hidden', !hasFilters);
|
||||
}
|
||||
|
||||
function clearFilter(filterKey) {
|
||||
switch (filterKey) {
|
||||
case 'author':
|
||||
document.getElementById('filter-author').value = '';
|
||||
break;
|
||||
case 'genre':
|
||||
document.getElementById('filter-genre').value = '';
|
||||
break;
|
||||
case 'series':
|
||||
document.getElementById('filter-series').value = '';
|
||||
break;
|
||||
case 'language':
|
||||
document.getElementById('filter-language').value = '';
|
||||
break;
|
||||
case 'yearMin':
|
||||
document.getElementById('filter-year-min').value = '';
|
||||
break;
|
||||
case 'yearMax':
|
||||
document.getElementById('filter-year-max').value = '';
|
||||
break;
|
||||
case 'hasCover':
|
||||
document.getElementById('filter-has-cover').checked = false;
|
||||
break;
|
||||
}
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
document.getElementById('filter-author').value = '';
|
||||
document.getElementById('filter-genre').value = '';
|
||||
document.getElementById('filter-series').value = '';
|
||||
document.getElementById('filter-language').value = '';
|
||||
document.getElementById('filter-year-min').value = '';
|
||||
document.getElementById('filter-year-max').value = '';
|
||||
document.getElementById('filter-has-cover').checked = false;
|
||||
|
||||
// Clear URL params
|
||||
const params = new URLSearchParams();
|
||||
params.set('library_id', currentLibrary.id);
|
||||
window.history.pushState({}, '', `?${params.toString()}`);
|
||||
|
||||
loadMediaItems();
|
||||
document.getElementById('active-filters').classList.add('hidden');
|
||||
}
|
||||
|
||||
function viewMediaItem(mediaId) {
|
||||
// TODO: Implement detailed media view
|
||||
alert('Media viewer coming soon!');
|
||||
}
|
||||
|
||||
// Load filters from URL on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadTheme();
|
||||
loadLibraries();
|
||||
|
||||
// Restore filters from URL
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.has('author_filter')) {
|
||||
document.getElementById('filter-author').value = params.get('author_filter');
|
||||
}
|
||||
if (params.has('genre_filter')) {
|
||||
document.getElementById('filter-genre').value = params.get('genre_filter');
|
||||
}
|
||||
if (params.has('series_filter')) {
|
||||
document.getElementById('filter-series').value = params.get('series_filter');
|
||||
}
|
||||
if (params.has('language_filter')) {
|
||||
document.getElementById('filter-language').value = params.get('language_filter');
|
||||
}
|
||||
if (params.has('year_min')) {
|
||||
document.getElementById('filter-year-min').value = params.get('year_min');
|
||||
}
|
||||
if (params.has('year_max')) {
|
||||
document.getElementById('filter-year-max').value = params.get('year_max');
|
||||
}
|
||||
if (params.has('has_cover')) {
|
||||
document.getElementById('filter-has-cover').checked = params.get('has_cover') === 'true';
|
||||
}
|
||||
if (params.has('sort')) {
|
||||
document.getElementById('sort-select').value = params.get('sort');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<!-- Dashboard Settings Modal -->
|
||||
@DashboardSettingsModal(sections)
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
templ CollectionCarousel(section handlers.SectionData) {
|
||||
<div class="dashboard-collection mb-8"
|
||||
data-collection-id={ section.ID }
|
||||
data-is-system={ section.IsSystem }>
|
||||
<!-- Collection Header -->
|
||||
<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>
|
||||
if section.Description != "" {
|
||||
<p class="text-sm" style="color: var(--text-secondary)">{ section.Description }</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
if section.ViewAllURL != "" {
|
||||
<a href={ section.ViewAllURL }
|
||||
class="text-sm font-medium hover:underline transition-colors"
|
||||
style="color: var(--accent);">
|
||||
View All →
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Carousel -->
|
||||
<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;">
|
||||
for _, item := range section.Items {
|
||||
@BookCard(item)
|
||||
}
|
||||
|
||||
if len(section.Items) == 0 {
|
||||
<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>
|
||||
}
|
||||
|
||||
templ BookCard(item handlers.BookInfo) {
|
||||
<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={ item.MediaItemID }
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label={ "View " + item.Title }>
|
||||
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2
|
||||
bg-gradient-to-br from-gray-700 to-gray-900">
|
||||
if item.CoverImagePath != "" {
|
||||
<img src={ item.CoverImagePath }
|
||||
alt={ item.Title }
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onerror="this.src='/static/placeholder-book.svg'">
|
||||
} else {
|
||||
<img src="/static/placeholder-book.svg"
|
||||
alt={ item.Title }
|
||||
class="w-full h-full object-cover">
|
||||
}
|
||||
</div>
|
||||
|
||||
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">
|
||||
{ item.Title }
|
||||
</h3>
|
||||
|
||||
if item.Author != "" {
|
||||
<p class="text-xs line-clamp-1" style="color: var(--text-secondary)">
|
||||
{ item.Author }
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ DashboardSettingsModal(sections []handlers.SectionData) {
|
||||
<div id="dashboard-settings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center"
|
||||
style="background-color: rgba(0, 0, 0, 0.7);">
|
||||
<div class="rounded-lg p-6 w-full max-w-2xl mx-4 shadow-2xl"
|
||||
style="background-color: var(--bg-secondary);">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
|
||||
<button data-action="close-dashboard-settings"
|
||||
class="p-2 hover:bg-gray-700 rounded transition-colors">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm mb-4" style="color: var(--text-secondary);">
|
||||
Drag to reorder collections, toggle visibility with the switch.
|
||||
</p>
|
||||
|
||||
<!-- Draggable Collection List -->
|
||||
<div id="collection-list" class="space-y-2 mb-6">
|
||||
for _, section := range sections {
|
||||
<div class="collection-item flex items-center justify-between p-3 rounded border
|
||||
cursor-move select-none"
|
||||
data-collection-id={ section.ID }
|
||||
data-is-system={ fmt.Sprintf("%v", section.IsSystem) }
|
||||
draggable="true"
|
||||
style="background-color: var(--bg-primary); border-color: var(--border);">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xl" style="color: var(--text-secondary);">☰</span>
|
||||
<span class="text-xl">{ section.Icon }</span>
|
||||
<div>
|
||||
<span class="font-medium" style="color: var(--text-primary);">{ section.Title }</span>
|
||||
if section.IsSystem {
|
||||
<span class="text-xs ml-2 px-2 py-1 rounded" style="background-color: var(--accent);">System</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
if section.IsSystem {
|
||||
<button data-action="restore-system-collection"
|
||||
data-collection-name={ section.ID }
|
||||
class="text-xs px-3 py-1 rounded border hover:opacity-80 transition-opacity"
|
||||
style="border-color: var(--border); color: var(--text-secondary);"
|
||||
title="Restore { section.Title } to defaults">
|
||||
Restore
|
||||
</button>
|
||||
}
|
||||
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox"
|
||||
class="sr-only peer"
|
||||
checked
|
||||
data-action="toggle-collection-visibility"
|
||||
data-collection-id={ section.ID }>
|
||||
<div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer
|
||||
peer-checked:after:translate-x-full peer-checked:after:border-white
|
||||
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
|
||||
after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all
|
||||
peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Items Per Section Slider -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Items per Collection: <span id="items-count-display" class="font-bold">20</span>
|
||||
</label>
|
||||
<input type="range" min="10" max="50" step="5" value="20"
|
||||
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
|
||||
data-action="update-items-count"
|
||||
target="items-count-display">
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button data-action="close-dashboard-settings"
|
||||
class="px-4 py-2 rounded-lg border hover:bg-gray-700 transition-colors"
|
||||
style="border-color: var(--border); color: var(--text-primary);">
|
||||
Cancel
|
||||
</button>
|
||||
<button data-action="save-dashboard-settings"
|
||||
class="px-4 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
|
||||
style="background-color: var(--accent);">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,332 @@
|
||||
// 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 {};
|
||||
Reference in New Issue
Block a user