feat: ajout de la gestion des chapitres dans le store Manga avec des actions pour charger et mettre à jour la disponibilité des chapitres, intégration d'un écouteur Mercure pour les mises à jour en temps réel, et amélioration des composants d'interface utilisateur pour gérer les états de chargement et d'erreur.
This commit is contained in:
parent
e51712a800
commit
5928cfd5f0
@@ -28,6 +28,11 @@ export const useMangaStore = defineStore('manga', {
|
|||||||
// mais les données détaillées ne sont plus stockées ici.
|
// mais les données détaillées ne sont plus stockées ici.
|
||||||
currentMangaId: null,
|
currentMangaId: null,
|
||||||
|
|
||||||
|
// --- Manga Chapters State ---
|
||||||
|
mangaChapters: {},
|
||||||
|
loadingChapters: false,
|
||||||
|
chaptersError: null,
|
||||||
|
|
||||||
// --- Search State ---
|
// --- Search State ---
|
||||||
searchResults: [],
|
searchResults: [],
|
||||||
loadingSearch: false,
|
loadingSearch: false,
|
||||||
@@ -86,6 +91,60 @@ export const useMangaStore = defineStore('manga', {
|
|||||||
this.currentMangaId = null;
|
this.currentMangaId = null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- Chapters Actions ---
|
||||||
|
async loadChapters(mangaId) {
|
||||||
|
if (this.loadingChapters) return;
|
||||||
|
this.loadingChapters = true;
|
||||||
|
this.chaptersError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chaptersData = await mangaRepository.getChapters(mangaId);
|
||||||
|
this.mangaChapters[mangaId] = chaptersData;
|
||||||
|
} catch (err) {
|
||||||
|
this.chaptersError = err.message;
|
||||||
|
} finally {
|
||||||
|
this.loadingChapters = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateChapterAvailability(chapterId, isAvailable = true) {
|
||||||
|
console.log(`Mise à jour du chapitre ${chapterId}, disponible: ${isAvailable}`);
|
||||||
|
|
||||||
|
// Pour chaque manga dans notre store
|
||||||
|
Object.keys(this.mangaChapters).forEach(mangaId => {
|
||||||
|
const chaptersObj = this.mangaChapters[mangaId];
|
||||||
|
if (!chaptersObj || !chaptersObj.items) return;
|
||||||
|
|
||||||
|
const chapters = chaptersObj.items;
|
||||||
|
|
||||||
|
// Chercher le chapitre correspondant
|
||||||
|
const chapterIndex = chapters.findIndex(chapter => chapter.id === chapterId);
|
||||||
|
|
||||||
|
// Si on trouve le chapitre, mettre à jour son état
|
||||||
|
if (chapterIndex !== -1) {
|
||||||
|
console.log(`Chapitre trouvé dans le manga ${mangaId}, index: ${chapterIndex}`);
|
||||||
|
|
||||||
|
// Important: créer une nouvelle référence pour que Vue détecte le changement
|
||||||
|
const updatedChapter = {
|
||||||
|
...chapters[chapterIndex],
|
||||||
|
isAvailable: isAvailable
|
||||||
|
};
|
||||||
|
|
||||||
|
// Créer un nouveau tableau pour garantir la réactivité
|
||||||
|
const updatedChapters = [...chapters];
|
||||||
|
updatedChapters[chapterIndex] = updatedChapter;
|
||||||
|
|
||||||
|
// Mise à jour reactive du store
|
||||||
|
this.mangaChapters[mangaId] = {
|
||||||
|
...chaptersObj,
|
||||||
|
items: updatedChapters
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Chapitre mis à jour avec succès');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// --- Search Actions ---
|
// --- Search Actions ---
|
||||||
async searchMangaDex(query) {
|
async searchMangaDex(query) {
|
||||||
if (this.loadingSearch) return;
|
if (this.loadingSearch) return;
|
||||||
@@ -130,16 +189,13 @@ export const useMangaStore = defineStore('manga', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- Chapter Actions ---
|
// --- Scrape Chapter Action ---
|
||||||
async searchChapter(chapterId) {
|
async searchChapter(chapterId) {
|
||||||
try {
|
try {
|
||||||
await mangaRepository.searchChapter(chapterId);
|
await mangaRepository.searchChapter(chapterId);
|
||||||
// Rafraîchir la collection après la recherche
|
|
||||||
await this.refreshCollectionInBackground();
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la recherche du chapitre:', error);
|
console.error('Erreur lors de la recherche du chapitre:', error);
|
||||||
return false;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,13 @@
|
|||||||
params: {
|
params: {
|
||||||
chapterId: chapter.id
|
chapterId: chapter.id
|
||||||
}
|
}
|
||||||
}"
|
}">
|
||||||
class="text-green-500">
|
|
||||||
{{ chapter.title || 'Sans titre' }}
|
{{ chapter.title || 'Sans titre' }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<span v-else>{{ chapter.title || 'Sans titre' }}</span>
|
<span v-else>{{ chapter.title || 'Sans titre' }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2 flex justify-end gap-2">
|
<td class="px-4 py-2 flex justify-end gap-2">
|
||||||
<button v-if="!chapter.isAvailable" @click="handleSearch" class="text-gray-500 hover:text-green-500">
|
<button v-if="!chapter.isAvailable" @click="handleSearch" :class="buttonClass">
|
||||||
<MagnifyingGlassIcon class="h-5 w-5" />
|
<MagnifyingGlassIcon class="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
<button v-else @click="handleDelete" class="text-gray-500 hover:text-green-500">
|
<button v-else @click="handleDelete" class="text-gray-500 hover:text-green-500">
|
||||||
@@ -35,7 +34,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { MagnifyingGlassIcon, ArrowDownTrayIcon, XMarkIcon, TrashIcon } from '@heroicons/vue/24/solid';
|
import { ArrowDownTrayIcon, MagnifyingGlassIcon, TrashIcon, XMarkIcon } from '@heroicons/vue/24/solid';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useMangaStore } from '../../application/store/mangaStore';
|
import { useMangaStore } from '../../application/store/mangaStore';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -50,11 +50,38 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const store = useMangaStore();
|
const store = useMangaStore();
|
||||||
|
const isLoading = ref(false);
|
||||||
|
|
||||||
|
const buttonClass = computed(() => {
|
||||||
|
return isLoading.value ? 'text-yellow-500 cursor-wait' : 'text-gray-500 hover:text-green-500';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Surveiller les changements d'état du chapitre
|
||||||
|
watch(
|
||||||
|
() => props.chapter.isAvailable,
|
||||||
|
(newValue, oldValue) => {
|
||||||
|
console.log(
|
||||||
|
`MangaChapter: État du chapitre ${props.chapter.number} (ID: ${props.chapter.id}) modifié - ${oldValue} => ${newValue}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Si le chapitre devient disponible, on arrête le chargement
|
||||||
|
if (newValue === true) {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const handleSearch = async () => {
|
const handleSearch = async () => {
|
||||||
try {
|
try {
|
||||||
|
console.log(`MangaChapter: Recherche du chapitre ${props.chapter.number} (ID: ${props.chapter.id})`);
|
||||||
|
// Montrer l'indicateur de chargement
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
// Lancer la recherche du chapitre - L'UI sera mise à jour par l'événement Mercure
|
||||||
await store.searchChapter(props.chapter.id);
|
await store.searchChapter(props.chapter.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// En cas d'erreur, on arrête le chargement
|
||||||
|
isLoading.value = false;
|
||||||
console.error('Erreur lors de la recherche du chapitre:', error);
|
console.error('Erreur lors de la recherche du chapitre:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,8 +32,14 @@
|
|||||||
|
|
||||||
<!-- Actions du volume -->
|
<!-- Actions du volume -->
|
||||||
<div class="flex space-x-2 text-xl text-bold">
|
<div class="flex space-x-2 text-xl text-bold">
|
||||||
<button class="w-8 text-center" @click="handleSearch">
|
<button
|
||||||
<MagnifyingGlassIcon class="h-6 w-6 text-gray-500 hover:text-green-500" />
|
class="w-8 text-center"
|
||||||
|
@click="handleSearch"
|
||||||
|
:class="{
|
||||||
|
'text-yellow-500 cursor-wait': isSearching,
|
||||||
|
'text-gray-500 hover:text-green-500': !isSearching
|
||||||
|
}">
|
||||||
|
<MagnifyingGlassIcon class="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
<button class="w-8 text-center" @click="handleDownload">
|
<button class="w-8 text-center" @click="handleDownload">
|
||||||
<ArrowDownTrayIcon class="h-6 w-6 text-gray-500 hover:text-green-500" />
|
<ArrowDownTrayIcon class="h-6 w-6 text-gray-500 hover:text-green-500" />
|
||||||
@@ -54,16 +60,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
|
||||||
import {
|
import {
|
||||||
|
ArrowDownTrayIcon,
|
||||||
BookmarkIcon,
|
BookmarkIcon,
|
||||||
ChevronUpIcon,
|
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
MagnifyingGlassIcon,
|
ChevronUpIcon,
|
||||||
ArrowDownTrayIcon
|
MagnifyingGlassIcon
|
||||||
} from '@heroicons/vue/24/outline';
|
} from '@heroicons/vue/24/outline';
|
||||||
import MangaChapterList from './MangaChapterList.vue';
|
import { ref } from 'vue';
|
||||||
import { useMangaStore } from '../../application/store/mangaStore';
|
import { useMangaStore } from '../../application/store/mangaStore';
|
||||||
|
import MangaChapterList from './MangaChapterList.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
volume: {
|
volume: {
|
||||||
@@ -82,14 +88,48 @@
|
|||||||
|
|
||||||
const store = useMangaStore();
|
const store = useMangaStore();
|
||||||
const isOpen = ref(props.isOpen);
|
const isOpen = ref(props.isOpen);
|
||||||
|
const isSearching = ref(false);
|
||||||
|
|
||||||
const toggleVolume = () => {
|
const toggleVolume = () => {
|
||||||
isOpen.value = !isOpen.value;
|
isOpen.value = !isOpen.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearch = async () => {
|
const handleSearch = async () => {
|
||||||
// TODO: Implémenter la recherche du volume
|
if (isSearching.value) return; // Éviter les clicks multiples
|
||||||
console.log('Recherche du volume:', props.volume.number);
|
|
||||||
|
try {
|
||||||
|
isSearching.value = true;
|
||||||
|
console.log(
|
||||||
|
`Recherche du volume ${props.volume.number} - Lancement du scraping de ${props.volume.chapters.length} chapitres`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Récupérer tous les chapitres non disponibles du volume
|
||||||
|
const chaptersToSearch = props.volume.chapters
|
||||||
|
.filter(chapter => !chapter.isAvailable)
|
||||||
|
.map(chapter => chapter.id);
|
||||||
|
|
||||||
|
if (chaptersToSearch.length === 0) {
|
||||||
|
console.log('Tous les chapitres sont déjà disponibles !');
|
||||||
|
isSearching.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Chapitres à scraper: ${chaptersToSearch.length}`);
|
||||||
|
|
||||||
|
// Lancer le scraping de chaque chapitre non disponible en séquentiel
|
||||||
|
for (const chapterId of chaptersToSearch) {
|
||||||
|
console.log(`Scraping du chapitre ${chapterId}...`);
|
||||||
|
await store.searchChapter(chapterId);
|
||||||
|
// Petite pause entre chaque requête pour éviter de surcharger le serveur
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Scraping des chapitres du volume ${props.volume.number} terminé`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Erreur lors du scraping du volume ${props.volume.number}:`, error);
|
||||||
|
} finally {
|
||||||
|
isSearching.value = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Composant invisible qui écoute les événements Mercure -->
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount, watch, inject } from 'vue';
|
||||||
|
import { useMangaStore } from '../../application/store/mangaStore';
|
||||||
|
import { useQueryClient } from '@tanstack/vue-query';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
mangaId: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = useMangaStore();
|
||||||
|
const eventSource = ref(null);
|
||||||
|
// On récupère le client de requête pour invalider le cache
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const setupMercureEventSource = () => {
|
||||||
|
if (eventSource.value) {
|
||||||
|
eventSource.value.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer les topics à écouter
|
||||||
|
const topics = [`manga/${props.mangaId}/chapters`, 'scraping/status'];
|
||||||
|
|
||||||
|
// Construire l'URL du hub Mercure avec les topics
|
||||||
|
const mercureHubUrl = new URL('/.well-known/mercure', window.location.origin);
|
||||||
|
topics.forEach(topic => {
|
||||||
|
mercureHubUrl.searchParams.append('topic', topic);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`MercureListener: Abonnement aux topics pour manga ${props.mangaId}`);
|
||||||
|
console.log(`MercureListener: URL Mercure - ${mercureHubUrl.toString()}`);
|
||||||
|
|
||||||
|
// Créer la source d'événements
|
||||||
|
eventSource.value = new EventSource(mercureHubUrl, { withCredentials: true });
|
||||||
|
|
||||||
|
// Définir les gestionnaires d'événements
|
||||||
|
eventSource.value.onmessage = event => {
|
||||||
|
try {
|
||||||
|
console.log('MercureListener: Événement reçu', event.data);
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
handleMercureEvent(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors du traitement de l'événement Mercure:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.value.onerror = error => {
|
||||||
|
console.error('Erreur de connexion à Mercure:', error);
|
||||||
|
// Tenter de reconnecter après un délai
|
||||||
|
setTimeout(() => {
|
||||||
|
if (eventSource.value) {
|
||||||
|
setupMercureEventSource();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMercureEvent = data => {
|
||||||
|
if (!data || !data.type) {
|
||||||
|
console.warn('MercureListener: Événement sans type reçu', data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case 'chapter.scraped':
|
||||||
|
console.log(`MercureListener: Chapitre ${data.chapterNumber} scrappé avec succès!`, data);
|
||||||
|
|
||||||
|
// Vérifier que l'ID du chapitre est présent et au bon format
|
||||||
|
if (!data.chapterId) {
|
||||||
|
console.error("MercureListener: ID du chapitre manquant dans l'événement", data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour l'état du chapitre dans le store
|
||||||
|
try {
|
||||||
|
// Mettre à jour le store Pinia
|
||||||
|
store.updateChapterAvailability(data.chapterId, true);
|
||||||
|
console.log(
|
||||||
|
`MercureListener: Chapitre ${data.chapterNumber} (ID: ${data.chapterId}) marqué comme disponible`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Invalider le cache des requêtes pour forcer un rechargement frais
|
||||||
|
console.log(`MercureListener: Invalidation du cache pour les chapitres du manga ${props.mangaId}`);
|
||||||
|
queryClient.invalidateQueries(['manga', ref(props.mangaId), 'chapters']);
|
||||||
|
|
||||||
|
// Force le rechargement des chapitres via le store
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('MercureListener: Rechargement forcé des chapitres via le store');
|
||||||
|
store.loadChapters(props.mangaId);
|
||||||
|
}, 100);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('MercureListener: Erreur lors de la mise à jour du chapitre', error);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'chapter.scraping.failed':
|
||||||
|
console.error(`MercureListener: Échec du scraping du chapitre ${data.chapterNumber}:`, data.reason);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log('MercureListener: Événement Mercure non géré:', data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.mangaId,
|
||||||
|
(newMangaId, oldMangaId) => {
|
||||||
|
console.log(`MercureListener: MangaId changé de ${oldMangaId} à ${newMangaId}`);
|
||||||
|
if (newMangaId) {
|
||||||
|
setupMercureEventSource();
|
||||||
|
} else if (eventSource.value) {
|
||||||
|
eventSource.value.close();
|
||||||
|
eventSource.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setupMercureEventSource();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (eventSource.value) {
|
||||||
|
eventSource.value.close();
|
||||||
|
eventSource.value = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -11,11 +11,25 @@ export function useMangaChapters(mangaId) {
|
|||||||
if (!mangaId.value) {
|
if (!mangaId.value) {
|
||||||
return Promise.resolve([]); // Retourne un tableau vide si pas d'ID
|
return Promise.resolve([]); // Retourne un tableau vide si pas d'ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`useMangaChapters: Chargement des chapitres pour le manga ${mangaId.value}`);
|
||||||
const response = await mangaRepository.getChapters(mangaId.value);
|
const response = await mangaRepository.getChapters(mangaId.value);
|
||||||
|
|
||||||
|
// Log pour déboguer
|
||||||
|
console.log(`useMangaChapters: ${response.items?.length || 0} chapitres chargés`);
|
||||||
|
|
||||||
// Assure de toujours retourner un tableau
|
// Assure de toujours retourner un tableau
|
||||||
return Array.isArray(response) ? response : response?.items ?? [];
|
return Array.isArray(response) ? response : response?.items ?? [];
|
||||||
},
|
},
|
||||||
enabled: computed(() => !!mangaId.value)
|
// Refresh toutes les 30 secondes en arrière-plan
|
||||||
|
refetchInterval: 30000,
|
||||||
|
// S'assurer que si le composant est visible à nouveau, on récupère les données fraîches
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
// Query activée uniquement si mangaId est défini
|
||||||
|
enabled: computed(() => !!mangaId.value),
|
||||||
|
// Options pour conserver les données entre les requêtes
|
||||||
|
staleTime: 60000, // Considère les données comme "périmées" après 1 minute
|
||||||
|
cacheTime: 5 * 60 * 1000 // Garde les données en cache pendant 5 minutes
|
||||||
});
|
});
|
||||||
|
|
||||||
// Retourne le résultat de useQuery (contenant data, isLoading, etc.)
|
// Retourne le résultat de useQuery (contenant data, isLoading, etc.)
|
||||||
|
|||||||
@@ -1,18 +1,39 @@
|
|||||||
import { computed } from 'vue';
|
import { computed, watch } from 'vue';
|
||||||
|
import { useMangaStore } from '../../application/store/mangaStore';
|
||||||
import { useMangaChapters } from './useMangaChapters'; // Importe le composable des chapitres
|
import { useMangaChapters } from './useMangaChapters'; // Importe le composable des chapitres
|
||||||
|
|
||||||
export function useMangaVolumes(mangaId) {
|
export function useMangaVolumes(mangaId) {
|
||||||
|
// Récupération du store pour avoir accès aux chapitres mis à jour en temps réel
|
||||||
|
const mangaStore = useMangaStore();
|
||||||
|
|
||||||
// Utilise le composable des chapitres pour récupérer les données brutes et les états
|
// Utilise le composable des chapitres pour récupérer les données brutes et les états
|
||||||
const { data: rawChaptersData, isLoading, isFetching, error, status } = useMangaChapters(mangaId);
|
const { data: rawChaptersData, isLoading, isFetching, error, status, refetch } = useMangaChapters(mangaId);
|
||||||
|
|
||||||
|
// Fonction pour forcer le rechargement des données
|
||||||
|
const refresh = () => {
|
||||||
|
console.log('useMangaVolumes: Rechargement forcé des chapitres');
|
||||||
|
refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Surveiller les changements dans le store pour les chapitres du manga actuel
|
||||||
|
watch(
|
||||||
|
() => mangaStore.mangaChapters[mangaId.value],
|
||||||
|
() => {
|
||||||
|
console.log('useMangaVolumes: Changement détecté dans les chapitres du store');
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
// Calcule les volumes à partir des données des chapitres
|
// Calcule les volumes à partir des données des chapitres
|
||||||
const volumes = computed(() => {
|
const volumes = computed(() => {
|
||||||
|
console.log('useMangaVolumes: Recalcul des volumes');
|
||||||
const chaptersArray = rawChaptersData.value || []; // Utilise la data retournée par useMangaChapters
|
const chaptersArray = rawChaptersData.value || []; // Utilise la data retournée par useMangaChapters
|
||||||
if (chaptersArray.length === 0) return [];
|
if (chaptersArray.length === 0) return [];
|
||||||
|
|
||||||
const volumeMap = new Map();
|
const volumeMap = new Map();
|
||||||
chaptersArray.forEach(chapter => {
|
chaptersArray.forEach(chapter => {
|
||||||
const volumeNumber = chapter.volume || 'Unknown';
|
const volumeNumber = chapter.volume || '00';
|
||||||
if (!volumeMap.has(volumeNumber)) {
|
if (!volumeMap.has(volumeNumber)) {
|
||||||
volumeMap.set(volumeNumber, {
|
volumeMap.set(volumeNumber, {
|
||||||
number: volumeNumber,
|
number: volumeNumber,
|
||||||
@@ -33,8 +54,13 @@ export function useMangaVolumes(mangaId) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(volumeMap.values()).sort((a, b) => {
|
return Array.from(volumeMap.values()).sort((a, b) => {
|
||||||
const numA = a.number === 'Unknown' ? -Infinity : Number(a.number);
|
// Cas spécial pour le volume 00, qui doit apparaître en premier
|
||||||
const numB = b.number === 'Unknown' ? -Infinity : Number(b.number);
|
if (a.number === '00') return -1;
|
||||||
|
if (b.number === '00') return 1;
|
||||||
|
|
||||||
|
// Pour tous les autres volumes, tri décroissant
|
||||||
|
const numA = Number(a.number);
|
||||||
|
const numB = Number(b.number);
|
||||||
return numB - numA;
|
return numB - numA;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -45,7 +71,8 @@ export function useMangaVolumes(mangaId) {
|
|||||||
isLoading, // L'état de chargement initial des chapitres
|
isLoading, // L'état de chargement initial des chapitres
|
||||||
isFetching, // L'état de rafraîchissement des chapitres
|
isFetching, // L'état de rafraîchissement des chapitres
|
||||||
error, // L'erreur potentielle lors du fetch des chapitres
|
error, // L'erreur potentielle lors du fetch des chapitres
|
||||||
status // L'état global ('pending', 'error', 'success')
|
status, // L'état global ('pending', 'error', 'success')
|
||||||
|
refresh
|
||||||
// On pourrait aussi retourner rawChaptersData si nécessaire ailleurs
|
// On pourrait aussi retourner rawChaptersData si nécessaire ailleurs
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="currentManga" class="relative">
|
<div v-else-if="currentManga" class="relative">
|
||||||
|
<!-- Composant invisible qui écoute les mises à jour Mercure -->
|
||||||
|
<MercureListener :manga-id="mangaId" />
|
||||||
|
|
||||||
<Toolbar :config="toolbarConfig" />
|
<Toolbar :config="toolbarConfig" />
|
||||||
|
|
||||||
<div v-if="isRefreshingDetails" class="absolute top-2 right-2 text-gray-500">
|
<div v-if="isRefreshingDetails" class="absolute top-2 right-2 text-gray-500">
|
||||||
@@ -31,7 +34,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onUnmounted, watch } from 'vue';
|
import { computed, onUnmounted, watch, onMounted } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import {
|
import {
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
@@ -49,6 +52,7 @@
|
|||||||
|
|
||||||
import MangaHeader from '../components/MangaHeader.vue';
|
import MangaHeader from '../components/MangaHeader.vue';
|
||||||
import MangaVolumeList from '../components/MangaVolumeList.vue';
|
import MangaVolumeList from '../components/MangaVolumeList.vue';
|
||||||
|
import MercureListener from '../components/MercureListener.vue';
|
||||||
|
|
||||||
import { useMangaStore } from '../../application/store/mangaStore';
|
import { useMangaStore } from '../../application/store/mangaStore';
|
||||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||||
@@ -72,6 +76,17 @@
|
|||||||
error: errorVolumes
|
error: errorVolumes
|
||||||
} = useMangaVolumes(mangaId);
|
} = useMangaVolumes(mangaId);
|
||||||
|
|
||||||
|
// Charger les chapitres dans le store quand le manga est chargé
|
||||||
|
watch(
|
||||||
|
mangaId,
|
||||||
|
newId => {
|
||||||
|
if (newId) {
|
||||||
|
mangaStore.loadChapters(newId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
const toolbarConfig = computed(() => ({
|
const toolbarConfig = computed(() => ({
|
||||||
leftSection: [
|
leftSection: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ readonly class FetchMangaChaptersHandler
|
|||||||
$limit = 500;
|
$limit = 500;
|
||||||
$hasMore = true;
|
$hasMore = true;
|
||||||
$chaptersByNumber = [];
|
$chaptersByNumber = [];
|
||||||
|
$chapterLanguages = []; // Pour stocker la langue de chaque chapitre
|
||||||
$chapterNumbers = [];
|
$chapterNumbers = [];
|
||||||
|
|
||||||
while ($hasMore) {
|
while ($hasMore) {
|
||||||
@@ -41,27 +42,40 @@ readonly class FetchMangaChaptersHandler
|
|||||||
foreach ($feed['data'] as $chapterData) {
|
foreach ($feed['data'] as $chapterData) {
|
||||||
$chapterNumber = (float) $chapterData['attributes']['chapter'];
|
$chapterNumber = (float) $chapterData['attributes']['chapter'];
|
||||||
$language = $chapterData['attributes']['translatedLanguage'];
|
$language = $chapterData['attributes']['translatedLanguage'];
|
||||||
|
$title = $chapterData['attributes']['title'];
|
||||||
|
|
||||||
// On ne traite que les chapitres en français ou en anglais
|
// Pour les langues autres que français et anglais, on utilise un titre générique
|
||||||
if (!in_array($language, ['fr', 'en'])) {
|
if (!in_array($language, ['fr', 'en'])) {
|
||||||
continue;
|
$title = "Chapter {$chapterNumber}";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si le chapitre n'existe pas encore ou si c'est une version française
|
// Définir les règles de priorité des langues (fr > en > autres)
|
||||||
if (!isset($chaptersByNumber[$chapterNumber]) || $language === 'fr') {
|
$shouldReplaceChapter = false;
|
||||||
$chapter = new Chapter(
|
|
||||||
|
if (!isset($chaptersByNumber[$chapterNumber])) {
|
||||||
|
// Si c'est le premier chapitre avec ce numéro qu'on rencontre
|
||||||
|
$shouldReplaceChapter = true;
|
||||||
|
$chapterNumbers[] = $chapterNumber;
|
||||||
|
} else if ($language === 'fr') {
|
||||||
|
// Le français est toujours prioritaire
|
||||||
|
$shouldReplaceChapter = true;
|
||||||
|
} else if ($language === 'en' && $chapterLanguages[$chapterNumber] !== 'fr') {
|
||||||
|
// L'anglais est prioritaire sur les autres langues, sauf le français
|
||||||
|
$shouldReplaceChapter = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($shouldReplaceChapter) {
|
||||||
|
$chaptersByNumber[$chapterNumber] = new Chapter(
|
||||||
new ChapterId((string) Uuid::uuid4()),
|
new ChapterId((string) Uuid::uuid4()),
|
||||||
$manga->getId()->getValue(),
|
$manga->getId()->getValue(),
|
||||||
$chapterNumber,
|
$chapterNumber,
|
||||||
$chapterData['attributes']['title'],
|
$title,
|
||||||
isset($chapterData['attributes']['volume']) ? (int) $chapterData['attributes']['volume'] : null,
|
isset($chapterData['attributes']['volume']) ? (int) $chapterData['attributes']['volume'] : null,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
new \DateTimeImmutable()
|
new \DateTimeImmutable()
|
||||||
);
|
);
|
||||||
|
$chapterLanguages[$chapterNumber] = $language;
|
||||||
$chaptersByNumber[$chapterNumber] = $chapter;
|
|
||||||
$chapterNumbers[] = $chapterNumber;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +83,9 @@ readonly class FetchMangaChaptersHandler
|
|||||||
$hasMore = count($feed['data']) === $limit;
|
$hasMore = count($feed['data']) === $limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Harmonisation des volumes: si le chapitre précédent et suivant ont un volume null, alors le chapitre actuel aussi
|
||||||
|
$this->harmonizeVolumes($chaptersByNumber);
|
||||||
|
|
||||||
// Récupère les chapitres existants
|
// Récupère les chapitres existants
|
||||||
$existingChapters = $this->mangaRepository->findExistingChaptersByNumbers(
|
$existingChapters = $this->mangaRepository->findExistingChaptersByNumbers(
|
||||||
$manga->getId()->getValue(),
|
$manga->getId()->getValue(),
|
||||||
@@ -82,4 +99,56 @@ readonly class FetchMangaChaptersHandler
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Harmonise les volumes des chapitres:
|
||||||
|
* - Si le chapitre précédent et suivant ont un volume null, alors le chapitre actuel aussi
|
||||||
|
* - Si le chapitre précédent et suivant ont le même volume, alors le chapitre actuel aura ce volume
|
||||||
|
*/
|
||||||
|
private function harmonizeVolumes(array &$chaptersByNumber): void
|
||||||
|
{
|
||||||
|
// Trie les chapitres par numéro pour faciliter la recherche des adjacents
|
||||||
|
ksort($chaptersByNumber);
|
||||||
|
|
||||||
|
$chapterNumbers = array_keys($chaptersByNumber);
|
||||||
|
$count = count($chapterNumbers);
|
||||||
|
|
||||||
|
for ($i = 1; $i < $count - 1; $i++) {
|
||||||
|
$prevChapterNum = $chapterNumbers[$i - 1];
|
||||||
|
$currentChapterNum = $chapterNumbers[$i];
|
||||||
|
$nextChapterNum = $chapterNumbers[$i + 1];
|
||||||
|
|
||||||
|
$prevChapter = $chaptersByNumber[$prevChapterNum];
|
||||||
|
$currentChapter = $chaptersByNumber[$currentChapterNum];
|
||||||
|
$nextChapter = $chaptersByNumber[$nextChapterNum];
|
||||||
|
|
||||||
|
$shouldUpdateVolume = false;
|
||||||
|
$newVolume = $currentChapter->getVolume();
|
||||||
|
|
||||||
|
// Si les chapitres adjacents ont un volume null, alors le chapitre actuel aussi
|
||||||
|
if ($prevChapter->getVolume() === null && $nextChapter->getVolume() === null && $currentChapter->getVolume() !== null) {
|
||||||
|
$shouldUpdateVolume = true;
|
||||||
|
$newVolume = null;
|
||||||
|
}
|
||||||
|
// Si les chapitres adjacents ont le même volume non-null, alors le chapitre actuel aura ce volume
|
||||||
|
else if ($prevChapter->getVolume() !== null && $prevChapter->getVolume() === $nextChapter->getVolume() && $currentChapter->getVolume() !== $prevChapter->getVolume()) {
|
||||||
|
$shouldUpdateVolume = true;
|
||||||
|
$newVolume = $prevChapter->getVolume();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($shouldUpdateVolume) {
|
||||||
|
// On doit créer un nouveau chapitre car les objets sont immuables
|
||||||
|
$chaptersByNumber[$currentChapterNum] = new Chapter(
|
||||||
|
id: new ChapterId($currentChapter->getId()),
|
||||||
|
mangaId: $currentChapter->getMangaId(),
|
||||||
|
number: $currentChapter->getNumber(),
|
||||||
|
title: $currentChapter->getTitle(),
|
||||||
|
volume: $newVolume, // On met à jour le volume
|
||||||
|
isVisible: $currentChapter->isVisible(),
|
||||||
|
isAvailable: $currentChapter->isAvailable(),
|
||||||
|
createdAt: $currentChapter->getCreatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ class MangadexClient implements MangadexClientInterface
|
|||||||
{
|
{
|
||||||
return $this->get('/manga/' . $mangaId . '/feed', [
|
return $this->get('/manga/' . $mangaId . '/feed', [
|
||||||
'limit' => $limit,
|
'limit' => $limit,
|
||||||
'translatedLanguage' => ['en', 'fr'],
|
// 'translatedLanguage' => ['en'],
|
||||||
'order' => ['chapter' => $order],
|
'order' => ['chapter' => $order],
|
||||||
'offset' => $offset,
|
'offset' => $offset,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ readonly class ScrapeChapterHandler
|
|||||||
$chapter->chapterNumber,
|
$chapter->chapterNumber,
|
||||||
$source->getId()->getValue()
|
$source->getId()->getValue()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Ajout de l'ID du chapitre dans le contexte du job
|
||||||
|
$job->context['chapterId'] = $command->chapterId;
|
||||||
|
|
||||||
$job->start();
|
$job->start();
|
||||||
$this->jobRepository->save($job);
|
$this->jobRepository->save($job);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Scraping\Infrastructure\EventSubscriber;
|
||||||
|
|
||||||
|
use App\Domain\Scraping\Domain\Event\ChapterScraped;
|
||||||
|
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
|
||||||
|
use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface;
|
||||||
|
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||||
|
use Symfony\Component\Mercure\HubInterface;
|
||||||
|
use Symfony\Component\Mercure\Update;
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
|
class ScrapingEventSubscriber implements EventSubscriberInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly HubInterface $hub,
|
||||||
|
private readonly ChapterRepositoryInterface $chapterRepository,
|
||||||
|
private readonly JobRepositoryInterface $jobRepository,
|
||||||
|
private readonly LoggerInterface $logger
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getSubscribedEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// Les événements sont capturés via le système de message handlers
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[AsMessageHandler]
|
||||||
|
public function onChapterScraped(ChapterScraped $event): void
|
||||||
|
{
|
||||||
|
$jobId = $event->getJobId();
|
||||||
|
$this->logger->info('ChapterScraped reçu pour le job: ' . $jobId);
|
||||||
|
|
||||||
|
$job = $this->jobRepository->get($jobId);
|
||||||
|
|
||||||
|
if (!$job) {
|
||||||
|
$this->logger->warning('Job non trouvé pour l\'ID: ' . $jobId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer le chapitre associé au job
|
||||||
|
$chapterId = $job->context['chapterId'] ?? null;
|
||||||
|
$this->logger->info('ChapterId extrait du job: ' . $chapterId);
|
||||||
|
|
||||||
|
$chapter = $this->chapterRepository->getById($chapterId);
|
||||||
|
|
||||||
|
if (!$chapter) {
|
||||||
|
$this->logger->warning('Chapitre non trouvé pour l\'ID: ' . $chapterId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->info('Chapitre trouvé - ID: ' . $chapter->id . ', MangaId: ' . $chapter->mangaId . ', Number: ' . $chapter->chapterNumber);
|
||||||
|
|
||||||
|
// Préparer les données à envoyer au front
|
||||||
|
$data = [
|
||||||
|
'type' => 'chapter.scraped',
|
||||||
|
'chapterId' => $chapter->id,
|
||||||
|
'mangaId' => $chapter->mangaId,
|
||||||
|
'chapterNumber' => $chapter->chapterNumber,
|
||||||
|
'isAvailable' => true,
|
||||||
|
'timestamp' => (new \DateTimeImmutable())->format('c')
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->logger->info('Données préparées pour Mercure: ' . json_encode($data));
|
||||||
|
|
||||||
|
// Publier une mise à jour sur le hub Mercure
|
||||||
|
$topics = [
|
||||||
|
'manga/chapter/' . $chapter->id, // Topic spécifique au chapitre
|
||||||
|
'manga/' . $chapter->mangaId . '/chapters', // Topic pour tous les chapitres d'un manga
|
||||||
|
'scraping/status' // Topic général pour les événements de scraping
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->logger->info('Topics Mercure: ' . implode(', ', $topics));
|
||||||
|
|
||||||
|
$update = new Update($topics, json_encode($data));
|
||||||
|
$this->hub->publish($update);
|
||||||
|
|
||||||
|
$this->logger->info('Mise à jour publiée sur Mercure');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[AsMessageHandler]
|
||||||
|
public function onChapterScrapingFailed(ChapterScrapingFailed $event): void
|
||||||
|
{
|
||||||
|
$this->logger->info('ChapterScrapingFailed reçu pour mangaId: ' . $event->getMangaId() . ', chapter: ' . $event->getChapterNumber());
|
||||||
|
|
||||||
|
// Préparer les données à envoyer au front
|
||||||
|
$data = [
|
||||||
|
'type' => 'chapter.scraping.failed',
|
||||||
|
'mangaId' => $event->getMangaId(),
|
||||||
|
'chapterNumber' => $event->getChapterNumber(),
|
||||||
|
'reason' => $event->getReason(),
|
||||||
|
'timestamp' => (new \DateTimeImmutable())->format('c')
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->logger->info('Données préparées pour Mercure: ' . json_encode($data));
|
||||||
|
|
||||||
|
// Publier une mise à jour sur le hub Mercure
|
||||||
|
$topics = [
|
||||||
|
'manga/' . $event->getMangaId() . '/chapters', // Topic pour tous les chapitres d'un manga
|
||||||
|
'scraping/status' // Topic général pour les événements de scraping
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->logger->info('Topics Mercure: ' . implode(', ', $topics));
|
||||||
|
|
||||||
|
$update = new Update($topics, json_encode($data));
|
||||||
|
$this->hub->publish($update);
|
||||||
|
|
||||||
|
$this->logger->info('Mise à jour publiée sur Mercure');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user