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:
ext.jeremy.guillot@maxicoffee.domains
2025-04-04 16:06:32 +02:00
parent e51712a800
commit 5928cfd5f0
11 changed files with 539 additions and 39 deletions

View File

@@ -28,6 +28,11 @@ export const useMangaStore = defineStore('manga', {
// mais les données détaillées ne sont plus stockées ici.
currentMangaId: null,
// --- Manga Chapters State ---
mangaChapters: {},
loadingChapters: false,
chaptersError: null,
// --- Search State ---
searchResults: [],
loadingSearch: false,
@@ -86,6 +91,60 @@ export const useMangaStore = defineStore('manga', {
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 ---
async searchMangaDex(query) {
if (this.loadingSearch) return;
@@ -130,16 +189,13 @@ export const useMangaStore = defineStore('manga', {
}
},
// --- Chapter Actions ---
// --- Scrape Chapter Action ---
async searchChapter(chapterId) {
try {
await mangaRepository.searchChapter(chapterId);
// Rafraîchir la collection après la recherche
await this.refreshCollectionInBackground();
return true;
} catch (error) {
console.error('Erreur lors de la recherche du chapitre:', error);
return false;
throw error;
}
}
}

View File

@@ -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);
}
};

View File

@@ -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 () => {

View File

@@ -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>

View File

@@ -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.)

View File

@@ -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
};
}

View File

@@ -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: [
{