Phase 3: HTMX & Alpine.js integration, router refactoring, and UI modernization
- Modernized the frontend with HTMX for server-driven UI and Alpine.js for client state. - Refactored anime, player, and recommendation logic into modular routers. - Updated README.md to reflect the latest project state and technologies (v2.4). - Added Plyr.io for an improved streaming experience. - Improved project structure with componentized templates. - Added Playwright and Vitest configuration for frontend testing.
This commit is contained in:
@@ -1,43 +1,70 @@
|
||||
{% macro anime_card(anime, in_watchlist=False) %}
|
||||
<div class="anime-card" id="anime-{{ anime.url | hash }}">
|
||||
<div class="anime-poster">
|
||||
<img src="{{ anime.cover_image or anime.metadata.poster_image or '/static/img/no-poster.png' }}"
|
||||
{% set poster = anime.cover_image or (anime.metadata.poster_image if anime.metadata else None) or 'https://placehold.co/400x600/161625/00d9ff?text=No+Image' %}
|
||||
<img src="{{ poster }}"
|
||||
alt="{{ anime.title }}"
|
||||
loading="lazy">
|
||||
<div class="anime-overlay">
|
||||
<button class="btn-play"
|
||||
hx-get="/api/anime/episodes?url={{ anime.url | urlencode }}"
|
||||
hx-target="#player-container"
|
||||
hx-swap="innerHTML">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
</div>
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
onerror="this.src='https://placehold.co/400x600/161625/00d9ff?text=Image+Error'; this.onerror=null;">
|
||||
|
||||
{% if anime.metadata and anime.metadata.rating %}
|
||||
<div class="anime-rating">{{ anime.metadata.rating }}</div>
|
||||
<div class="anime-rating-badge">
|
||||
<i class="fas fa-star"></i> {{ anime.metadata.rating }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="anime-overlay">
|
||||
<div class="overlay-buttons">
|
||||
<button class="btn-circle"
|
||||
hx-get="/api/anime/episodes?url={{ anime.url | urlencode }}"
|
||||
hx-target="#player-container"
|
||||
hx-swap="innerHTML"
|
||||
title="Play">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="anime-info">
|
||||
<h3 class="anime-title" title="{{ anime.title }}">{{ anime.title }}</h3>
|
||||
<div class="anime-meta">
|
||||
<span class="badge badge-provider">{{ anime.provider_id or 'unknown' }}</span>
|
||||
|
||||
<div class="anime-meta-tags">
|
||||
<span class="badge">{{ anime.provider_id or 'Anime' }}</span>
|
||||
{% if anime.metadata and anime.metadata.status %}
|
||||
<span class="badge badge-status">{{ anime.metadata.status }}</span>
|
||||
<span class="badge" style="color: var(--primary)">{{ anime.metadata.status }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="anime-actions">
|
||||
{% if not in_watchlist %}
|
||||
<button class="btn btn-sm btn-outline"
|
||||
hx-post="/api/watchlist"
|
||||
hx-vals='{"anime_url": "{{ anime.url }}", "anime_title": "{{ anime.title }}", "provider_id": "{{ anime.provider_id }}"}'
|
||||
hx-swap="none"
|
||||
hx-on::after-request="this.remove()">
|
||||
<i class="fas fa-plus"></i> Watchlist
|
||||
<div class="anime-card-buttons">
|
||||
<button class="btn-card btn-watch"
|
||||
hx-get="/api/anime/episodes?url={{ anime.url | urlencode }}"
|
||||
hx-target="#player-container"
|
||||
hx-swap="innerHTML">
|
||||
<i class="fas fa-eye"></i> <span>Regarder</span>
|
||||
</button>
|
||||
<button class="btn-card btn-download"
|
||||
hx-get="/api/anime/episodes?url={{ anime.url | urlencode }}"
|
||||
hx-target="#player-container"
|
||||
hx-swap="innerHTML">
|
||||
<i class="fas fa-download"></i> <span>Télécharger</span>
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="text-muted small"><i class="fas fa-check"></i> Dans la watchlist</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if not in_watchlist %}
|
||||
<button class="btn-add-watchlist"
|
||||
hx-post="/api/watchlist"
|
||||
hx-vals='{"anime_url": "{{ anime.url }}", "anime_title": "{{ anime.title }}", "provider_id": "{{ anime.provider_id }}"}'
|
||||
hx-swap="none"
|
||||
hx-on::after-request="this.innerHTML='<i class=\'fas fa-check\'></i> Suivi'; this.disabled=true; this.classList.add('followed')">
|
||||
<i class="fas fa-plus"></i> Watchlist
|
||||
</button>
|
||||
{% else %}
|
||||
<button class="btn-add-watchlist followed" disabled>
|
||||
<i class="fas fa-check"></i> Suivi
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -15,28 +15,24 @@
|
||||
{% else %}
|
||||
<div class="no-results">
|
||||
<i class="fas fa-search"></i>
|
||||
<p>Aucun résultat trouvé pour votre recherche.</p>
|
||||
<p>Aucun anime trouvé pour votre recherche.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.provider-section { margin-bottom: 30px; }
|
||||
.provider-section { margin-bottom: 40px; }
|
||||
.provider-title {
|
||||
border-bottom: 2px solid #00d9ff;
|
||||
padding-bottom: 5px;
|
||||
margin-bottom: 15px;
|
||||
color: var(--primary);
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.anime-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 20px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: 50px;
|
||||
color: #aaa;
|
||||
padding: 100px 20px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.no-results i { font-size: 3rem; margin-bottom: 10px; display: block; }
|
||||
.no-results i { font-size: 4rem; margin-bottom: 20px; display: block; opacity: 0.2; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<div class="episode-list-container card" x-data="{ view: 'grid' }">
|
||||
<div class="episode-header">
|
||||
<div class="header-info">
|
||||
<h3>{{ anime_title }}</h3>
|
||||
<span class="episode-count">{{ episodes|length }} épisodes disponibles</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-icon" @click="view = 'grid'" :class="{ 'active': view === 'grid' }">
|
||||
<i class="fas fa-th"></i>
|
||||
</button>
|
||||
<button class="btn btn-icon" @click="view = 'list'" :class="{ 'active': view === 'list' }">
|
||||
<i class="fas fa-list"></i>
|
||||
</button>
|
||||
<button class="btn btn-close" onclick="document.getElementById('player-container').innerHTML = ''">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="episodes-content" :class="'view-' + view">
|
||||
{% if episodes %}
|
||||
{% for ep in episodes %}
|
||||
<div class="episode-item">
|
||||
<div class="ep-number">EP {{ ep.episode_number or loop.index }}</div>
|
||||
<div class="ep-title" title="{{ ep.title or 'Épisode ' ~ (ep.episode_number or loop.index) }}">
|
||||
{{ ep.title or 'Épisode ' ~ (ep.episode_number or loop.index) }}
|
||||
</div>
|
||||
<div class="ep-actions">
|
||||
<button class="btn-play-small"
|
||||
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-download-small"
|
||||
hx-post="/api/anime/download?url={{ ep.url | urlencode }}"
|
||||
hx-swap="none">
|
||||
<i class="fas fa-download"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="empty-msg">Aucun épisode trouvé pour ce lien.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Zone d'affichage du player vidéo -->
|
||||
<div id="video-player-display"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.episode-list-container {
|
||||
margin-top: 20px;
|
||||
background: #1e1e2e;
|
||||
border: 1px solid #333;
|
||||
padding: 20px;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
.episode-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
.episode-header h3 { margin: 0; color: #00d9ff; }
|
||||
.episode-count { font-size: 0.8rem; color: #888; }
|
||||
|
||||
.episodes-content.view-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.view-grid .episode-item {
|
||||
background: #252538;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.view-grid .episode-item:hover { background: #2d2d4a; transform: translateY(-2px); }
|
||||
.view-grid .ep-title { display: none; }
|
||||
.view-grid .ep-number { font-weight: bold; font-size: 1.2rem; margin-bottom: 10px; }
|
||||
|
||||
.episodes-content.view-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.view-list .episode-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
background: #252538;
|
||||
padding: 10px 15px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.view-list .ep-number { font-weight: bold; width: 50px; }
|
||||
.view-list .ep-title { flex: 1; color: #ccc; }
|
||||
|
||||
.btn-play-small {
|
||||
background: #00d9ff;
|
||||
color: #000;
|
||||
border: none;
|
||||
padding: 5px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
.btn-download-small {
|
||||
background: transparent;
|
||||
color: #888;
|
||||
border: 1px solid #444;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-download-small:hover { color: #fff; border-color: #fff; }
|
||||
|
||||
#video-player-display:not(:empty) {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 2px dashed #333;
|
||||
}
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
</style>
|
||||
@@ -1,36 +1,41 @@
|
||||
<!-- Home Section: Recommendations & Latest Releases -->
|
||||
<div id="tab-home" class="tab-content"
|
||||
x-show="activeTab === 'home'"
|
||||
x-init="if (activeTab === 'home') setTimeout(() => loadHomeContent(), 500)"
|
||||
@set-tab.window="if ($event.detail.tab === 'home') loadHomeContent()">
|
||||
<!-- Loading State -->
|
||||
<div id="homeLoading" class="loading-spinner">Chargement des recommandations...</div>
|
||||
<!-- Home Section: Premium Layout -->
|
||||
<div id="tab-home" class="tab-content" x-show="activeTab === 'home'">
|
||||
|
||||
<!-- Hero / Featured area could go here later -->
|
||||
|
||||
<!-- Recommendations Section -->
|
||||
<div id="recommendationsSection" style="display: none;">
|
||||
<!-- Recommendations Row -->
|
||||
<div class="section-container">
|
||||
<div class="section-header">
|
||||
<h2>🎯 Recommandé pour vous</h2>
|
||||
<button class="btn-small btn-secondary" onclick="loadRecommendations()">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
Actualiser
|
||||
<button class="btn-secondary btn-small"
|
||||
hx-get="/api/recommendations"
|
||||
hx-target="#recommendationsList">
|
||||
<i class="fas fa-sync-alt"></i> Actualiser
|
||||
</button>
|
||||
</div>
|
||||
<div id="recommendationsList" class="recommendations-carousel"></div>
|
||||
<div id="recommendationsList"
|
||||
hx-get="/api/recommendations"
|
||||
hx-trigger="load delay:100ms"
|
||||
class="streaming-row">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Latest Releases Section -->
|
||||
<div id="releasesSection" style="display: none; margin-top: 40px;">
|
||||
<!-- Latest Releases Row -->
|
||||
<div class="section-container">
|
||||
<div class="section-header">
|
||||
<h2>🔥 Dernières sorties de la saison</h2>
|
||||
<button class="btn-small btn-secondary" onclick="loadLatestReleases()">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
Actualiser
|
||||
<h2>🔥 Dernières sorties</h2>
|
||||
<button class="btn-secondary btn-small"
|
||||
hx-get="/api/releases/latest"
|
||||
hx-target="#releasesList">
|
||||
<i class="fas fa-sync-alt"></i> Actualiser
|
||||
</button>
|
||||
</div>
|
||||
<div id="releasesList" class="releases-carousel"></div>
|
||||
<div id="releasesList"
|
||||
hx-get="/api/releases/latest"
|
||||
hx-trigger="load delay:300ms"
|
||||
class="streaming-row">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<div class="player-embed-box"
|
||||
x-data="{
|
||||
initPlayer() {
|
||||
if (!this.$refs.player) return;
|
||||
const player = new Plyr(this.$refs.player, {
|
||||
captions: { active: true, update: true, language: 'auto' },
|
||||
speed: { selected: 1, options: [0.5, 0.75, 1, 1.25, 1.5, 2] }
|
||||
});
|
||||
console.log('Plyr initialized');
|
||||
}
|
||||
}"
|
||||
x-init="initPlayer()">
|
||||
|
||||
{% if is_iframe %}
|
||||
<div class="iframe-container">
|
||||
<iframe src="{{ video_url }}"
|
||||
allowfullscreen
|
||||
webkitallowfullscreen
|
||||
mozallowfullscreen></iframe>
|
||||
</div>
|
||||
<div class="player-info-hint">
|
||||
💡 Lecteur externe utilisé. Les contrôles dépendent de l'hébergeur.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="video-wrapper">
|
||||
<video x-ref="player" playsinline controls preload="metadata">
|
||||
<source src="{{ video_url }}" type="video/mp4">
|
||||
</video>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="player-footer-actions">
|
||||
<a href="{{ video_url }}" class="btn btn-sm btn-secondary" target="_blank">
|
||||
<i class="fas fa-external-link-alt"></i> Ouvrir sur l'hébergeur
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.player-embed-box {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
}
|
||||
.iframe-container {
|
||||
position: relative;
|
||||
padding-bottom: 56.25%; /* 16:9 Aspect Ratio */
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.iframe-container iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
.video-wrapper {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.player-info-hint {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.player-footer-actions {
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
{% from "components/anime_card.html" import anime_card %}
|
||||
|
||||
{% if recommendations %}
|
||||
{% for anime in recommendations %}
|
||||
{{ anime_card(anime) }}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>Aucune recommandation pour le moment.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% from "components/anime_card.html" import anime_card %}
|
||||
|
||||
{% if releases %}
|
||||
{% for anime in releases %}
|
||||
{{ anime_card(anime) }}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>Aucune sortie récente trouvée.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,57 @@
|
||||
{% macro series_card(series, in_watchlist=False) %}
|
||||
<div class="anime-card" id="series-{{ series.url | hash }}">
|
||||
<div class="anime-poster">
|
||||
<img src="{{ series.cover_image or 'https://placehold.co/400x600/161625/ff6b6b?text=No+Image' }}"
|
||||
alt="{{ series.title }}"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
onerror="this.src='https://placehold.co/400x600/161625/ff6b6b?text=Image+Error'; this.onerror=null;">
|
||||
<div class="anime-overlay">
|
||||
<div class="overlay-buttons">
|
||||
<button class="btn-circle"
|
||||
hx-get="/api/anime/episodes?url={{ series.url | urlencode }}&lang=vf"
|
||||
hx-target="#player-container"
|
||||
hx-swap="innerHTML"
|
||||
title="Play">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="anime-info">
|
||||
<h3 class="anime-title" title="{{ series.title }}">{{ series.title }}</h3>
|
||||
<div class="anime-meta-tags">
|
||||
<span class="badge">FS7</span>
|
||||
</div>
|
||||
|
||||
<div class="anime-card-buttons">
|
||||
<button class="btn-card btn-watch"
|
||||
hx-get="/api/anime/episodes?url={{ series.url | urlencode }}&lang=vf"
|
||||
hx-target="#player-container"
|
||||
hx-swap="innerHTML">
|
||||
<i class="fas fa-eye"></i> <span>Regarder</span>
|
||||
</button>
|
||||
<button class="btn-card btn-download"
|
||||
hx-get="/api/anime/episodes?url={{ series.url | urlencode }}&lang=vf"
|
||||
hx-target="#player-container"
|
||||
hx-swap="innerHTML">
|
||||
<i class="fas fa-download"></i> <span>Télécharger</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if not in_watchlist %}
|
||||
<button class="btn-add-watchlist"
|
||||
hx-post="/api/watchlist"
|
||||
hx-vals='{"anime_url": "{{ series.url }}", "anime_title": "{{ series.title }}", "provider_id": "fs7", "lang": "vf"}'
|
||||
hx-swap="none"
|
||||
hx-on::after-request="this.innerHTML='<i class=\'fas fa-check\'></i> Suivi'; this.disabled=true; this.classList.add('followed')">
|
||||
<i class="fas fa-plus"></i> Watchlist
|
||||
</button>
|
||||
{% else %}
|
||||
<button class="btn-add-watchlist followed" disabled>
|
||||
<i class="fas fa-check"></i> Suivi
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,38 @@
|
||||
{% from "components/series_card.html" import series_card %}
|
||||
|
||||
<div class="search-results-container">
|
||||
{% if results %}
|
||||
{% for provider_id, items in results.items() %}
|
||||
<div class="provider-section">
|
||||
<h3 class="provider-title">{{ provider_id | upper }}</h3>
|
||||
<div class="anime-grid">
|
||||
{% for series in items %}
|
||||
{{ series_card(series) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="no-results">
|
||||
<i class="fas fa-search"></i>
|
||||
<p>Aucune série TV trouvée pour votre recherche.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.provider-section { margin-bottom: 40px; }
|
||||
.provider-title {
|
||||
color: var(--secondary);
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.2rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: 100px 20px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.no-results i { font-size: 4rem; margin-bottom: 20px; display: block; opacity: 0.2; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user