Files
ohm_streaming/templates/components/episode_list.html
T
root 819acf04f8 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)
2026-04-11 21:08:29 +00:00

206 lines
12 KiB
HTML

<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 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 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' }" title="Liste">
<i class="fas fa-list"></i>
</button>
<!-- 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>
</div>
<!-- Video player display area -->
<div id="video-player-display" x-ref="playerArea"></div>
<!-- Episodes content -->
{% if episodes %}
<!-- Grid View -->
<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-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>
{% 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-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>
</div>
{% endfor %}
</div>
</div>
<!-- List View -->
<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-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 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 }}"
hx-target="#video-player-display"
hx-swap="innerHTML"
onclick="document.getElementById('video-player-display').scrollIntoView({behavior: 'smooth'})">
<i class="fas fa-play"></i>
</button>
<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>
</div>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="text-center py-12 text-base-content/40">
<i class="fas fa-exclamation-circle text-3xl mb-3 block"></i>
<p>Aucun épisode trouvé pour cette source.</p>
</div>
{% 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>