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:
2026-01-30 08:32:56 -05:00
parent 3b2075fc70
commit ffb09ccc5e
2 changed files with 277 additions and 6 deletions
+268 -5
View File
@@ -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>
File diff suppressed because one or more lines are too long