Phase 3: HTMX & Alpine.js integration, router refactoring, and UI modernization
CI / Test (Python 3.11) (push) Has been cancelled
CI / Test (Python 3.12) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Summary (push) Has been cancelled

- 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:
root
2026-03-26 10:34:26 +00:00
parent a684237725
commit 9f85908ff3
31 changed files with 3413 additions and 2201 deletions
+6 -6
View File
@@ -18,17 +18,17 @@
[x-cloak] { display: none !important; }
</style>
<!-- Legacy JavaScript (To be refactored) -->
<!-- Legacy JavaScript (Refactored to HTMX/Alpine) -->
<script src="/static/js/auth.js?v=1.10" defer></script>
<script src="/static/js/api.js?v=1.11" defer></script>
<script src="/static/js/utils.js?v=1.11" defer></script>
<script src="/static/js/downloads.js?v=1.11" defer></script>
<script src="/static/js/anime.js?v=1.11" defer></script>
<script src="/static/js/anime-details.js?v=1.12" defer></script>
<script src="/static/js/series-search.js?v=1.11" defer></script>
<script src="/static/js/recommendations.js?v=1.11" defer></script>
<!-- <script src="/static/js/anime.js?v=1.11" defer></script> -->
<!-- <script src="/static/js/anime-details.js?v=1.12" defer></script> -->
<!-- <script src="/static/js/series-search.js?v=1.11" defer></script> -->
<!-- <script src="/static/js/recommendations.js?v=1.11" defer></script> -->
<script src="/static/js/watchlist.js?v=1.11" defer></script>
<script src="/static/js/watchlist-ui.js?v=1.11" defer></script>
<!-- <script src="/static/js/watchlist-ui.js?v=1.11" defer></script> -->
<script src="/static/js/main.js?v=1.11" defer></script>
</head>
<body x-data="globalAppState">
+52 -25
View File
@@ -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 %}
+9 -13
View File
@@ -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>
+131
View File
@@ -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>
+29 -24
View File
@@ -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>
+77
View File
@@ -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 %}
+11
View File
@@ -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 %}
+57
View File
@@ -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>
+25 -8
View File
@@ -18,6 +18,7 @@
<form hx-get="/api/anime/search"
hx-target="#animeSearchResults"
hx-indicator="#search-loading"
hx-trigger="submit, keyup changed delay:500ms from:#animeSearchInput"
class="input-group">
<input type="hidden" name="html" value="1">
<input
@@ -53,14 +54,16 @@
<!-- Latest Releases Section -->
<div class="section-header">
<h2>🔥 Dernières sorties Anime</h2>
<button class="btn-small btn-secondary" onclick="loadAnimeReleases()">
<button class="btn-small btn-secondary"
hx-get="/api/releases/latest"
hx-target="#animeReleasesList">
<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>
Dernières sorties
</button>
</div>
<div id="animeReleasesList" class="recommendations-carousel"></div>
<div id="animeReleasesList" class="recommendations-carousel" hx-get="/api/releases/latest" hx-trigger="load delay:500ms"></div>
</div>
<div id="tab-series" class="tab-content" x-show="activeTab === 'series'">
@@ -69,18 +72,28 @@
<h2>📺 Rechercher une Série TV</h2>
</div>
<div class="url-form">
<div class="input-group">
<form hx-get="/api/series/search"
hx-target="#seriesSearchResults"
hx-indicator="#series-search-loading"
hx-trigger="submit, keyup changed delay:500ms from:#seriesSearchInput"
class="input-group">
<input type="hidden" name="html" value="1">
<input
type="text"
name="q"
id="seriesSearchInput"
placeholder="Rechercher une série (ex: Breaking Bad, Game of Thrones...)"
required
>
<button type="button" class="btn-primary" onclick="handleSeriesSearch()">
<button type="submit" class="btn-primary">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
Rechercher
</button>
</form>
<div id="series-search-loading" class="htmx-indicator">
<div class="spinner"></div> Recherche en cours...
</div>
<div style="margin-top: 10px; padding: 10px; background: rgba(255, 107, 107, 0.1); border-radius: 8px; font-size: 12px; color: #aaa;">
💡 <strong>Info:</strong> La recherche utilise FS7 pour trouver des séries TV américaines et européennes
@@ -95,26 +108,30 @@
<!-- Recommendations Section -->
<div class="section-header">
<h2>🎯 Recommandé pour vous</h2>
<button class="btn-small btn-secondary" onclick="loadSeriesRecommendations()">
<button class="btn-small btn-secondary"
hx-get="/api/recommendations"
hx-target="#seriesRecommendationsList">
<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>
</div>
<div id="seriesRecommendationsList" class="recommendations-carousel" style="margin-bottom: 40px;"></div>
<div id="seriesRecommendationsList" class="recommendations-carousel" style="margin-bottom: 40px;" hx-get="/api/recommendations" hx-trigger="load delay:600ms"></div>
<!-- Latest Releases Section -->
<div class="section-header" style="margin-top: 40px;">
<h2>🔥 Dernières sorties Séries TV</h2>
<button class="btn-small btn-secondary" onclick="loadSeriesReleases()">
<button class="btn-small btn-secondary"
hx-get="/api/releases/latest"
hx-target="#seriesReleasesList">
<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>
Dernières sorties
</button>
</div>
<div id="seriesReleasesList" class="releases-carousel"></div>
<div id="seriesReleasesList" class="releases-carousel" hx-get="/api/releases/latest" hx-trigger="load delay:700ms"></div>
</div>
<div id="tab-providers" class="tab-content" x-show="activeTab === 'providers'">