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:
@@ -274,8 +274,8 @@ function renderAnimeDetails(anime) {
|
||||
<a href="${escapeHtml(anime.url)}" target="_blank" class="btn btn-secondary btn-sm">
|
||||
<i class="fa-solid fa-link"></i> Voir sur MAL
|
||||
</a>
|
||||
<button onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')" class="btn btn-primary btn-sm">
|
||||
<i class="fa-solid fa-download"></i> Télécharger
|
||||
<button onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')" class="btn btn-success btn-sm">
|
||||
<i class="fa-solid fa-arrow-down"></i> Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -397,7 +397,7 @@ function renderStreamingResult(result, query) {
|
||||
return `
|
||||
<div class="card bg-base-200 border border-base-300 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg">${icon}</span>
|
||||
<span class="font-semibold text-sm">${escapeHtml(name)}</span>
|
||||
@@ -414,9 +414,16 @@ function renderStreamingResult(result, query) {
|
||||
${episodes.length > 20 ? `<option disabled>... et ${episodes.length - 20} autres</option>` : ''}
|
||||
</select>
|
||||
|
||||
<button class="btn btn-primary btn-sm w-full streaming-download-btn" onclick="downloadSelectedEpisode(this)">
|
||||
<i class="fa-solid fa-download"></i> Télécharger
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary btn-sm flex-1 streaming-download-btn" onclick="downloadSelectedEpisode(this)">
|
||||
<i class="fa-solid fa-download"></i> Télécharger
|
||||
</button>
|
||||
<button class="btn btn-success btn-sm streaming-download-all-btn"
|
||||
onclick="downloadAllEpisodes(this, '${escapeHtml(query)}', '${escapeHtml(provider)}')"
|
||||
title="Télécharger toute la saison">
|
||||
<i class="fas fa-layer-group"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="#" class="link link-hover text-xs mt-2" onclick="searchAnimeOnProviders('${escapeHtml(query)}'); return false;">
|
||||
@@ -427,6 +434,45 @@ function renderStreamingResult(result, query) {
|
||||
`;
|
||||
}
|
||||
|
||||
// Download all episodes from a streaming result card
|
||||
async function downloadAllEpisodes(button, query, provider) {
|
||||
const card = button.closest('.card');
|
||||
const select = card.querySelector('.streaming-episode-select');
|
||||
const totalEps = select.options.length - 1; // exclude disabled options
|
||||
const hasMore = select.querySelector('option[disabled]');
|
||||
|
||||
button.disabled = true;
|
||||
const originalHtml = button.innerHTML;
|
||||
button.innerHTML = '<span class="loading loading-spinner loading-xs"></span>';
|
||||
|
||||
let completed = 0;
|
||||
const promises = [];
|
||||
|
||||
for (const option of select.options) {
|
||||
if (!option.value || option.disabled) continue;
|
||||
promises.push(
|
||||
fetch(`${API_BASE}/anime/download?url=${encodeURIComponent(option.value)}`, { method: 'POST' })
|
||||
.then(r => { completed++; return r; })
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
const successCount = results.filter(r => r.status === 'fulfilled').length;
|
||||
|
||||
button.innerHTML = '<i class="fas fa-check"></i>';
|
||||
showToast(`${successCount} épisodes mis en file de téléchargement`);
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHtml;
|
||||
button.disabled = false;
|
||||
}, 4000);
|
||||
|
||||
// Refresh downloads list
|
||||
if (typeof loadDownloads === 'function') {
|
||||
loadDownloads();
|
||||
}
|
||||
}
|
||||
|
||||
// Download selected episode from streaming results
|
||||
async function downloadSelectedEpisode(button) {
|
||||
const select = button.parentElement.querySelector('.streaming-episode-select');
|
||||
@@ -519,7 +565,7 @@ async function translateSynopsis(synopsisId, button) {
|
||||
|
||||
synopsisElement.textContent = data.translatedText;
|
||||
synopsisElement.dataset.translated = 'true';
|
||||
button.innerHTML = '<i class="fa-solid fa-rotate"></i> Voir l\\'original';
|
||||
button.innerHTML = '<i class="fa-solid fa-rotate"></i> Voir l'original';
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||
console.error('Translation API error:', errorData);
|
||||
|
||||
@@ -190,8 +190,8 @@ function renderRecommendationCard(anime) {
|
||||
<button class="btn btn-secondary btn-sm" onclick="window.open('${escapeHtml(anime.url)}', '_blank')">
|
||||
<i class="fa-solid fa-link"></i> MAL
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')">
|
||||
<i class="fa-solid fa-download"></i> Télécharger
|
||||
<button class="btn btn-success btn-sm" onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')">
|
||||
<i class="fa-solid fa-arrow-down"></i> Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -247,8 +247,8 @@ function renderReleaseCard(anime) {
|
||||
<button class="btn btn-secondary btn-sm" onclick="window.open('${escapeHtml(anime.url)}', '_blank')">
|
||||
<i class="fa-solid fa-link"></i> MAL
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')">
|
||||
<i class="fa-solid fa-download"></i> Télécharger
|
||||
<button class="btn btn-success btn-sm" onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')">
|
||||
<i class="fa-solid fa-arrow-down"></i> Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+93
-25
@@ -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;
|
||||
|
||||
+4
-4
@@ -36,8 +36,8 @@ function renderSeriesRecommendationCard(series) {
|
||||
<button class="btn btn-secondary btn-sm" onclick="window.open('${escapeHtml(series.url)}', '_blank')">
|
||||
<i class="fa-solid fa-link"></i> Voir sur FS7
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
|
||||
<i class="fa-solid fa-download"></i> Voir les épisodes
|
||||
<button class="btn btn-success btn-sm" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
|
||||
<i class="fa-solid fa-arrow-down"></i> Voir les épisodes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,8 +96,8 @@ function renderSeriesReleaseCard(series) {
|
||||
<button class="btn btn-secondary btn-sm" onclick="window.open('${escapeHtml(series.url)}', '_blank')">
|
||||
<i class="fa-solid fa-link"></i> Voir sur FS7
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
|
||||
<i class="fa-solid fa-download"></i> Voir les épisodes
|
||||
<button class="btn btn-success btn-sm" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
|
||||
<i class="fa-solid fa-arrow-down"></i> Voir les épisodes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user