Template changes: - Update page titles: "Bookmann" → "Bookhoard" - Update header branding and navigation text - Regenerate compiled .go templates from .templ sources - Update all UI references in HTML templates This is part 3 of the project rename to Bookhoard.
661 lines
33 KiB
Templ
661 lines
33 KiB
Templ
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 - 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>
|
||
<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>
|
||
} |