Files
bookhoard/templates/dashboard.templ
T
john-okeefe ffb09ccc5e Phase 2 & 3: Update frontend with sorting and filtering UI
- Expand sort dropdown with 12 sorting options
- Add collapsible filter panel with 6 filter types
- Implement JavaScript filter logic (applyFilters, loadMediaItemsWithFilters, displayActiveFilters, clearFilters)
- Add URL state management for shareable filtered/sorted views
- Add active filter chips display with clear functionality
- Support filtering by author, genre, series, language, year range, has_cover
- Restore filters from URL on page load
2026-01-30 08:32:56 -05:00

661 lines
33 KiB
Templ
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.
package templates
templ Dashboard(user User) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - Bookmann</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>
<link href="/static/style.css" rel="stylesheet">
<style>
:root {
--bg-primary: #1a1b26;
--bg-secondary: #16161e;
--text-primary: #a9b1d6;
--text-secondary: #565f89;
--accent: #7aa2f7;
--border: #414868;
}
.theme-tokyo-night {
--bg-primary: #1a1b26;
--bg-secondary: #16161e;
--text-primary: #a9b1d6;
--text-secondary: #565f89;
--accent: #7aa2f7;
--border: #414868;
}
body {
background-color: var(--bg-primary);
color: var(--text-primary);
}
.card {
background-color: var(--bg-secondary);
border-color: var(--border);
}
.btn-primary {
background-color: var(--accent);
color: var(--bg-primary);
}
.btn-primary:hover {
opacity: 0.8;
}
.rating-star {
display: inline-block;
transition: all 0.2s ease;
cursor: pointer;
position: relative;
}
.rating-star:hover {
transform: scale(1.1);
}
.half-star {
position: relative;
display: inline-block;
}
.half-star::before {
content: '★';
position: absolute;
left: 0;
top: 0;
width: 50%;
overflow: hidden;
color: #facc15; /* yellow-400 */
}
.rating-text {
font-size: 0.875rem;
margin-left: 0.5rem;
opacity: 0.8;
}
</style>
</head>
<body class="theme-tokyo-night">
@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>
</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
</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>
</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>';
});
}
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>
</body>
</html>
}