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:
+379
-5
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user