Découvrir v2 : rails par type, exploration par genre
- Rails Nouveautés séparés : 🆕 animés (tri date Kitsu) et 🆕 séries & films (French-Stream) — chaque type garde son rail, plus d'écrasement mutuel - Section 🎭 Explorer : chips type × genre — animés via catégories Kitsu, séries (/<genre>-series-/) et films (/films/<genre>/) via French-Stream ; état dans l'URL (/discover?t=serie&g=medical), partageable - Scraper : browse(media_type, genre) + catalogue YAML surchargeable ; parsing div.short mutualisé avec les nouveautés - Films réels retirés du mode « Animés » (ils suivent le rail séries) - Endpoints /api/discover (latest_anime/latest_serie), /api/discover/genres, /api/discover/browse — parcours filtré par la préférence du compte - Warmup démarrage : latest_by_type()
This commit is contained in:
+155
-14
@@ -4,10 +4,61 @@
|
||||
|
||||
{% block content %}
|
||||
<h1 class="page-title">Découvrir</h1>
|
||||
<p class="page-sub">Nouveautés de tes sources, incontournables et suggestions basées sur tes téléchargements.</p>
|
||||
<p class="page-sub">Explore par genre, nouveautés de tes sources, incontournables et suggestions basées sur tes téléchargements.</p>
|
||||
|
||||
<div x-data="discoverPage()" x-init="load()" x-cloak>
|
||||
|
||||
<!-- ------------------------------------------------ Explorer par genre -->
|
||||
<section class="rail-section explore-section" x-show="!loading && types.length > 0">
|
||||
<h2 class="rail-title">🎭 Explorer
|
||||
<span class="rail-source" x-text="exploreSubtitle"></span>
|
||||
</h2>
|
||||
<div class="explore-filters">
|
||||
<div class="chip-row" role="tablist" aria-label="Type de contenu">
|
||||
<template x-for="t in types" :key="t">
|
||||
<button class="genre-chip" :class="{ active: exploreType === t }" role="tab"
|
||||
:aria-selected="exploreType === t" @click="setType(t)"
|
||||
x-text="typeLabels[t]"></button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="chip-row" role="tablist" aria-label="Genre" x-show="currentGenres.length > 0">
|
||||
<template x-for="g in currentGenres" :key="g.key">
|
||||
<button class="genre-chip" :class="{ active: exploreGenre === g.key }" role="tab"
|
||||
:aria-selected="exploreGenre === g.key" @click="setGenre(g.key)"
|
||||
x-text="g.label"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rail-wrap" x-show="!exploreLoading">
|
||||
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
|
||||
@click="scrollRail($el, -1)">‹</button>
|
||||
<div class="rail">
|
||||
<template x-for="(item, i) in exploreItems" :key="'ex' + i + (item.source_id || item.kitsu_id || item.title)">
|
||||
<a class="card rail-card" :style="`--i:${i}`" :href="exploreHref(item)"
|
||||
:title="item.source ? item.title : `Rechercher « ${item.title} »`">
|
||||
<img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
|
||||
loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
|
||||
<span class="card-chip" x-show="item.label" x-text="item.label"></span>
|
||||
<span class="card-chip" x-show="!item.label && item.rating" x-text="item.rating ? '★ ' + item.rating : ''"></span>
|
||||
<div class="card-overlay">
|
||||
<div class="card-title" x-text="item.title"></div>
|
||||
<div class="card-meta">
|
||||
<span class="badge badge-type" x-text="item.start_date ? item.start_date.slice(0, 4) : (item.year || '')"></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
<button class="rail-nav rail-next" type="button" aria-label="Défiler à droite"
|
||||
@click="scrollRail($el, 1)">›</button>
|
||||
</div>
|
||||
<div class="explore-skel" x-show="exploreLoading">
|
||||
<div class="rail rail-skel">
|
||||
<template x-for="i in 8" :key="'sk' + i"><div class="rail-card skel skel-card"></div></template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------ Skeletons de chargement -->
|
||||
<div x-show="loading">
|
||||
<section class="rail-section" x-data="{ n: 8 }">
|
||||
@@ -28,14 +79,14 @@
|
||||
<div class="login-error" x-text="error"></div>
|
||||
</template>
|
||||
|
||||
<!-- ------------------------------------------------ Nouveautés -->
|
||||
<section class="rail-section" x-show="!loading && latest.length > 0">
|
||||
<h2 class="rail-title">🆕 Nouveautés <span class="rail-source">de tes sources, triées par date de sortie</span></h2>
|
||||
<!-- ------------------------------------------------ Nouveautés animés -->
|
||||
<section class="rail-section" x-show="!loading && latestAnime.length > 0">
|
||||
<h2 class="rail-title">🆕 Nouveautés animés <span class="rail-source">triées par date de sortie</span></h2>
|
||||
<div class="rail-wrap">
|
||||
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
|
||||
@click="scrollRail($el, -1)">‹</button>
|
||||
<div class="rail">
|
||||
<template x-for="(item, i) in latest" :key="item.source + item.source_id">
|
||||
<template x-for="(item, i) in latestAnime" :key="item.source + item.source_id">
|
||||
<a class="card rail-card" :style="`--i:${i}`" :href="`/title/${item.source}/${encodeURIComponent(item.source_id)}`">
|
||||
<img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
|
||||
loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
|
||||
@@ -56,9 +107,33 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------ Nouveautés séries & films -->
|
||||
<section class="rail-section" x-show="!loading && latestSerie.length > 0">
|
||||
<h2 class="rail-title">🆕 Nouveautés séries & films <span class="rail-source">ajouts récents de French-Stream</span></h2>
|
||||
<div class="rail-wrap">
|
||||
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
|
||||
@click="scrollRail($el, -1)">‹</button>
|
||||
<div class="rail">
|
||||
<template x-for="(item, i) in latestSerie" :key="item.source + item.source_id">
|
||||
<a class="card rail-card" :style="`--i:${i}`" :href="`/title/${item.source}/${encodeURIComponent(item.source_id)}`">
|
||||
<img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
|
||||
loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
|
||||
<span class="card-chip" x-text="item.label"></span>
|
||||
<span class="card-year" x-text="item.media_type === 'film' ? 'Film' : ''"></span>
|
||||
<div class="card-overlay">
|
||||
<div class="card-title" x-text="item.title"></div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
<button class="rail-nav rail-next" type="button" aria-label="Défiler à droite"
|
||||
@click="scrollRail($el, 1)">›</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------ Incontournables -->
|
||||
<section class="rail-section" x-show="!loading && mustWatch.length > 0">
|
||||
<h2 class="rail-title">🔥 Incontournables <span class="rail-source">les classiques les mieux notés</span></h2>
|
||||
<h2 class="rail-title">🔥 Incontournables animés <span class="rail-source">les classiques les mieux notés</span></h2>
|
||||
<div class="rail-wrap">
|
||||
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
|
||||
@click="scrollRail($el, -1)">‹</button>
|
||||
@@ -107,7 +182,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<template x-if="!loading && !error && latest.length === 0 && mustWatch.length === 0 && forYou.items.length === 0">
|
||||
<template x-if="!loading && !error && latestAnime.length === 0 && latestSerie.length === 0 && mustWatch.length === 0 && forYou.items.length === 0 && exploreItems.length === 0">
|
||||
<div class="empty-state"><div class="big">🏜️</div>Rien à afficher pour le moment — réessaie plus tard.</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -117,23 +192,89 @@
|
||||
<script>
|
||||
function discoverPage() {
|
||||
return {
|
||||
latest: [], mustWatch: [], forYou: { based_on: [], items: [] },
|
||||
latestAnime: [], latestSerie: [], mustWatch: [], forYou: { based_on: [], items: [] },
|
||||
genres: {}, exploreType: null, exploreGenre: null,
|
||||
exploreItems: [], exploreLoading: false,
|
||||
loading: true, error: null,
|
||||
typeLabels: { anime: '⛩ Animés', serie: '📺 Séries', film: '🎬 Films' },
|
||||
|
||||
get types() { return Object.keys(this.genres); },
|
||||
get currentGenres() { return this.genres[this.exploreType] || []; },
|
||||
get exploreSubtitle() {
|
||||
const genre = (this.currentGenres.find(g => g.key === this.exploreGenre) || {}).label;
|
||||
return genre ? `${this.typeLabels[this.exploreType] || ''} · ${genre}` : 'choisis un genre';
|
||||
},
|
||||
|
||||
scrollRail(el, dir) {
|
||||
const rail = el.closest('.rail-wrap').querySelector('.rail');
|
||||
rail.scrollBy({ left: dir * rail.clientWidth * 0.85, behavior: 'smooth' });
|
||||
},
|
||||
|
||||
exploreHref(item) {
|
||||
return item.source
|
||||
? `/title/${item.source}/${encodeURIComponent(item.source_id)}`
|
||||
: `/?q=${encodeURIComponent(item.title)}`;
|
||||
},
|
||||
|
||||
setType(type) {
|
||||
this.exploreType = type;
|
||||
this.exploreGenre = (this.genres[type][0] || {}).key || null;
|
||||
this.browse();
|
||||
},
|
||||
|
||||
setGenre(genre) {
|
||||
this.exploreGenre = genre;
|
||||
this.browse();
|
||||
},
|
||||
|
||||
async browse() {
|
||||
if (!this.exploreType || !this.exploreGenre) return;
|
||||
this.exploreLoading = true;
|
||||
const params = new URLSearchParams({ type: this.exploreType, genre: this.exploreGenre });
|
||||
const url = `${location.pathname}?${params}`;
|
||||
history.replaceState(null, '', url); // état partageable
|
||||
try {
|
||||
const res = await fetch(`/api/discover/browse?${params}`);
|
||||
if (!res.ok) throw new Error('Erreur ' + res.status);
|
||||
this.exploreItems = (await res.json()).items;
|
||||
} catch (e) {
|
||||
this.exploreItems = [];
|
||||
console.error('Explorer :', e);
|
||||
} finally {
|
||||
this.exploreLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async load() {
|
||||
this.loading = true; this.error = null;
|
||||
try {
|
||||
const res = await fetch('/api/discover');
|
||||
if (!res.ok) throw new Error('Erreur ' + res.status);
|
||||
const data = await res.json();
|
||||
this.latest = data.latest;
|
||||
const [discoverRes, genresRes] = await Promise.all([
|
||||
fetch('/api/discover'), fetch('/api/discover/genres'),
|
||||
]);
|
||||
if (!discoverRes.ok) throw new Error('Erreur ' + discoverRes.status);
|
||||
const data = await discoverRes.json();
|
||||
this.latestAnime = data.latest_anime;
|
||||
this.latestSerie = data.latest_serie;
|
||||
this.mustWatch = data.must_watch;
|
||||
this.forYou = data.for_you;
|
||||
} catch (e) { this.error = e.message; }
|
||||
finally { this.loading = false; }
|
||||
this.genres = await genresRes.json();
|
||||
|
||||
// État initial : paramètres d'URL (?t=&g=) sinon premier type/genre dispo
|
||||
const params = new URLSearchParams(location.search);
|
||||
let type = params.get('t');
|
||||
if (!this.genres[type]) type = this.types[0];
|
||||
let genre = params.get('g');
|
||||
if (!(this.genres[type] || []).some(g => g.key === genre)) {
|
||||
genre = ((this.genres[type] || [])[0] || {}).key;
|
||||
}
|
||||
this.exploreType = type || null;
|
||||
this.exploreGenre = genre || null;
|
||||
await this.browse();
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user