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
This commit is contained in:
+268
-5
@@ -100,14 +100,94 @@ templ Dashboard(user User) {
|
||||
</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 First</option>
|
||||
<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="created_at ASC">Oldest First</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 -->
|
||||
@@ -370,11 +450,11 @@ templ Dashboard(user User) {
|
||||
|
||||
function filterMedia() {
|
||||
const searchTerm = document.getElementById('search-input').value.toLowerCase();
|
||||
const filteredItems = mediaItems.filter(item =>
|
||||
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;
|
||||
@@ -382,15 +462,198 @@ templ Dashboard(user User) {
|
||||
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 libraries on page load
|
||||
// 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>
|
||||
|
||||
Reference in New Issue
Block a user