feat: ajout de la fonctionnalité de recherche et d'ajout de mangas, avec mise à jour du store pour gérer les états de recherche et d'ajout, ainsi que création d'une nouvelle page AddManga pour l'interface utilisateur

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-03-30 18:06:46 +02:00
parent 77f05b287c
commit b1b5177d4e
5 changed files with 316 additions and 89 deletions

View File

@@ -26,7 +26,16 @@ export const useMangaStore = defineStore('manga', {
// --- Selected Manga State ---
// Gardé pour savoir quel manga est sélectionné dans l'UI,
// mais les données détaillées ne sont plus stockées ici.
currentMangaId: null
currentMangaId: null,
// --- Search State ---
searchResults: [],
loadingSearch: false,
searchError: null,
// --- Add Manga State ---
addingManga: false,
addMangaError: null
}),
getters: {
@@ -75,8 +84,44 @@ export const useMangaStore = defineStore('manga', {
clearCurrentMangaFocus() {
this.currentMangaId = null;
}
},
// Plus d'actions fetchMangaDetails / fetchMangaChapters ici
// --- Search Actions ---
async searchMangaDex(query) {
if (this.loadingSearch) return;
this.loadingSearch = true;
this.searchError = null;
this.searchResults = [];
try {
const data = await mangaRepository.searchMangaDex(query);
this.searchResults = data.items || [];
} catch (error) {
this.searchError = error.message;
throw error;
} finally {
this.loadingSearch = false;
}
},
// --- Add Manga Actions ---
async createFromMangaDex(externalId) {
if (this.addingManga) return;
this.addingManga = true;
this.addMangaError = null;
try {
await mangaRepository.createFromMangaDex(externalId);
// Rafraîchir la collection après l'ajout
await this.loadCollection();
} catch (error) {
this.addMangaError = error.message;
throw error;
} finally {
this.addingManga = false;
}
}
}
});

View File

