feat: Add advanced filtering, sorting, and grouping to downloads history

Implement a comprehensive download history management system with powerful filtering, sorting, and grouping capabilities.

New Features:
1. Statistics Dashboard
   - Real-time stats: total, downloading, paused, completed, failed
   - Color-coded badges for quick visual overview
   - Auto-updates every second with downloads

2. Advanced Filtering System
   - Filter by status: All, In Progress, Paused, Completed, Cancelled, Failed
   - Real-time search by filename or URL
   - Multiple filters can be combined

3. Multiple Sorting Options
   - Date (newest/oldest first)
   - Name (alphabetical A-Z / Z-A)
   - File size

4. Smart Grouping System
   - Group by Series: Automatically detects anime series names
     * Removes episode numbers, seasons, quality markers
     * Groups episodes of same anime together
   - Group by Status: Organizes by download state
   - Group by Day: Aujourd'hui, Hier, or specific date
   - Collapsible groups for cleaner UI

5. Bulk Actions
   - Clear all completed downloads with one click
   - Confirmation dialog to prevent accidents

UI Improvements:
- Modern filter controls with dark theme
- Responsive layout that works on all screen sizes
- Collapsible group headers with episode counts
- Empty state messages when no downloads match filters
- Visual indicators for each status type

Technical Details:
- extractSeriesName() function with regex patterns for:
  * Episode numbers (Ep, Episode, Épisode, SxxExx)
  * Quality markers (1080p, 720p, 480p)
  * Language tags (VOSTFR, VF, MULTI)
  * File extensions and brackets
- getDayString() for intelligent date grouping
- filterDownloads() for real-time filtering without API calls
- groupDownloads() for automatic series detection
- updateStats() for live statistics

User Experience:
- Filters persist during auto-refresh (every second)
- Group headers are clickable to toggle visibility
- Search works instantly as you type
- Statistics update in real-time
- Smooth animations and transitions

