feat: redesign download UX — batch select, season download, toast feedback

Episode list:
- Added 'Saison complète' header button to download all episodes at once
- Added multi-select mode with checkboxes for batch episode download
- Individual download buttons now show visual feedback (checkmark + reset)
- Better grid/list toggle with selection state indicators

Search results (anime + series):
- Redesigned download dropdown with icons, descriptions, spinner on click
- Smooth scale/opacity transitions on dropdown open/close
- Consistent btn-success color for all download actions

Series search JS:
- Replaced basic <select> with scrollable episode list inline
- Added 'Tout télécharger' button per series card
- Replaced all alert() calls with toast notifications
- Episode buttons show checkmark on successful download

Anime details JS:
- Added batch download button next to episode select
- Fixed pre-existing lint error (escaped quote in translateSynopsis)
- Standardized download icon to fa-arrow-down across all cards

Recommendations + Tabs JS:
- Unified download button color (btn-success) across all card types
- Consistent icon (fa-arrow-down) for download actions

Toast system:
- Connected to existing Alpine.js toast infrastructure (show-toast events)
This commit is contained in:
root
2026-04-11 21:08:29 +00:00
parent a7145aabd1
commit 819acf04f8
8 changed files with 386 additions and 83 deletions
+93 -25
View File
@@ -59,7 +59,7 @@ async function handleSeriesSearch() {
<i class="fa-solid fa-link"></i> Voir sur FS7
</button>
<button class="btn btn-primary btn-sm" onclick="loadSeriesEpisodesDirect('${escapeHtml(s.url)}', '${escapeHtml(s.title)}')">
<i class="fa-solid fa-download"></i> Voir les épisodes
<i class="fa-solid fa-arrow-down"></i> Télécharger
</button>
</div>
<div id="episodes-fs7-${encodeURIComponent(s.url)}" class="mt-2"></div>
@@ -91,51 +91,119 @@ async function handleSeriesSearch() {
}
}
// Load series episodes directly without redirecting to search
// Load series episodes directly — shows an inline episode list with download buttons
async function loadSeriesEpisodesDirect(url, title) {
const episodesContainer = document.getElementById(`episodes-fs7-${encodeURIComponent(url)}`);
if (!episodesContainer) return;
try {
episodesContainer.innerHTML = '<div class="flex justify-center py-4"><span class="loading loading-spinner loading-sm"></span><span class="ml-2 text-base-content/60 text-sm">Chargement des épisodes...</span></div>';
episodesContainer.innerHTML = `
<div class="flex items-center gap-2 py-4">
<span class="loading loading-spinner loading-sm text-primary"></span>
<span class="text-base-content/60 text-sm">Chargement des épisodes...</span>
</div>
`;
const response = await fetch(`${API_BASE}/anime/episodes?url=${encodeURIComponent(url)}&lang=vf`);
const data = await response.json();
if (data.episodes && data.episodes.length > 0) {
const totalEps = data.episodes.length;
let html = `
<div class="mt-3">
<label class="label-text text-xs mb-1 block text-warning">
<i class="fa-solid fa-tv"></i> Sélectionner un épisode:
</label>
<select id="select-episodes-${encodeURIComponent(url)}" class="select select-bordered select-sm w-full">
<option value="">Sélectionner un épisode</option>
${data.episodes.map(ep => `
<option value="${escapeHtml(ep.url)}">Épisode ${escapeHtml(ep.episode)}</option>
`).join('')}
</select>
<button class="btn btn-primary btn-sm w-full mt-2" onclick="downloadSeriesEpisode('${escapeHtml(url)}', '${escapeHtml(title)}')">
<i class="fa-solid fa-download"></i>
Télécharger l'épisode
</button>
<div class="mt-3 space-y-2">
<div class="flex items-center justify-between mb-2">
<span class="label-text text-xs text-base-content/60">
<i class="fas fa-list-ol text-primary"></i> ${totalEps} épisodes disponibles
</span>
<button class="btn btn-xs btn-success gap-1"
onclick="downloadAllSeriesEpisodes(this, '${escapeHtml(url)}', '${escapeHtml(title)}')">
<i class="fas fa-layer-group"></i> Tout télécharger
</button>
</div>
<div class="max-h-48 overflow-y-auto rounded-lg border border-base-300">
<ul class="divide-y divide-base-300">
${data.episodes.map((ep, i) => `
<li class="flex items-center justify-between px-3 py-2 hover:bg-base-200/50 transition-colors">
<span class="text-sm font-medium">Épisode ${escapeHtml(ep.episode)}</span>
<button class="btn btn-xs btn-outline btn-success gap-1"
hx-post="/api/anime/download?url=${escapeHtml(ep.url)}"
hx-swap="none"
hx-on::after-request="if(event.detail.successful){this.innerHTML='<i class=\'fas fa-check\'></i>';this.disabled=true;this.classList.remove('btn-outline','btn-success');this.classList.add('btn-ghost','pointer-events-none');setTimeout(()=>{this.innerHTML='<i class=\'fas fa-download\'></i>';this.disabled=false;this.classList.remove('btn-ghost','pointer-events-none');this.classList.add('btn-outline','btn-success')},4000)}"
title="Télécharger l'épisode ${escapeHtml(ep.episode)}">
<i class="fas fa-download"></i>
</button>
</li>
`).join('')}
</ul>
</div>
</div>
`;
episodesContainer.innerHTML = html;
} else {
episodesContainer.innerHTML = '<div class="text-center py-4 text-base-content/50 text-sm">Aucun épisode disponible</div>';
episodesContainer.innerHTML = `
<div class="text-center py-4 text-base-content/50 text-sm">
<i class="fas fa-inbox mb-1 block"></i>
Aucun épisode disponible
</div>
`;
}
} catch (error) {
console.error('Error loading episodes:', error);
episodesContainer.innerHTML = `<div class="text-center py-4 text-sm text-error">Erreur: ${error.message}</div>`;
episodesContainer.innerHTML = `
<div class="alert alert-error alert-sm text-xs">
<i class="fas fa-triangle-exclamation"></i>
<span>Erreur: ${error.message}</span>
</div>
`;
}
}
// Download series episode
// Download all series episodes
async function downloadAllSeriesEpisodes(button, url, title) {
const container = button.closest('.mt-3');
const episodeBtns = container.querySelectorAll('ul button[hx-post*="/api/anime/download"]');
// Visual feedback: disable button, show spinner
button.disabled = true;
const originalHtml = button.innerHTML;
button.innerHTML = '<span class="loading loading-spinner loading-xs"></span> En cours...';
let completed = 0;
const total = episodeBtns.length;
const results = await Promise.allSettled(
[...episodeBtns].map(btn => {
const hxPost = btn.getAttribute('hx-post');
const epUrl = hxPost.split('url=')[1];
return fetch(`${API_BASE}/anime/download?url=${encodeURIComponent(epUrl)}`, { method: 'POST' })
.then(r => {
completed++;
// Visual: mark episode button as done
btn.innerHTML = '<i class="fas fa-check"></i>';
btn.disabled = true;
btn.classList.remove('btn-outline', 'btn-success');
btn.classList.add('btn-ghost', 'pointer-events-none');
return r;
});
})
);
button.innerHTML = '<i class="fas fa-check"></i> Terminé';
showToast(`${completed} épisodes de "${title}" mis en file`);
// Reset button after delay
setTimeout(() => {
button.innerHTML = originalHtml;
button.disabled = false;
}, 5000);
}
// Download series episode (single - kept for compatibility)
async function downloadSeriesEpisode(url, title) {
const select = document.getElementById(`select-episodes-${encodeURIComponent(url)}`);
if (!select || !select.value) {
alert('Veuillez sélectionner un épisode');
showToast('Veuillez sélectionner un épisode', 'warning');
return;
}
@@ -147,8 +215,7 @@ async function downloadSeriesEpisode(url, title) {
});
if (response.ok) {
alert(`Téléchargement démarré pour "${title}"`);
// Refresh downloads
showToast(`Téléchargement démarré pour "${title}"`);
if (typeof loadDownloads === 'function') {
loadDownloads();
}
@@ -157,11 +224,11 @@ async function downloadSeriesEpisode(url, title) {
const errorMessage = error.detail
? (typeof error.detail === 'string' ? error.detail : JSON.stringify(error.detail))
: 'Impossible de démarrer le téléchargement';
alert(`Erreur : ${errorMessage}`);
showToast(`Erreur : ${errorMessage}`, 'error');
}
} catch (error) {
console.error('Download error:', error);
alert(`Erreur lors du téléchargement : ${error.message}`);
showToast(`Erreur lors du téléchargement : ${error.message}`, 'error');
}
}
@@ -169,3 +236,4 @@ async function downloadSeriesEpisode(url, title) {
window.handleSeriesSearch = handleSeriesSearch;
window.loadSeriesEpisodesDirect = loadSeriesEpisodesDirect;
window.downloadSeriesEpisode = downloadSeriesEpisode;
window.downloadAllSeriesEpisodes = downloadAllSeriesEpisodes;