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
+1 -1
View File
File diff suppressed because one or more lines are too long
+53 -7
View File
@@ -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&#39;original';
} else {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
console.error('Translation API error:', errorData);
+4 -4
View File
@@ -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
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;
+4 -4
View File
@@ -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>
+49 -13
View File
@@ -5,14 +5,14 @@
{% for item in items %}
{% set _key = item.title | lower | trim %}
{% if _key not in _groups.items %}
{% set _ = _groups.items.update({_key: {
{% set _ = _groups.items.update({
"title": item.title,
"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 ""),
"rating": item.metadata.rating if item.metadata and item.metadata.rating else "",
"genres": item.metadata.genres if item.metadata and item.metadata.genres else [],
"providers": [{ "id": item.provider_id or pid, "url": item.url }]
}}) %}
}) %}
{% else %}
{% set _existing = _groups.items[_key] %}
{% if not _existing.cover and item.cover_image %}
@@ -35,6 +35,7 @@
{% 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-body p-5 flex-row gap-5">
<!-- Poster -->
<figure class="w-28 shrink-0">
<a href="{{ first_url }}" target="_blank" rel="noopener">
<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;">
</a>
</figure>
<!-- Content -->
<div class="flex-1 min-w-0 flex flex-col gap-2">
<!-- Title + rating -->
<div class="flex items-baseline gap-3">
<h3 class="card-title text-base truncate">{{ group.title }}</h3>
{% if group.rating %}
@@ -63,6 +67,7 @@
</div>
{% endif %}
<!-- Provider badges -->
<div class="flex flex-wrap gap-1.5">
{% for p in group.providers %}
<a href="{{ p.url }}" target="_blank" rel="noopener"
@@ -72,41 +77,72 @@
{% endfor %}
</div>
<!-- Action buttons -->
<div class="flex flex-wrap gap-2 mt-1">
<!-- Watch -->
<a href="{{ first_url }}" target="_blank" rel="noopener"
class="btn btn-sm btn-primary">
<i class="fas fa-play"></i> Regarder
</a>
<div class="dropdown" @click.outside="openDropdown = null">
<!-- Download dropdown -->
<div class="dropdown dropdown-end" @click.outside="openDropdown = null">
<div tabindex="0" role="button"
@click.stop="openDropdown = (openDropdown === '{{ first_url | urlencode }}') ? null : '{{ first_url | urlencode }}'">
<span class="btn btn-sm btn-secondary">
<i class="fas fa-download"></i> Telecharger <i class="fas fa-chevron-down text-[0.6rem] ml-1"></i>
@click.stop="openDropdown = (openDropdown === '{{ first_url | urlencode }}') ? null : '{{ first_url | urlencode }}'"
x-ref="dlToggle-{{ loop.index0 }}">
<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>
</div>
<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-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>
<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-swap="none"
hx-on::after-request="openDropdown = null">
<i class="fas fa-layer-group"></i> Saison complete
hx-on::before-request="this.querySelector('.dl-icon').classList.add('hidden'); this.querySelector('.dl-spinner').classList.remove('hidden')"
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>
</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-target="#player-container"
hx-swap="innerHTML"
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>
</li>
</ul>
</div>
<!-- Follow -->
<button class="btn btn-sm btn-accent btn-outline"
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 }}"}'
+135 -17
View File
@@ -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 -->
<div class="card-body p-6">
<div class="flex justify-between items-center">
<div class="flex items-center gap-3">
<div class="flex flex-col sm:flex-row justify-between items-start sm: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>
<span class="badge badge-outline">{{ episodes|length }} épisodes disponibles</span>
</div>
<div class="flex gap-2">
<button class="btn btn-circle btn-sm btn-ghost" @click="view = 'grid'" :class="{ 'btn-primary': view === 'grid' }">
<div class="flex gap-2 flex-wrap">
<!-- 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>
</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>
</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>
</button>
</div>
@@ -28,18 +63,38 @@
<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">
{% 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>
<button class="btn btn-xs btn-primary w-full"
hx-get="/api/player/embed?url={{ ep.url | urlencode }}"
{% if ep.title %}
<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-swap="innerHTML"
onclick="document.getElementById('video-player-display').scrollIntoView({behavior: 'smooth'})">
<i class="fas fa-play"></i> Regarder
</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-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">
<i class="fas fa-download"></i> Télécharger
</button>
@@ -52,23 +107,37 @@
<div x-show="view === 'list'" x-transition class="mt-6">
<div class="flex flex-col gap-2">
{% 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="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) }}">
{{ ep.title or 'Épisode ' ~ (ep.episode_number or loop.index) }}
</span>
<div class="flex gap-2 shrink-0">
<button class="btn btn-xs btn-primary"
hx-get="/api/player/embed?url={{ ep.url | urlencode }}"
<button class="btn btn-xs btn-primary"
hx-get="/api/player/embed?url={{ ep.url | urlencode }}"
hx-target="#video-player-display"
hx-swap="innerHTML"
onclick="document.getElementById('video-player-display').scrollIntoView({behavior: 'smooth'})">
<i class="fas fa-play"></i> Regarder
<i class="fas fa-play"></i>
</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-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">
<i class="fas fa-download"></i>
</button>
@@ -85,3 +154,52 @@
{% endif %}
</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>
+47 -12
View File
@@ -5,12 +5,12 @@
{% for item in items %}
{% set _key = item.title | lower | trim %}
{% if _key not in _groups.items %}
{% set _ = _groups.items.update({_key: {
{% set _ = _groups.items.update({
"title": item.title,
"cover": item.cover_image or "",
"synopsis": (item.metadata.synopsis if item.metadata and item.metadata.synopsis else ""),
"providers": [{ "id": item.provider_id or pid, "url": item.url }]
}}) %}
}) %}
{% else %}
{% set _existing = _groups.items[_key] %}
{% if not _existing.cover and item.cover_image %}
@@ -27,6 +27,7 @@
{% 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-body p-5 flex-row gap-5">
<!-- Poster -->
<figure class="w-28 shrink-0">
<a href="{{ first_url }}" target="_blank" rel="noopener">
<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;">
</a>
</figure>
<!-- Content -->
<div class="flex-1 min-w-0 flex flex-col gap-2">
<!-- Title -->
<div class="flex items-baseline gap-3">
<h3 class="card-title text-base truncate">{{ group.title }}</h3>
</div>
@@ -44,6 +48,7 @@
<p class="text-sm text-base-content/60 line-clamp-3">{{ group.synopsis }}</p>
{% endif %}
<!-- Provider badges -->
<div class="flex flex-wrap gap-1.5">
{% for p in group.providers %}
<a href="{{ p.url }}" target="_blank" rel="noopener"
@@ -53,41 +58,71 @@
{% endfor %}
</div>
<!-- Action buttons -->
<div class="flex flex-wrap gap-2 mt-1">
<!-- Watch -->
<a href="{{ first_url }}" target="_blank" rel="noopener"
class="btn btn-sm btn-primary">
<i class="fas fa-play"></i> Regarder
</a>
<div class="dropdown" @click.outside="openDropdown = null">
<!-- Download dropdown -->
<div class="dropdown dropdown-end" @click.outside="openDropdown = null">
<div tabindex="0" role="button"
@click.stop="openDropdown = (openDropdown === '{{ first_url | urlencode }}') ? null : '{{ first_url | urlencode }}'">
<span class="btn btn-sm btn-secondary">
<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>
</div>
<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-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>
<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-swap="none"
hx-on::after-request="openDropdown = null">
<i class="fas fa-layer-group"></i> Saison complete
hx-on::before-request="this.querySelector('.dl-icon').classList.add('hidden'); this.querySelector('.dl-spinner').classList.remove('hidden')"
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>
</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-target="#player-container"
hx-swap="innerHTML"
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>
</li>
</ul>
</div>
<!-- Follow -->
<button class="btn btn-sm btn-accent btn-outline"
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 }}"}'