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:
File diff suppressed because one or more lines are too long
@@ -274,8 +274,8 @@ function renderAnimeDetails(anime) {
|
|||||||
<a href="${escapeHtml(anime.url)}" target="_blank" class="btn btn-secondary btn-sm">
|
<a href="${escapeHtml(anime.url)}" target="_blank" class="btn btn-secondary btn-sm">
|
||||||
<i class="fa-solid fa-link"></i> Voir sur MAL
|
<i class="fa-solid fa-link"></i> Voir sur MAL
|
||||||
</a>
|
</a>
|
||||||
<button onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')" class="btn btn-primary btn-sm">
|
<button onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')" class="btn btn-success btn-sm">
|
||||||
<i class="fa-solid fa-download"></i> Télécharger
|
<i class="fa-solid fa-arrow-down"></i> Télécharger
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -397,7 +397,7 @@ function renderStreamingResult(result, query) {
|
|||||||
return `
|
return `
|
||||||
<div class="card bg-base-200 border border-base-300 shadow-sm">
|
<div class="card bg-base-200 border border-base-300 shadow-sm">
|
||||||
<div class="card-body p-4">
|
<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">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-lg">${icon}</span>
|
<span class="text-lg">${icon}</span>
|
||||||
<span class="font-semibold text-sm">${escapeHtml(name)}</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>` : ''}
|
${episodes.length > 20 ? `<option disabled>... et ${episodes.length - 20} autres</option>` : ''}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button class="btn btn-primary btn-sm w-full streaming-download-btn" onclick="downloadSelectedEpisode(this)">
|
<div class="flex gap-2">
|
||||||
<i class="fa-solid fa-download"></i> Télécharger
|
<button class="btn btn-primary btn-sm flex-1 streaming-download-btn" onclick="downloadSelectedEpisode(this)">
|
||||||
</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
<a href="#" class="link link-hover text-xs mt-2" onclick="searchAnimeOnProviders('${escapeHtml(query)}'); return false;">
|
<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
|
// Download selected episode from streaming results
|
||||||
async function downloadSelectedEpisode(button) {
|
async function downloadSelectedEpisode(button) {
|
||||||
const select = button.parentElement.querySelector('.streaming-episode-select');
|
const select = button.parentElement.querySelector('.streaming-episode-select');
|
||||||
@@ -519,7 +565,7 @@ async function translateSynopsis(synopsisId, button) {
|
|||||||
|
|
||||||
synopsisElement.textContent = data.translatedText;
|
synopsisElement.textContent = data.translatedText;
|
||||||
synopsisElement.dataset.translated = 'true';
|
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 {
|
} else {
|
||||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||||
console.error('Translation API error:', errorData);
|
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')">
|
<button class="btn btn-secondary btn-sm" onclick="window.open('${escapeHtml(anime.url)}', '_blank')">
|
||||||
<i class="fa-solid fa-link"></i> MAL
|
<i class="fa-solid fa-link"></i> MAL
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary btn-sm" onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')">
|
<button class="btn btn-success btn-sm" onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')">
|
||||||
<i class="fa-solid fa-download"></i> Télécharger
|
<i class="fa-solid fa-arrow-down"></i> Télécharger
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,8 +247,8 @@ function renderReleaseCard(anime) {
|
|||||||
<button class="btn btn-secondary btn-sm" onclick="window.open('${escapeHtml(anime.url)}', '_blank')">
|
<button class="btn btn-secondary btn-sm" onclick="window.open('${escapeHtml(anime.url)}', '_blank')">
|
||||||
<i class="fa-solid fa-link"></i> MAL
|
<i class="fa-solid fa-link"></i> MAL
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary btn-sm" onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')">
|
<button class="btn btn-success btn-sm" onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')">
|
||||||
<i class="fa-solid fa-download"></i> Télécharger
|
<i class="fa-solid fa-arrow-down"></i> Télécharger
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+93
-25
@@ -59,7 +59,7 @@ async function handleSeriesSearch() {
|
|||||||
<i class="fa-solid fa-link"></i> Voir sur FS7
|
<i class="fa-solid fa-link"></i> Voir sur FS7
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary btn-sm" onclick="loadSeriesEpisodesDirect('${escapeHtml(s.url)}', '${escapeHtml(s.title)}')">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="episodes-fs7-${encodeURIComponent(s.url)}" class="mt-2"></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) {
|
async function loadSeriesEpisodesDirect(url, title) {
|
||||||
const episodesContainer = document.getElementById(`episodes-fs7-${encodeURIComponent(url)}`);
|
const episodesContainer = document.getElementById(`episodes-fs7-${encodeURIComponent(url)}`);
|
||||||
|
|
||||||
if (!episodesContainer) return;
|
if (!episodesContainer) return;
|
||||||
|
|
||||||
try {
|
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 response = await fetch(`${API_BASE}/anime/episodes?url=${encodeURIComponent(url)}&lang=vf`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.episodes && data.episodes.length > 0) {
|
if (data.episodes && data.episodes.length > 0) {
|
||||||
|
const totalEps = data.episodes.length;
|
||||||
let html = `
|
let html = `
|
||||||
<div class="mt-3">
|
<div class="mt-3 space-y-2">
|
||||||
<label class="label-text text-xs mb-1 block text-warning">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<i class="fa-solid fa-tv"></i> Sélectionner un épisode:
|
<span class="label-text text-xs text-base-content/60">
|
||||||
</label>
|
<i class="fas fa-list-ol text-primary"></i> ${totalEps} épisodes disponibles
|
||||||
<select id="select-episodes-${encodeURIComponent(url)}" class="select select-bordered select-sm w-full">
|
</span>
|
||||||
<option value="">Sélectionner un épisode</option>
|
<button class="btn btn-xs btn-success gap-1"
|
||||||
${data.episodes.map(ep => `
|
onclick="downloadAllSeriesEpisodes(this, '${escapeHtml(url)}', '${escapeHtml(title)}')">
|
||||||
<option value="${escapeHtml(ep.url)}">Épisode ${escapeHtml(ep.episode)}</option>
|
<i class="fas fa-layer-group"></i> Tout télécharger
|
||||||
`).join('')}
|
</button>
|
||||||
</select>
|
</div>
|
||||||
<button class="btn btn-primary btn-sm w-full mt-2" onclick="downloadSeriesEpisode('${escapeHtml(url)}', '${escapeHtml(title)}')">
|
<div class="max-h-48 overflow-y-auto rounded-lg border border-base-300">
|
||||||
<i class="fa-solid fa-download"></i>
|
<ul class="divide-y divide-base-300">
|
||||||
Télécharger l'épisode
|
${data.episodes.map((ep, i) => `
|
||||||
</button>
|
<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>
|
</div>
|
||||||
`;
|
`;
|
||||||
episodesContainer.innerHTML = html;
|
episodesContainer.innerHTML = html;
|
||||||
} else {
|
} 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) {
|
} catch (error) {
|
||||||
console.error('Error loading episodes:', 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) {
|
async function downloadSeriesEpisode(url, title) {
|
||||||
const select = document.getElementById(`select-episodes-${encodeURIComponent(url)}`);
|
const select = document.getElementById(`select-episodes-${encodeURIComponent(url)}`);
|
||||||
if (!select || !select.value) {
|
if (!select || !select.value) {
|
||||||
alert('Veuillez sélectionner un épisode');
|
showToast('Veuillez sélectionner un épisode', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,8 +215,7 @@ async function downloadSeriesEpisode(url, title) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
alert(`Téléchargement démarré pour "${title}"`);
|
showToast(`Téléchargement démarré pour "${title}"`);
|
||||||
// Refresh downloads
|
|
||||||
if (typeof loadDownloads === 'function') {
|
if (typeof loadDownloads === 'function') {
|
||||||
loadDownloads();
|
loadDownloads();
|
||||||
}
|
}
|
||||||
@@ -157,11 +224,11 @@ async function downloadSeriesEpisode(url, title) {
|
|||||||
const errorMessage = error.detail
|
const errorMessage = error.detail
|
||||||
? (typeof error.detail === 'string' ? error.detail : JSON.stringify(error.detail))
|
? (typeof error.detail === 'string' ? error.detail : JSON.stringify(error.detail))
|
||||||
: 'Impossible de démarrer le téléchargement';
|
: 'Impossible de démarrer le téléchargement';
|
||||||
alert(`Erreur : ${errorMessage}`);
|
showToast(`Erreur : ${errorMessage}`, 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Download error:', 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.handleSeriesSearch = handleSeriesSearch;
|
||||||
window.loadSeriesEpisodesDirect = loadSeriesEpisodesDirect;
|
window.loadSeriesEpisodesDirect = loadSeriesEpisodesDirect;
|
||||||
window.downloadSeriesEpisode = downloadSeriesEpisode;
|
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')">
|
<button class="btn btn-secondary btn-sm" onclick="window.open('${escapeHtml(series.url)}', '_blank')">
|
||||||
<i class="fa-solid fa-link"></i> Voir sur FS7
|
<i class="fa-solid fa-link"></i> Voir sur FS7
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary btn-sm" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
|
<button class="btn btn-success btn-sm" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
|
||||||
<i class="fa-solid fa-download"></i> Voir les épisodes
|
<i class="fa-solid fa-arrow-down"></i> Voir les épisodes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,8 +96,8 @@ function renderSeriesReleaseCard(series) {
|
|||||||
<button class="btn btn-secondary btn-sm" onclick="window.open('${escapeHtml(series.url)}', '_blank')">
|
<button class="btn btn-secondary btn-sm" onclick="window.open('${escapeHtml(series.url)}', '_blank')">
|
||||||
<i class="fa-solid fa-link"></i> Voir sur FS7
|
<i class="fa-solid fa-link"></i> Voir sur FS7
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary btn-sm" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
|
<button class="btn btn-success btn-sm" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
|
||||||
<i class="fa-solid fa-download"></i> Voir les épisodes
|
<i class="fa-solid fa-arrow-down"></i> Voir les épisodes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,14 +5,14 @@
|
|||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
{% set _key = item.title | lower | trim %}
|
{% set _key = item.title | lower | trim %}
|
||||||
{% if _key not in _groups.items %}
|
{% if _key not in _groups.items %}
|
||||||
{% set _ = _groups.items.update({_key: {
|
{% set _ = _groups.items.update({
|
||||||
"title": item.title,
|
"title": item.title,
|
||||||
"cover": item.cover_image or (item.metadata.poster_image if item.metadata else "") or "",
|
"cover": item.cover_image or (item.metadata.poster_image if item.metadata else "") or "",
|
||||||
"synopsis": (item.metadata.synopsis if item.metadata and item.metadata.synopsis else ""),
|
"synopsis": (item.metadata.synopsis if item.metadata and item.metadata.synopsis else ""),
|
||||||
"rating": item.metadata.rating if item.metadata and item.metadata.rating else "",
|
"rating": item.metadata.rating if item.metadata and item.metadata.rating else "",
|
||||||
"genres": item.metadata.genres if item.metadata and item.metadata.genres else [],
|
"genres": item.metadata.genres if item.metadata and item.metadata.genres else [],
|
||||||
"providers": [{ "id": item.provider_id or pid, "url": item.url }]
|
"providers": [{ "id": item.provider_id or pid, "url": item.url }]
|
||||||
}}) %}
|
}) %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% set _existing = _groups.items[_key] %}
|
{% set _existing = _groups.items[_key] %}
|
||||||
{% if not _existing.cover and item.cover_image %}
|
{% if not _existing.cover and item.cover_image %}
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
{% set first_url = group.providers[0].url %}
|
{% set first_url = group.providers[0].url %}
|
||||||
<div class="card bg-base-200 border border-base-300 hover:border-primary transition-colors">
|
<div class="card bg-base-200 border border-base-300 hover:border-primary transition-colors">
|
||||||
<div class="card-body p-5 flex-row gap-5">
|
<div class="card-body p-5 flex-row gap-5">
|
||||||
|
<!-- Poster -->
|
||||||
<figure class="w-28 shrink-0">
|
<figure class="w-28 shrink-0">
|
||||||
<a href="{{ first_url }}" target="_blank" rel="noopener">
|
<a href="{{ first_url }}" target="_blank" rel="noopener">
|
||||||
<img src="{{ group.cover or 'https://placehold.co/240x360/29274c/7e52a0?text=No+Image' }}"
|
<img src="{{ group.cover or 'https://placehold.co/240x360/29274c/7e52a0?text=No+Image' }}"
|
||||||
@@ -43,7 +44,10 @@
|
|||||||
onerror="this.src='https://placehold.co/240x360/29274c/7e52a0?text=Error'; this.onerror=null;">
|
onerror="this.src='https://placehold.co/240x360/29274c/7e52a0?text=Error'; this.onerror=null;">
|
||||||
</a>
|
</a>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
<div class="flex-1 min-w-0 flex flex-col gap-2">
|
<div class="flex-1 min-w-0 flex flex-col gap-2">
|
||||||
|
<!-- Title + rating -->
|
||||||
<div class="flex items-baseline gap-3">
|
<div class="flex items-baseline gap-3">
|
||||||
<h3 class="card-title text-base truncate">{{ group.title }}</h3>
|
<h3 class="card-title text-base truncate">{{ group.title }}</h3>
|
||||||
{% if group.rating %}
|
{% if group.rating %}
|
||||||
@@ -63,6 +67,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Provider badges -->
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
{% for p in group.providers %}
|
{% for p in group.providers %}
|
||||||
<a href="{{ p.url }}" target="_blank" rel="noopener"
|
<a href="{{ p.url }}" target="_blank" rel="noopener"
|
||||||
@@ -72,41 +77,72 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
<div class="flex flex-wrap gap-2 mt-1">
|
<div class="flex flex-wrap gap-2 mt-1">
|
||||||
|
<!-- Watch -->
|
||||||
<a href="{{ first_url }}" target="_blank" rel="noopener"
|
<a href="{{ first_url }}" target="_blank" rel="noopener"
|
||||||
class="btn btn-sm btn-primary">
|
class="btn btn-sm btn-primary">
|
||||||
<i class="fas fa-play"></i> Regarder
|
<i class="fas fa-play"></i> Regarder
|
||||||
</a>
|
</a>
|
||||||
<div class="dropdown" @click.outside="openDropdown = null">
|
|
||||||
|
<!-- Download dropdown -->
|
||||||
|
<div class="dropdown dropdown-end" @click.outside="openDropdown = null">
|
||||||
<div tabindex="0" role="button"
|
<div tabindex="0" role="button"
|
||||||
@click.stop="openDropdown = (openDropdown === '{{ first_url | urlencode }}') ? null : '{{ first_url | urlencode }}'">
|
@click.stop="openDropdown = (openDropdown === '{{ first_url | urlencode }}') ? null : '{{ first_url | urlencode }}'"
|
||||||
<span class="btn btn-sm btn-secondary">
|
x-ref="dlToggle-{{ loop.index0 }}">
|
||||||
<i class="fas fa-download"></i> Telecharger <i class="fas fa-chevron-down text-[0.6rem] ml-1"></i>
|
<span class="btn btn-sm btn-success">
|
||||||
|
<i class="fas fa-arrow-down"></i> Télécharger <i class="fas fa-chevron-down text-[0.6rem] ml-1"></i>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ul tabindex="0"
|
<ul tabindex="0"
|
||||||
class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52 border border-base-300"
|
class="dropdown-content z-[1] menu p-2 shadow-xl bg-base-300 rounded-xl w-64 border border-base-300 mt-2"
|
||||||
x-show="openDropdown === '{{ first_url | urlencode }}'"
|
x-show="openDropdown === '{{ first_url | urlencode }}'"
|
||||||
x-transition>
|
x-transition:enter="transition ease-out duration-100"
|
||||||
|
x-transition:enter-start="opacity-0 scale-95"
|
||||||
|
x-transition:enter-end="opacity-100 scale-100"
|
||||||
|
x-transition:leave="transition ease-in duration-75"
|
||||||
|
x-transition:leave-start="opacity-100 scale-100"
|
||||||
|
x-transition:leave-end="opacity-0 scale-95">
|
||||||
|
<!-- Full season -->
|
||||||
<li>
|
<li>
|
||||||
<button class="flex items-center gap-2 text-sm"
|
<button class="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors"
|
||||||
hx-post="/api/anime/download-season?url={{ first_url | urlencode }}&lang={{ default_lang }}"
|
hx-post="/api/anime/download-season?url={{ first_url | urlencode }}&lang={{ default_lang }}"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on::after-request="openDropdown = null">
|
hx-on::before-request="this.querySelector('.dl-icon').classList.add('hidden'); this.querySelector('.dl-spinner').classList.remove('hidden')"
|
||||||
<i class="fas fa-layer-group"></i> Saison complete
|
hx-on::after-request="if(event.detail.successful){showToast('Saison complète mise en file'); this.querySelector('.dl-icon').classList.remove('hidden'); this.querySelector('.dl-spinner').classList.add('hidden'); openDropdown = null}">
|
||||||
|
<span class="w-8 h-8 rounded-lg bg-success/20 text-success flex items-center justify-center shrink-0">
|
||||||
|
<i class="fas fa-layer-group dl-icon text-sm"></i>
|
||||||
|
<span class="loading loading-spinner loading-xs dl-spinner hidden"></span>
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-col text-left">
|
||||||
|
<span class="text-sm font-medium">Saison complète</span>
|
||||||
|
<span class="text-xs text-base-content/50">Tous les épisodes d'un coup</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button class="flex items-center gap-2 text-sm"
|
<div class="divider my-1 before:bg-base-content/10 after:bg-base-content/10"></div>
|
||||||
|
</li>
|
||||||
|
<!-- Choose episodes -->
|
||||||
|
<li>
|
||||||
|
<button class="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors"
|
||||||
hx-get="/api/anime/episodes?url={{ first_url | urlencode }}&lang={{ default_lang }}&html=1"
|
hx-get="/api/anime/episodes?url={{ first_url | urlencode }}&lang={{ default_lang }}&html=1"
|
||||||
hx-target="#player-container"
|
hx-target="#player-container"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-on::after-request="openDropdown = null; document.getElementById('player-container').scrollIntoView({behavior: 'smooth'})">
|
hx-on::after-request="openDropdown = null; document.getElementById('player-container').scrollIntoView({behavior: 'smooth'})">
|
||||||
<i class="fas fa-list-ol"></i> Choisir des episodes
|
<span class="w-8 h-8 rounded-lg bg-primary/20 text-primary flex items-center justify-center shrink-0">
|
||||||
|
<i class="fas fa-list-ol text-sm"></i>
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-col text-left">
|
||||||
|
<span class="text-sm font-medium">Choisir des épisodes</span>
|
||||||
|
<span class="text-xs text-base-content/50">Sélectionnez ce que vous voulez</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Follow -->
|
||||||
<button class="btn btn-sm btn-accent btn-outline"
|
<button class="btn btn-sm btn-accent btn-outline"
|
||||||
hx-post="/api/watchlist"
|
hx-post="/api/watchlist"
|
||||||
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}", "poster_image": "{{ group.cover }}"}'
|
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}", "poster_image": "{{ group.cover }}"}'
|
||||||
|
|||||||
@@ -1,19 +1,54 @@
|
|||||||
<div class="card bg-base-200 border border-primary/30 mt-8" x-data="{ view: 'grid' }">
|
<div class="card bg-base-200 border border-primary/30 mt-8"
|
||||||
|
x-data="{ view: 'grid', selectedEps: new Set(), selectMode: false, downloadingSeason: false }"
|
||||||
|
id="episode-list-card">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="card-body p-6">
|
<div class="card-body p-6">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
<h2 class="text-xl font-bold border-none p-0 m-0">{{ anime_title }}</h2>
|
<h2 class="text-xl font-bold border-none p-0 m-0">{{ anime_title }}</h2>
|
||||||
<span class="badge badge-outline">{{ episodes|length }} épisodes disponibles</span>
|
<span class="badge badge-outline">{{ episodes|length }} épisodes disponibles</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2 flex-wrap">
|
||||||
<button class="btn btn-circle btn-sm btn-ghost" @click="view = 'grid'" :class="{ 'btn-primary': view === 'grid' }">
|
<!-- View toggles -->
|
||||||
|
<button class="btn btn-circle btn-sm btn-ghost" @click="view = 'grid'" :class="{ 'btn-primary': view === 'grid' }" title="Grille">
|
||||||
<i class="fas fa-th"></i>
|
<i class="fas fa-th"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-circle btn-sm btn-ghost" @click="view = 'list'" :class="{ 'btn-primary': view === 'list' }">
|
<button class="btn btn-circle btn-sm btn-ghost" @click="view = 'list'" :class="{ 'btn-primary': view === 'list' }" title="Liste">
|
||||||
<i class="fas fa-list"></i>
|
<i class="fas fa-list"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-circle btn-sm btn-error" onclick="document.getElementById('player-container').innerHTML = ''">
|
|
||||||
|
<!-- Batch select toggle -->
|
||||||
|
<button class="btn btn-circle btn-sm btn-ghost"
|
||||||
|
@click="selectMode = !selectMode; if(!selectMode) selectedEps.clear()"
|
||||||
|
:class="{ 'btn-accent': selectMode }"
|
||||||
|
title="Sélection multiple">
|
||||||
|
<i class="fas fa-check-double"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Download selected episodes -->
|
||||||
|
<template x-if="selectMode && selectedEps.size > 0">
|
||||||
|
<button class="btn btn-sm btn-success gap-1"
|
||||||
|
@click="downloadSelected()"
|
||||||
|
:disabled="downloadingSeason">
|
||||||
|
<i class="fas fa-download" x-show="!downloadingSeason"></i>
|
||||||
|
<span class="loading loading-spinner loading-xs" x-show="downloadingSeason"></span>
|
||||||
|
<span x-text="selectedEps.size + ' épisode' + (selectedEps.size > 1 ? 's' : '')"></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Download full season -->
|
||||||
|
<button class="btn btn-sm btn-secondary gap-1"
|
||||||
|
x-show="!selectMode"
|
||||||
|
:disabled="downloadingSeason"
|
||||||
|
@click="downloadFullSeason()">
|
||||||
|
<i class="fas fa-layer-group" x-show="!downloadingSeason"></i>
|
||||||
|
<span class="loading loading-spinner loading-xs" x-show="downloadingSeason"></span>
|
||||||
|
Saison complète
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Close player -->
|
||||||
|
<button class="btn btn-circle btn-sm btn-error" onclick="document.getElementById('player-container').innerHTML = ''" title="Fermer">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -28,18 +63,38 @@
|
|||||||
<div x-show="view === 'grid'" x-transition class="mt-6">
|
<div x-show="view === 'grid'" x-transition class="mt-6">
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-7 gap-3">
|
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-7 gap-3">
|
||||||
{% for ep in episodes %}
|
{% for ep in episodes %}
|
||||||
<div class="bg-base-300 rounded-lg p-4 text-center hover:bg-base-100 transition-colors border border-transparent hover:border-primary flex flex-col gap-2">
|
<div class="bg-base-300 rounded-lg p-4 text-center hover:bg-base-100 transition-all border border-transparent hover:border-primary flex flex-col gap-2 relative group"
|
||||||
|
:class="{ 'ring-2 ring-accent border-accent': selectMode && selectedEps.has('{{ ep.url }}') }">
|
||||||
|
<!-- Selection checkbox -->
|
||||||
|
<div class="absolute top-2 right-2 z-10 transition-opacity"
|
||||||
|
:class="selectMode ? 'opacity-100' : 'opacity-0 group-hover:opacity-50'">
|
||||||
|
<label class="checkbox checkbox-sm checkbox-accent">
|
||||||
|
<input type="checkbox"
|
||||||
|
x-model="selectedEps"
|
||||||
|
value="{{ ep.url }}"
|
||||||
|
@change="$event.target.checked ? selectedEps.add('{{ ep.url }}') : selectedEps.delete('{{ ep.url }}')"
|
||||||
|
:checked="selectedEps.has('{{ ep.url }}')"
|
||||||
|
x-show="selectMode">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="text-primary font-bold text-xl">EP {{ ep.episode_number or loop.index }}</div>
|
<div class="text-primary font-bold text-xl">EP {{ ep.episode_number or loop.index }}</div>
|
||||||
<button class="btn btn-xs btn-primary w-full"
|
{% if ep.title %}
|
||||||
hx-get="/api/player/embed?url={{ ep.url | urlencode }}"
|
<div class="text-[0.65rem] text-base-content/50 truncate" title="{{ ep.title }}">{{ ep.title }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<button class="btn btn-xs btn-primary w-full"
|
||||||
|
hx-get="/api/player/embed?url={{ ep.url | urlencode }}"
|
||||||
hx-target="#video-player-display"
|
hx-target="#video-player-display"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
onclick="document.getElementById('video-player-display').scrollIntoView({behavior: 'smooth'})">
|
onclick="document.getElementById('video-player-display').scrollIntoView({behavior: 'smooth'})">
|
||||||
<i class="fas fa-play"></i> Regarder
|
<i class="fas fa-play"></i> Regarder
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-xs btn-ghost w-full"
|
<button class="btn btn-xs btn-outline btn-success w-full gap-1"
|
||||||
hx-post="/api/anime/download?url={{ ep.url | urlencode }}"
|
hx-post="/api/anime/download?url={{ ep.url | urlencode }}"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
|
hx-on::after-request="if(event.detail.successful){this.innerHTML='<i class=\'fas fa-check\'></i> Lancé';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> Télécharger';this.disabled=false;this.classList.remove('btn-ghost','pointer-events-none');this.classList.add('btn-outline','btn-success')},4000)}"
|
||||||
title="Télécharger cet épisode">
|
title="Télécharger cet épisode">
|
||||||
<i class="fas fa-download"></i> Télécharger
|
<i class="fas fa-download"></i> Télécharger
|
||||||
</button>
|
</button>
|
||||||
@@ -52,23 +107,37 @@
|
|||||||
<div x-show="view === 'list'" x-transition class="mt-6">
|
<div x-show="view === 'list'" x-transition class="mt-6">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{% for ep in episodes %}
|
{% for ep in episodes %}
|
||||||
<div class="flex items-center gap-4 bg-base-300 rounded-lg px-4 py-3 hover:bg-base-100 transition-colors">
|
<div class="flex items-center gap-3 bg-base-300 rounded-lg px-4 py-3 hover:bg-base-100 transition-all group"
|
||||||
|
:class="{ 'ring-2 ring-accent border-accent': selectMode && selectedEps.has('{{ ep.url }}') }">
|
||||||
|
<!-- Selection checkbox -->
|
||||||
|
<div class="shrink-0 transition-opacity"
|
||||||
|
:class="selectMode ? 'opacity-100' : 'opacity-0'">
|
||||||
|
<label class="checkbox checkbox-sm checkbox-accent">
|
||||||
|
<input type="checkbox"
|
||||||
|
x-model="selectedEps"
|
||||||
|
value="{{ ep.url }}"
|
||||||
|
@change="$event.target.checked ? selectedEps.add('{{ ep.url }}') : selectedEps.delete('{{ ep.url }}')"
|
||||||
|
:checked="selectedEps.has('{{ ep.url }}')">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span class="font-bold text-primary w-12 shrink-0">EP {{ ep.episode_number or loop.index }}</span>
|
<span class="font-bold text-primary w-12 shrink-0">EP {{ ep.episode_number or loop.index }}</span>
|
||||||
<span class="flex-1 truncate text-base-content/80 font-medium"
|
<span class="flex-1 truncate text-base-content/80 font-medium text-sm"
|
||||||
title="{{ ep.title or 'Épisode ' ~ (ep.episode_number or loop.index) }}">
|
title="{{ ep.title or 'Épisode ' ~ (ep.episode_number or loop.index) }}">
|
||||||
{{ ep.title or 'Épisode ' ~ (ep.episode_number or loop.index) }}
|
{{ ep.title or 'Épisode ' ~ (ep.episode_number or loop.index) }}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex gap-2 shrink-0">
|
<div class="flex gap-2 shrink-0">
|
||||||
<button class="btn btn-xs btn-primary"
|
<button class="btn btn-xs btn-primary"
|
||||||
hx-get="/api/player/embed?url={{ ep.url | urlencode }}"
|
hx-get="/api/player/embed?url={{ ep.url | urlencode }}"
|
||||||
hx-target="#video-player-display"
|
hx-target="#video-player-display"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
onclick="document.getElementById('video-player-display').scrollIntoView({behavior: 'smooth'})">
|
onclick="document.getElementById('video-player-display').scrollIntoView({behavior: 'smooth'})">
|
||||||
<i class="fas fa-play"></i> Regarder
|
<i class="fas fa-play"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-xs btn-ghost"
|
<button class="btn btn-xs btn-outline btn-success gap-1"
|
||||||
hx-post="/api/anime/download?url={{ ep.url | urlencode }}"
|
hx-post="/api/anime/download?url={{ ep.url | urlencode }}"
|
||||||
hx-swap="none"
|
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 cet épisode">
|
title="Télécharger cet épisode">
|
||||||
<i class="fas fa-download"></i>
|
<i class="fas fa-download"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -85,3 +154,52 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('episodeListActions', () => ({
|
||||||
|
downloadSelected() {
|
||||||
|
if (this.selectedEps.size === 0) return;
|
||||||
|
this.downloadingSeason = true;
|
||||||
|
let completed = 0;
|
||||||
|
const total = this.selectedEps.size;
|
||||||
|
const urls = [...this.selectedEps];
|
||||||
|
|
||||||
|
Promise.allSettled(urls.map(url =>
|
||||||
|
fetch(`/api/anime/download?url=${encodeURIComponent(url)}`, { method: 'POST' })
|
||||||
|
.then(r => { completed++; return r; })
|
||||||
|
)).then(() => {
|
||||||
|
this.downloadingSeason = false;
|
||||||
|
this.selectedEps.clear();
|
||||||
|
this.selectMode = false;
|
||||||
|
showToast(`${completed} téléchargement${completed > 1 ? 's' : ''} lancé${completed > 1 ? 's' : ''}`);
|
||||||
|
htmx.trigger('#downloads-container-inner', 'refresh');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
downloadFullSeason() {
|
||||||
|
this.downloadingSeason = true;
|
||||||
|
const card = document.getElementById('episode-list-card');
|
||||||
|
const downloadBtns = card.querySelectorAll('[hx-post*="/api/anime/download"]');
|
||||||
|
let completed = 0;
|
||||||
|
const total = downloadBtns.length;
|
||||||
|
|
||||||
|
Promise.allSettled([...downloadBtns].map(btn => {
|
||||||
|
const url = new URLSearchParams(btn.getAttribute('hx-post').split('?')[1]).get('url');
|
||||||
|
return fetch(`/api/anime/download?url=${encodeURIComponent(url)}`, { method: 'POST' })
|
||||||
|
.then(r => { completed++; return r; });
|
||||||
|
})).then(() => {
|
||||||
|
this.downloadingSeason = false;
|
||||||
|
showToast(`${total} épisodes mis en file de téléchargement`);
|
||||||
|
htmx.trigger('#downloads-container-inner', 'refresh');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toast notification helper — uses the Alpine.js toast system in toast_container.html
|
||||||
|
// Already defined globally in settings.js, this is a fallback
|
||||||
|
function showToast(message, type = 'success') {
|
||||||
|
const ev = new CustomEvent('show-toast', { detail: { message, type } });
|
||||||
|
(window.dispatchEvent || document.dispatchEvent).call(window, ev);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
{% set _key = item.title | lower | trim %}
|
{% set _key = item.title | lower | trim %}
|
||||||
{% if _key not in _groups.items %}
|
{% if _key not in _groups.items %}
|
||||||
{% set _ = _groups.items.update({_key: {
|
{% set _ = _groups.items.update({
|
||||||
"title": item.title,
|
"title": item.title,
|
||||||
"cover": item.cover_image or "",
|
"cover": item.cover_image or "",
|
||||||
"synopsis": (item.metadata.synopsis if item.metadata and item.metadata.synopsis else ""),
|
"synopsis": (item.metadata.synopsis if item.metadata and item.metadata.synopsis else ""),
|
||||||
"providers": [{ "id": item.provider_id or pid, "url": item.url }]
|
"providers": [{ "id": item.provider_id or pid, "url": item.url }]
|
||||||
}}) %}
|
}) %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% set _existing = _groups.items[_key] %}
|
{% set _existing = _groups.items[_key] %}
|
||||||
{% if not _existing.cover and item.cover_image %}
|
{% if not _existing.cover and item.cover_image %}
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
{% set first_url = group.providers[0].url %}
|
{% set first_url = group.providers[0].url %}
|
||||||
<div class="card bg-base-200 border border-base-300 hover:border-primary transition-colors">
|
<div class="card bg-base-200 border border-base-300 hover:border-primary transition-colors">
|
||||||
<div class="card-body p-5 flex-row gap-5">
|
<div class="card-body p-5 flex-row gap-5">
|
||||||
|
<!-- Poster -->
|
||||||
<figure class="w-28 shrink-0">
|
<figure class="w-28 shrink-0">
|
||||||
<a href="{{ first_url }}" target="_blank" rel="noopener">
|
<a href="{{ first_url }}" target="_blank" rel="noopener">
|
||||||
<img src="{{ group.cover or 'https://placehold.co/240x360/29274c/7e52a0?text=No+Image' }}"
|
<img src="{{ group.cover or 'https://placehold.co/240x360/29274c/7e52a0?text=No+Image' }}"
|
||||||
@@ -35,7 +36,10 @@
|
|||||||
onerror="this.src='https://placehold.co/240x360/29274c/7e52a0?text=Error'; this.onerror=null;">
|
onerror="this.src='https://placehold.co/240x360/29274c/7e52a0?text=Error'; this.onerror=null;">
|
||||||
</a>
|
</a>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
<div class="flex-1 min-w-0 flex flex-col gap-2">
|
<div class="flex-1 min-w-0 flex flex-col gap-2">
|
||||||
|
<!-- Title -->
|
||||||
<div class="flex items-baseline gap-3">
|
<div class="flex items-baseline gap-3">
|
||||||
<h3 class="card-title text-base truncate">{{ group.title }}</h3>
|
<h3 class="card-title text-base truncate">{{ group.title }}</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,6 +48,7 @@
|
|||||||
<p class="text-sm text-base-content/60 line-clamp-3">{{ group.synopsis }}</p>
|
<p class="text-sm text-base-content/60 line-clamp-3">{{ group.synopsis }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Provider badges -->
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
{% for p in group.providers %}
|
{% for p in group.providers %}
|
||||||
<a href="{{ p.url }}" target="_blank" rel="noopener"
|
<a href="{{ p.url }}" target="_blank" rel="noopener"
|
||||||
@@ -53,41 +58,71 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
<div class="flex flex-wrap gap-2 mt-1">
|
<div class="flex flex-wrap gap-2 mt-1">
|
||||||
|
<!-- Watch -->
|
||||||
<a href="{{ first_url }}" target="_blank" rel="noopener"
|
<a href="{{ first_url }}" target="_blank" rel="noopener"
|
||||||
class="btn btn-sm btn-primary">
|
class="btn btn-sm btn-primary">
|
||||||
<i class="fas fa-play"></i> Regarder
|
<i class="fas fa-play"></i> Regarder
|
||||||
</a>
|
</a>
|
||||||
<div class="dropdown" @click.outside="openDropdown = null">
|
|
||||||
|
<!-- Download dropdown -->
|
||||||
|
<div class="dropdown dropdown-end" @click.outside="openDropdown = null">
|
||||||
<div tabindex="0" role="button"
|
<div tabindex="0" role="button"
|
||||||
@click.stop="openDropdown = (openDropdown === '{{ first_url | urlencode }}') ? null : '{{ first_url | urlencode }}'">
|
@click.stop="openDropdown = (openDropdown === '{{ first_url | urlencode }}') ? null : '{{ first_url | urlencode }}'">
|
||||||
<span class="btn btn-sm btn-secondary">
|
<span class="btn btn-sm btn-success">
|
||||||
<i class="fas fa-download"></i> Telecharger <i class="fas fa-chevron-down text-[0.6rem] ml-1"></i>
|
<i class="fas fa-arrow-down"></i> Télécharger <i class="fas fa-chevron-down text-[0.6rem] ml-1"></i>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ul tabindex="0"
|
<ul tabindex="0"
|
||||||
class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52 border border-base-300"
|
class="dropdown-content z-[1] menu p-2 shadow-xl bg-base-300 rounded-xl w-64 border border-base-300 mt-2"
|
||||||
x-show="openDropdown === '{{ first_url | urlencode }}'"
|
x-show="openDropdown === '{{ first_url | urlencode }}'"
|
||||||
x-transition>
|
x-transition:enter="transition ease-out duration-100"
|
||||||
|
x-transition:enter-start="opacity-0 scale-95"
|
||||||
|
x-transition:enter-end="opacity-100 scale-100"
|
||||||
|
x-transition:leave="transition ease-in duration-75"
|
||||||
|
x-transition:leave-start="opacity-100 scale-100"
|
||||||
|
x-transition:leave-end="opacity-0 scale-95">
|
||||||
|
<!-- Full season -->
|
||||||
<li>
|
<li>
|
||||||
<button class="flex items-center gap-2 text-sm"
|
<button class="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors"
|
||||||
hx-post="/api/anime/download-season?url={{ first_url | urlencode }}&lang={{ default_lang }}"
|
hx-post="/api/anime/download-season?url={{ first_url | urlencode }}&lang={{ default_lang }}"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on::after-request="openDropdown = null">
|
hx-on::before-request="this.querySelector('.dl-icon').classList.add('hidden'); this.querySelector('.dl-spinner').classList.remove('hidden')"
|
||||||
<i class="fas fa-layer-group"></i> Saison complete
|
hx-on::after-request="if(event.detail.successful){showToast('Saison complète mise en file'); this.querySelector('.dl-icon').classList.remove('hidden'); this.querySelector('.dl-spinner').classList.add('hidden'); openDropdown = null}">
|
||||||
|
<span class="w-8 h-8 rounded-lg bg-success/20 text-success flex items-center justify-center shrink-0">
|
||||||
|
<i class="fas fa-layer-group dl-icon text-sm"></i>
|
||||||
|
<span class="loading loading-spinner loading-xs dl-spinner hidden"></span>
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-col text-left">
|
||||||
|
<span class="text-sm font-medium">Saison complète</span>
|
||||||
|
<span class="text-xs text-base-content/50">Tous les épisodes d'un coup</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button class="flex items-center gap-2 text-sm"
|
<div class="divider my-1 before:bg-base-content/10 after:bg-base-content/10"></div>
|
||||||
|
</li>
|
||||||
|
<!-- Choose episodes -->
|
||||||
|
<li>
|
||||||
|
<button class="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors"
|
||||||
hx-get="/api/anime/episodes?url={{ first_url | urlencode }}&lang={{ default_lang }}&html=1"
|
hx-get="/api/anime/episodes?url={{ first_url | urlencode }}&lang={{ default_lang }}&html=1"
|
||||||
hx-target="#player-container"
|
hx-target="#player-container"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-on::after-request="openDropdown = null; document.getElementById('player-container').scrollIntoView({behavior: 'smooth'})">
|
hx-on::after-request="openDropdown = null; document.getElementById('player-container').scrollIntoView({behavior: 'smooth'})">
|
||||||
<i class="fas fa-list-ol"></i> Choisir des episodes
|
<span class="w-8 h-8 rounded-lg bg-primary/20 text-primary flex items-center justify-center shrink-0">
|
||||||
|
<i class="fas fa-list-ol text-sm"></i>
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-col text-left">
|
||||||
|
<span class="text-sm font-medium">Choisir des épisodes</span>
|
||||||
|
<span class="text-xs text-base-content/50">Sélectionnez ce que vous voulez</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Follow -->
|
||||||
<button class="btn btn-sm btn-accent btn-outline"
|
<button class="btn btn-sm btn-accent btn-outline"
|
||||||
hx-post="/api/watchlist"
|
hx-post="/api/watchlist"
|
||||||
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}", "poster_image": "{{ group.cover }}"}'
|
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}", "poster_image": "{{ group.cover }}"}'
|
||||||
|
|||||||
Reference in New Issue
Block a user