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:
parent
77f05b287c
commit
b1b5177d4e
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
|
||||
149
assets/vue/app/domain/manga/presentation/pages/AddManga.vue
Normal file
149
assets/vue/app/domain/manga/presentation/pages/AddManga.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user