Example Use Cases:
- "Show me all completed One Piece episodes"
- "List all failed downloads from yesterday"
- "Find all Naruto episodes sorted by name"
- "Clean up all completed downloads at once"

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
root
2026-01-23 10:39:49 +00:00
parent eb870d89c2
commit f13ad6abbd
+379 -5
View File
@@ -132,12 +132,144 @@
color: #000;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.2);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
margin-top: 40px;
}
.section-header h2 {
font-size: 1.8em;
margin: 0;
background: linear-gradient(45deg, #00d9ff, #00ff88);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.downloads-stats {
display: flex;
gap: 15px;
font-size: 0.85em;
}
.stat-item {
background: rgba(255, 255, 255, 0.05);
padding: 5px 12px;
border-radius: 15px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.stat-count {
font-weight: bold;
color: #00d9ff;
}
.downloads-controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
background: rgba(255, 255, 255, 0.03);
padding: 15px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
}
.filter-group label {
font-size: 0.85em;
color: #aaa;
white-space: nowrap;
}
.filter-group select,
.filter-group input {
padding: 8px 12px;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
background: rgba(0, 0, 0, 0.3);
color: #fff;
font-size: 13px;
min-width: 120px;
}
.filter-group select:focus,
.filter-group input:focus {
outline: none;
border-color: #00d9ff;
}
.search-group input {
min-width: 200px;
}
.actions-group {
margin-left: auto;
}
.downloads-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.downloads-group {
margin-bottom: 20px;
}
.downloads-group-header {
background: rgba(255, 255, 255, 0.08);
padding: 12px 18px;
border-radius: 8px;
margin-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
transition: all 0.3s;
}
.downloads-group-header:hover {
background: rgba(255, 255, 255, 0.12);
}
.downloads-group-title {
font-weight: 600;
font-size: 1.05em;
color: #00d9ff;
}
.downloads-group-count {
background: rgba(0, 217, 255, 0.2);
padding: 4px 10px;
border-radius: 12px;
font-size: 0.85em;
color: #00d9ff;
}
.downloads-group-items {
display: flex;
flex-direction: column;
gap: 12px;
}
.download-item {
background: rgba(255, 255, 255, 0.05);
border-radius: 10px;
@@ -620,6 +752,61 @@
</div>
</div>
<!-- Downloads Section with Filters -->
<div class="section-header">
<h2>Téléchargements</h2>
<div class="downloads-stats" id="downloadsStats"></div>
</div>
<!-- Filters and Controls -->
<div class="downloads-controls">
<div class="filter-group">
<label>Statut:</label>
<select id="statusFilter" onchange="filterDownloads()">
<option value="all">Tous</option>
<option value="downloading">En cours</option>
<option value="paused">En pause</option>
<option value="completed">Terminés</option>
<option value="cancelled">Annulés</option>
<option value="failed">Échoués</option>
</select>
</div>
<div class="filter-group">
<label>Tri par:</label>
<select id="sortBy" onchange="filterDownloads()">
<option value="date">Date (récent)</option>
<option value="date_asc">Date (ancien)</option>
<option value="name">Nom (A-Z)</option>
<option value="name_desc">Nom (Z-A)</option>
<option value="size">Taille</option>
</select>
</div>
<div class="filter-group">
<label>Regroupement:</label>
<select id="groupBy" onchange="filterDownloads()">
<option value="none">Aucun</option>
<option value="series">Par série</option>
<option value="status">Par statut</option>
<option value="day">Par jour</option>
</select>
</div>
<div class="filter-group search-group">
<input type="text" id="searchDownloads" placeholder="🔍 Rechercher..." oninput="filterDownloads()">
</div>
<div class="actions-group">
<button class="btn-small btn-secondary" onclick="clearCompleted()" title="Supprimer les terminés">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
Nettoyer
</button>
</div>
</div>
<div id="downloadsList" class="downloads-list">
<div class="empty-state">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -635,6 +822,140 @@
let autoRefreshInterval;
let currentAnimeUrl = '';
let searchResultsCache = {};
let allDownloads = []; // Store all downloads for filtering
// Extract series name from filename (for grouping)
function extractSeriesName(filename) {
// Common patterns:
// - "Naruto Shippuden - Episode 123.mp4"
// - "[One Piece] Ep 456.mkv"
// - "Attack on Titan S03E09.mp4"
// - "Anime Name - S01E05.mp4"
let name = filename;
// Remove file extension
name = name.replace(/\.[^/.]+$/, '');
// Remove episode numbers and patterns
name = name
.replace(/[-_ ]?(E(?:p)?|Episode|Épisode|Saison|Season)[-_: ]?\d+/gi, '')
.replace(/[-_ ]?S\d{2}E\d{2}/gi, '')
.replace(/\[.*?\]/g, '')
.replace(/\(.*\)/g, '')
.replace(/[-_ ]?\d{3,4}p/gi, '')
.replace(/[-_ ]?(VOSTFR|VF|MULTI)/gi, '')
.trim();
// If nothing left, use original filename
if (!name) {
return filename.replace(/\.[^/.]+$/, '');
}
return name;
}
// Get day string for grouping
function getDayString(dateString) {
const date = new Date(dateString);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === today.toDateString()) {
return "Aujourd'hui";
} else if (date.toDateString() === yesterday.toDateString()) {
return "Hier";
} else {
return date.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'short' });
}
}
// Filter and sort downloads
function filterDownloads() {
const statusFilter = document.getElementById('statusFilter').value;
const sortBy = document.getElementById('sortBy').value;
const groupBy = document.getElementById('groupBy').value;
const searchTerm = document.getElementById('searchDownloads').value.toLowerCase();
// Filter by status and search
let filtered = allDownloads.filter(dl => {
const matchesStatus = statusFilter === 'all' || dl.status === statusFilter;
const matchesSearch = !searchTerm ||
dl.filename.toLowerCase().includes(searchTerm) ||
(dl.url && dl.url.toLowerCase().includes(searchTerm));
return matchesStatus && matchesSearch;
});
// Sort
filtered.sort((a, b) => {
switch (sortBy) {
case 'date_asc':
return new Date(a.created_at) - new Date(b.created_at);
case 'name':
return a.filename.localeCompare(b.filename);
case 'name_desc':
return b.filename.localeCompare(a.filename);
case 'size':
return (b.total_bytes || 0) - (a.total_bytes || 0);
case 'date':
default:
return new Date(b.created_at) - new Date(a.created_at);
}
});
displayDownloads(filtered, groupBy);
}
// Group downloads
function groupDownloads(downloads, groupBy) {
const groups = {};
downloads.forEach(dl => {
let key = 'Ungrouped';
switch (groupBy) {
case 'series':
key = extractSeriesName(dl.filename);
break;
case 'status':
key = translateStatus(dl.status);
break;
case 'day':
key = getDayString(dl.created_at);
break;
default:
key = 'Tous';
}
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(dl);
});
return groups;
}
// Clear completed downloads
async function clearCompleted() {
const completed = allDownloads.filter(dl => dl.status === 'completed');
if (completed.length === 0) {
alert('Aucun téléchargement terminé à supprimer');
return;
}
if (!confirm(`Supprimer ${completed.length} téléchargement(s) terminé(s) ?`)) {
return;
}
for (const dl of completed) {
await fetch(`${API_BASE}/download/${dl.id}`, { method: 'DELETE' });
}
loadDownloads();
}
// Search Anime across all providers
async function searchAnime() {
@@ -1149,10 +1470,30 @@
async function loadDownloads() {
const response = await fetch(`${API_BASE}/downloads`);
const data = await response.json();
displayDownloads(data.downloads);
allDownloads = data.downloads; // Store all downloads
updateStats(); // Update statistics
filterDownloads(); // Apply current filters
}
function displayDownloads(downloads) {
function updateStats() {
const stats = {
total: allDownloads.length,
downloading: allDownloads.filter(d => d.status === 'downloading').length,
paused: allDownloads.filter(d => d.status === 'paused').length,
completed: allDownloads.filter(d => d.status === 'completed').length,
failed: allDownloads.filter(d => d.status === 'failed').length
};
document.getElementById('downloadsStats').innerHTML = `
<div class="stat-item">Total: <span class="stat-count">${stats.total}</span></div>
${stats.downloading > 0 ? `<div class="stat-item">En cours: <span class="stat-count" style="color: #00d9ff;">${stats.downloading}</span></div>` : ''}
${stats.paused > 0 ? `<div class="stat-item">En pause: <span class="stat-count" style="color: #ffa500;">${stats.paused}</span></div>` : ''}
${stats.completed > 0 ? `<div class="stat-item">Terminés: <span class="stat-count" style="color: #00ff88;">${stats.completed}</span></div>` : ''}
${stats.failed > 0 ? `<div class="stat-item">Échoués: <span class="stat-count" style="color: #ff4444;">${stats.failed}</span></div>` : ''}
`;
}
function displayDownloads(downloads, groupBy = 'none') {
const container = document.getElementById('downloadsList');
if (downloads.length === 0) {
@@ -1161,13 +1502,40 @@
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"></path>
</svg>
<p>Aucun téléchargement pour le moment</p>
<p>Aucun téléchargement trouvé</p>
</div>
`;
return;
}
container.innerHTML = downloads.map(dl => `
// Group downloads if needed
const groups = groupBy !== 'none' ? groupDownloads(downloads, groupBy) : null;
if (groups) {
// Display grouped downloads
let html = '';
for (const [groupName, groupDownloads] of Object.entries(groups)) {
html += `
<div class="downloads-group">
<div class="downloads-group-header" onclick="toggleGroup(this)">
<div class="downloads-group-title">${escapeHtml(groupName)}</div>
<div class="downloads-group-count">${groupDownloads.length}</div>
</div>
<div class="downloads-group-items">
${groupDownloads.map(dl => renderDownloadItem(dl)).join('')}
</div>
</div>
`;
}
container.innerHTML = html;
} else {
// Display flat list
container.innerHTML = downloads.map(dl => renderDownloadItem(dl)).join('');
}
}
function renderDownloadItem(dl) {
return `
<div class="download-item">
<div class="download-header">
<div class="filename">${escapeHtml(dl.filename)}</div>
@@ -1242,7 +1610,13 @@
</div>
${dl.error ? `<div class="error-message">${escapeHtml(dl.error)}</div>` : ''}
</div>
`).join('');
`;
}
function toggleGroup(header) {
const items = header.nextElementSibling;
items.style.display = items.style.display === 'none' ? 'flex' : 'none';
header.classList.toggle('collapsed');
}
async function pauseDownload(id) {