Files
Mangarr/assets/vue/app/domain/manga/presentation/pages/DiscoverPage.vue
ext.jeremy.guillot@maxicoffee.domains 814fe46ce5 feat(manga): implémenter la page Découvrir avec recommandations MangaDex
- Endpoint GET /api/manga-discover via DiscoverMangaStateProvider + DiscoverMangaHandler
- Algorithme : top 5 manga de la collection → appel /manga/{id}/recommendation
  par source → agrégation avec système de votes (multi-sources = plus pertinent)
- Filtrage : tags exclus (Oneshot, Doujinshi, Self-Published), contentRating,
  et suppression des manga déjà en bibliothèque
- Page Vue DiscoverPage.vue : chargement auto au montage, bouton Actualiser,
  modal détail, ajout à la bibliothèque
- Adapteurs InMemory de test mis à jour (discover + getMangaRecommendations)
2026-03-15 21:43:57 +01:00

193 lines
9.8 KiB
Vue

<template>
<div class="flex flex-col h-full">
<Toolbar :config="toolbarConfig" />
<div class="overflow-y-auto flex-1">
<div class="px-6 py-8">
<!-- État de chargement -->
<section v-if="loading" class="border-t border-gray-200 dark:border-gray-700 pt-6">
<div class="flex items-center gap-3 text-gray-600 dark:text-gray-400">
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-green-600"></div>
<span class="text-sm">Chargement des recommandations...</span>
</div>
</section>
<!-- Message d'erreur -->
<section v-else-if="error" class="border-t border-gray-200 dark:border-gray-700 pt-6">
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
</section>
<!-- Résultats -->
<section v-else-if="discoverResults.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Recommandations</h2>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ discoverResults.length }} manga(s)</span>
</div>
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
<div
v-for="manga in discoverResults"
:key="manga.externalId"
class="flex items-start gap-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors cursor-pointer px-2"
@click="openMangaModal(manga)">
<img
:src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
alt=""
class="h-36 w-24 object-cover flex-shrink-0"
referrerpolicy="no-referrer" />
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ manga.title }}</p>
<p v-if="manga.description" class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-4">{{ manga.description }}</p>
</div>
</div>
</div>
</section>
<!-- Collection locale vide -->
<section v-else-if="!loading" class="border-t border-gray-200 dark:border-gray-700 pt-6">
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">Ajoutez des manga pour obtenir des recommandations.</p>
</section>
</div>
</div>
<!-- Modal de détail -->
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
<div class="fixed inset-0 bg-gray-900/70 dark:bg-gray-900/80 transition-opacity" aria-hidden="true" />
<div class="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel v-if="selectedManga" class="w-full max-w-2xl bg-white dark:bg-gray-800 shadow-xl overflow-hidden flex flex-col max-h-[90vh]">
<!-- En-tête avec couverture -->
<div class="flex gap-0 border-b border-gray-200 dark:border-gray-700">
<img
:src="selectedManga.imageUrl || selectedManga.thumbnailUrl || '/placeholder-cover.png'"
:alt="selectedManga.title"
class="h-64 w-44 object-cover flex-shrink-0"
referrerpolicy="no-referrer" />
<div class="flex-1 min-w-0 p-6 flex flex-col justify-between">
<div>
<DialogTitle class="text-base font-semibold text-gray-900 dark:text-gray-100 leading-snug">
{{ selectedManga.title }}
</DialogTitle>
<div class="mt-3 space-y-1.5">
<p v-if="selectedManga.author" class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">Auteur</span>
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.author }}</span>
</p>
<p v-if="selectedManga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">Publication</span>
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.publicationYear }}</span>
</p>
<p v-if="selectedManga.status" class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">Statut</span>
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.status }}</span>
</p>
<p v-if="selectedManga.rating" class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">Note</span>
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.rating.toFixed(2) }} / 10</span>
</p>
</div>
</div>
<div v-if="selectedManga.genres?.length" class="flex flex-wrap gap-1.5 mt-4">
<span
v-for="genre in selectedManga.genres"
:key="genre"
class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
{{ genre }}
</span>
</div>
</div>
</div>
<!-- Description -->
<div class="px-6 py-4 overflow-y-auto flex-1">
<h3 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Description</h3>
<p v-if="selectedManga.description" class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
{{ selectedManga.description }}
</p>
<p v-else class="text-sm text-gray-400 dark:text-gray-500 italic">Aucune description disponible.</p>
</div>
<!-- Actions -->
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button
type="button"
@click="closeModal"
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors px-4 py-2">
Annuler
</button>
<button
type="button"
@click="addManga"
:disabled="adding"
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-4 py-2 font-medium transition-colors inline-flex items-center gap-2">
<ArrowPathIcon v-if="adding" class="h-4 w-4 animate-spin" />
{{ adding ? 'Ajout en cours...' : 'Ajouter à la bibliothèque' }}
</button>
</div>
</DialogPanel>
</div>
</Dialog>
</div>
</template>
<script setup>
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
import { ArrowPathIcon, ArrowPathRoundedSquareIcon } from '@heroicons/vue/24/outline';
import { storeToRefs } from 'pinia';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useMangaStore } from '../../application/store/mangaStore';
const router = useRouter();
const mangaStore = useMangaStore();
const isModalOpen = ref(false);
const selectedManga = ref(null);
const { discoverResults, loadingDiscover: loading, discoverError: error, addingManga: adding } = storeToRefs(mangaStore);
const toolbarConfig = computed(() => ({
leftSection: [
{ type: 'label', text: 'Découvrir', class: 'text-sm font-medium' },
],
rightSection: [
{
type: 'button',
icon: ArrowPathRoundedSquareIcon,
label: 'Actualiser',
onClick: () => mangaStore.loadDiscoverRecommendations(),
disabled: loading.value,
},
],
}));
onMounted(() => {
mangaStore.loadDiscoverRecommendations();
});
const openMangaModal = manga => {
selectedManga.value = manga;
isModalOpen.value = true;
};
const closeModal = () => {
isModalOpen.value = false;
selectedManga.value = null;
};
const addManga = async () => {
if (!selectedManga.value) return;
try {
await mangaStore.createFromMangaDex(selectedManga.value.externalId);
router.push('/manga');
} catch (e) {
console.error("Erreur d'ajout:", e);
} finally {
closeModal();
}
};
</script>