@@ -1,90 +1,122 @@
import { MangaCollection } from '../../domain/entities/manga';
export class ApiMangaRepository {
async getCollection() {
try {
const response = await fetch('/api/mangas');
if (!response.ok) {
throw new Error('Failed to fetch manga collection');
}
const data = await response.json();
return new MangaCollection(
data.items,
data.total,
data.page,
data.limit,
data.hasNextPage,
data.hasPreviousPage
);
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async getMangaById(id) {
try {
const response = await fetch(`/api/mangas/by-id/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch manga details');
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async getChapters(mangaId) {
try {
let allChapters = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const response = await fetch(`/api/mangas/${mangaId}/chapters?limit=500&page=${page}`);
if (!response.ok) {
throw new Error('Failed to fetch manga chapters');
async getCollection() {
try {
const response = await fetch('/api/mangas');
if (!response.ok) {
throw new Error('Failed to fetch manga collection');
}
const data = await response.json();
return new MangaCollection(
data.items,
data.total,
data.page,
data.limit,
data.hasNextPage,
data.hasPreviousPage
);
} catch (error) {
console.error('API Error:', error);
throw error;
}
const data = await response.json();
allChapters = allChapters.concat(data.items);
hasMore = data.hasNextPage;
page++;
}
return {
items: allChapters,
total: allChapters.length
};
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async getMangaBySlug(slug) {
try {
const response = await fetch(`/api/mangas/by-slug/${slug}`);
if (!response.ok) {
throw new Error('Failed to fetch manga details');
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
async getMangaById(id) {
try {
const response = await fetch(`/api/mangas/by-id/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch manga details');
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
}
async searchMangas(query) {
try {
const response = await fetch(`/api/mangas/search?q=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error('Failed to search mangas');
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
async getChapters(mangaId) {
try {
let allChapters = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const response = await fetch(`/api/mangas/${mangaId}/chapters?limit=500&page=${page}`);
if (!response.ok) {
throw new Error('Failed to fetch manga chapters');
}
const data = await response.json();
allChapters = allChapters.concat(data.items);
hasMore = data.hasNextPage;
page++;
}
return {
items: allChapters,
total: allChapters.length
};
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async getMangaBySlug(slug) {
try {
const response = await fetch(`/api/mangas/by-slug/${slug}`);
if (!response.ok) {
throw new Error('Failed to fetch manga details');
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async searchMangas(query) {
try {
const response = await fetch(`/api/mangas/search?q=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error('Failed to search mangas');
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async searchMangaDex(query) {
try {
const response = await fetch(`https://localhost/api/mangadex-search?title=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error('Failed to search MangaDex');
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async createFromMangaDex(externalId) {
try {
const response = await fetch('https://localhost/api/mangas/create-from-mangadex', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ externalId })
});
if (!response.ok) {
throw new Error('Failed to create manga from MangaDex');
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
}
}

View File

@@ -3,7 +3,8 @@
<div
v-for="manga in mangas"
:key="manga.id"
class="flex bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg p-4 space-x-4">
class="flex bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg p-4 space-x-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
@click="$emit('manga-click', manga)">
<!-- Cover Image -->
<div class="flex-shrink-0">
<img :src="manga.imageUrl || '/placeholder-cover.png'" alt="" class="h-48 w-32 object-cover rounded" />
@@ -30,7 +31,9 @@
</template>
<script setup>
import { defineProps } from 'vue';
import { defineProps, defineEmits } from 'vue';
const emit = defineEmits(['manga-click']);
const props = defineProps({
mangas: {
@@ -39,8 +42,6 @@
}
});
console.log(props.mangas);
const formatDate = dateString => {
if (!dateString) return '';
const options = { year: 'numeric', month: 'long', day: 'numeric' };

View File

@@ -0,0 +1,149 @@
<template>
<div class="container mx-auto px-4 py-8">
<!-- Barre de recherche -->
<div class="mb-8">
<div class="flex gap-4">
<input
type="text"
v-model="searchQuery"
@keyup.enter="performSearch"
placeholder="Rechercher un manga..."
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<button
@click="performSearch"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
Rechercher
</button>
</div>
</div>
<!-- État de chargement -->
<div v-if="loading" class="text-center py-8">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p class="mt-4 text-gray-600">Recherche en cours...</p>
</div>
<!-- Message d'erreur -->
<div v-if="error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-6">
{{ error }}
</div>
<!-- Résultats de recherche -->
<MangaList v-if="searchResults.length > 0" :mangas="searchResults" @manga-click="openMangaModal" />
<p v-else-if="!loading && searchQuery" class="text-center text-gray-600">Aucun résultat trouvé</p>
<!-- Modal de confirmation -->
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" />
<div class="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel class="w-full max-w-lg bg-white rounded-xl shadow-xl p-6">
<DialogTitle class="text-lg mb-4"> Ajouter à la bibliothèque </DialogTitle>
<div v-if="selectedManga">
<div class="flex gap-4">
<img
:src="selectedManga.imageUrl || '/placeholder-cover.png'"
:alt="selectedManga.title"
class="h-48 w-32 object-cover" />
<div class="flex-1 min-w-0">
<h4 class="text-lg">{{ selectedManga.title }}</h4>
<p class="mt-2">
{{ truncatedDescription }}
</p>
</div>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
@click="closeModal"
class="px-4 py-2 rounded-lg border border-gray-300 hover:bg-gray-50">
Annuler
</button>
<button
type="button"
@click="addManga"
:disabled="adding"
class="px-4 py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center">
<span v-if="adding" class="mr-2">
<ArrowPathIcon class="h-5 w-5 animate-spin" />
</span>
{{ adding ? 'Ajout en cours...' : 'Ajouter' }}
</button>
</div>
</DialogPanel>
</div>
</Dialog>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { storeToRefs } from 'pinia';
import { useMangaStore } from '../../application/store/mangaStore';
import MangaList from '../components/MangaList.vue';
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
import { ArrowPathIcon } from '@heroicons/vue/24/solid';
const router = useRouter();
const route = useRoute();
const mangaStore = useMangaStore();
const searchQuery = ref('');
const isModalOpen = ref(false);
const selectedManga = ref(null);
// Récupération des états du store
const { searchResults, loadingSearch: loading, searchError: error, addingManga: adding } = storeToRefs(mangaStore);
const truncatedDescription = computed(() => {
if (!selectedManga.value?.description) return '';
return selectedManga.value.description.length > 500
? selectedManga.value.description.slice(0, 500) + '...'
: selectedManga.value.description;
});
// Effectuer la recherche au chargement si un paramètre q est présent
onMounted(() => {
const queryParam = route.query.q;
if (queryParam) {
searchQuery.value = queryParam;
performSearch();
}
});
const performSearch = async () => {
if (!searchQuery.value.trim()) return;
try {
await mangaStore.searchMangaDex(searchQuery.value);
} catch (e) {
console.error('Erreur de recherche:', e);
}
};
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>

View File

@@ -4,6 +4,7 @@ import HomePage from '../domain/manga/presentation/pages/HomePage.vue';
import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue';
import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue';
import ActivityPage from '../domain/activity/presentation/pages/ActivityPage.vue';
import AddManga from '../domain/manga/presentation/pages/AddManga.vue';
// Placeholder component for new routes
const PlaceholderComponent = {
@@ -44,8 +45,7 @@ const routes = [
{
path: '/add',
name: 'add-manga',
component: PlaceholderComponent,
props: { title: 'Ajouter un manga' }
component: AddManga
},
{
path: '/reader/:chapterId',