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
@@ -11,14 +11,13 @@
|
||||
params: {
|
||||
chapterId: chapter.id
|
||||
}
|
||||
}"
|
||||
class="text-green-500">
|
||||
}">
|
||||
{{ chapter.title || 'Sans titre' }}
|
||||
</router-link>
|
||||
<span v-else>{{ chapter.title || 'Sans titre' }}</span>
|
||||
</td>
|
||||
<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" />
|
||||
</button>
|
||||
<button v-else @click="handleDelete" class="text-gray-500 hover:text-green-500">
|
||||
@@ -35,7 +34,8 @@
|
||||
</template>
|
||||
|
||||
<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';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -50,11 +50,38 @@
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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);
|
||||
} catch (error) {
|
||||
// En cas d'erreur, on arrête le chargement
|
||||
isLoading.value = false;
|
||||
console.error('Erreur lors de la recherche du chapitre:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -32,8 +32,14 @@
|
||||
|
||||
<!-- Actions du volume -->
|
||||
<div class="flex space-x-2 text-xl text-bold">
|
||||
<button class="w-8 text-center" @click="handleSearch">
|
||||
<MagnifyingGlassIcon class="h-6 w-6 text-gray-500 hover:text-green-500" />
|
||||
<button
|
||||
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 class="w-8 text-center" @click="handleDownload">
|
||||
<ArrowDownTrayIcon class="h-6 w-6 text-gray-500 hover:text-green-500" />
|
||||
@@ -54,16 +60,16 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
ArrowDownTrayIcon,
|
||||
BookmarkIcon,
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
MagnifyingGlassIcon,
|
||||
ArrowDownTrayIcon
|
||||
ChevronUpIcon,
|
||||
MagnifyingGlassIcon
|
||||
} from '@heroicons/vue/24/outline';
|
||||
import MangaChapterList from './MangaChapterList.vue';
|
||||
import { ref } from 'vue';
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
import MangaChapterList from './MangaChapterList.vue';
|
||||
|
||||
const props = defineProps({
|
||||
volume: {
|
||||
@@ -82,14 +88,48 @@
|
||||
|
||||
const store = useMangaStore();
|
||||
const isOpen = ref(props.isOpen);
|
||||
const isSearching = ref(false);
|
||||
|
||||
const toggleVolume = () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
// TODO: Implémenter la recherche du volume
|
||||
console.log('Recherche du volume:', props.volume.number);
|
||||
if (isSearching.value) return; // Éviter les clicks multiples
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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) {
|
||||
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);
|
||||
|
||||
// Log pour déboguer
|
||||
console.log(`useMangaChapters: ${response.items?.length || 0} chapitres chargés`);
|
||||
|
||||
// Assure de toujours retourner un tableau
|
||||
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.)
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
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
|
||||
const volumes = computed(() => {
|
||||
console.log('useMangaVolumes: Recalcul des volumes');
|
||||
const chaptersArray = rawChaptersData.value || []; // Utilise la data retournée par useMangaChapters
|
||||
if (chaptersArray.length === 0) return [];
|
||||
|
||||
const volumeMap = new Map();
|
||||
chaptersArray.forEach(chapter => {
|
||||
const volumeNumber = chapter.volume || 'Unknown';
|
||||
const volumeNumber = chapter.volume || '00';
|
||||
if (!volumeMap.has(volumeNumber)) {
|
||||
volumeMap.set(volumeNumber, {
|
||||
number: volumeNumber,
|
||||
@@ -33,8 +54,13 @@ export function useMangaVolumes(mangaId) {
|
||||
});
|
||||
|
||||
return Array.from(volumeMap.values()).sort((a, b) => {
|
||||
const numA = a.number === 'Unknown' ? -Infinity : Number(a.number);
|
||||
const numB = b.number === 'Unknown' ? -Infinity : Number(b.number);
|
||||
// Cas spécial pour le volume 00, qui doit apparaître en premier
|
||||
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;
|
||||
});
|
||||
});
|
||||
@@ -45,7 +71,8 @@ export function useMangaVolumes(mangaId) {
|
||||
isLoading, // L'état de chargement initial des chapitres
|
||||
isFetching, // L'état de rafraîchissement 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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
</div>
|
||||
|
||||
<div v-else-if="currentManga" class="relative">
|
||||
<!-- Composant invisible qui écoute les mises à jour Mercure -->
|
||||
<MercureListener :manga-id="mangaId" />
|
||||
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<div v-if="isRefreshingDetails" class="absolute top-2 right-2 text-gray-500">
|
||||
@@ -31,7 +34,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, watch } from 'vue';
|
||||
import { computed, onUnmounted, watch, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
@@ -49,6 +52,7 @@
|
||||
|
||||
import MangaHeader from '../components/MangaHeader.vue';
|
||||
import MangaVolumeList from '../components/MangaVolumeList.vue';
|
||||
import MercureListener from '../components/MercureListener.vue';
|
||||
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
@@ -72,6 +76,17 @@
|
||||
error: errorVolumes
|
||||
} = 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(() => ({
|
||||
leftSection: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user