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 ---
|
// --- Selected Manga State ---
|
||||||
// Gardé pour savoir quel manga est sélectionné dans l'UI,
|
// Gardé pour savoir quel manga est sélectionné dans l'UI,
|
||||||
// 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,
|
||||||
|
|
||||||
|
// --- Search State ---
|
||||||
|
searchResults: [],
|
||||||
|
loadingSearch: false,
|
||||||
|
searchError: null,
|
||||||
|
|
||||||
|
// --- Add Manga State ---
|
||||||
|
addingManga: false,
|
||||||
|
addMangaError: null
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
@@ -75,8 +84,44 @@ export const useMangaStore = defineStore('manga', {
|
|||||||
|
|
||||||
clearCurrentMangaFocus() {
|
clearCurrentMangaFocus() {
|
||||||
this.currentMangaId = null;
|
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';
|
import { MangaCollection } from '../../domain/entities/manga';
|
||||||
|
|
||||||
export class ApiMangaRepository {
|
export class ApiMangaRepository {
|
||||||
async getCollection() {
|
async getCollection() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/mangas');
|
const response = await fetch('/api/mangas');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch manga collection');
|
throw new Error('Failed to fetch manga collection');
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return new MangaCollection(
|
return new MangaCollection(
|
||||||
data.items,
|
data.items,
|
||||||
data.total,
|
data.total,
|
||||||
data.page,
|
data.page,
|
||||||
data.limit,
|
data.limit,
|
||||||
data.hasNextPage,
|
data.hasNextPage,
|
||||||
data.hasPreviousPage
|
data.hasPreviousPage
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API Error:', error);
|
console.error('API Error:', error);
|
||||||
throw 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');
|
|
||||||
}
|
}
|
||||||
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) {
|
async getMangaById(id) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/mangas/by-slug/${slug}`);
|
const response = await fetch(`/api/mangas/by-id/${id}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch manga details');
|
throw new Error('Failed to fetch manga details');
|
||||||
}
|
}
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API Error:', error);
|
console.error('API Error:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async searchMangas(query) {
|
async getChapters(mangaId) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/mangas/search?q=${encodeURIComponent(query)}`);
|
let allChapters = [];
|
||||||
if (!response.ok) {
|
let page = 1;
|
||||||
throw new Error('Failed to search mangas');
|
let hasMore = true;
|
||||||
}
|
|
||||||
return await response.json();
|
while (hasMore) {
|
||||||
} catch (error) {
|
const response = await fetch(`/api/mangas/${mangaId}/chapters?limit=500&page=${page}`);
|
||||||
console.error('API Error:', error);
|
if (!response.ok) {
|
||||||
throw error;
|
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
|
<div
|
||||||
v-for="manga in mangas"
|
v-for="manga in mangas"
|
||||||
:key="manga.id"
|
: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 -->
|
<!-- Cover Image -->
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<img :src="manga.imageUrl || '/placeholder-cover.png'" alt="" class="h-48 w-32 object-cover rounded" />
|
<img :src="manga.imageUrl || '/placeholder-cover.png'" alt="" class="h-48 w-32 object-cover rounded" />
|
||||||
@@ -30,7 +31,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps } from 'vue';
|
import { defineProps, defineEmits } from 'vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['manga-click']);
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
mangas: {
|
mangas: {
|
||||||
@@ -39,8 +42,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(props.mangas);
|
|
||||||
|
|
||||||
const formatDate = dateString => {
|
const formatDate = dateString => {
|
||||||
if (!dateString) return '';
|
if (!dateString) return '';
|
||||||
const options = { year: 'numeric', month: 'long', day: 'numeric' };
|
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 MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue';
|
||||||
import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue';
|
import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue';
|
||||||
import ActivityPage from '../domain/activity/presentation/pages/ActivityPage.vue';
|
import ActivityPage from '../domain/activity/presentation/pages/ActivityPage.vue';
|
||||||
|
import AddManga from '../domain/manga/presentation/pages/AddManga.vue';
|
||||||
|
|
||||||
// Placeholder component for new routes
|
// Placeholder component for new routes
|
||||||
const PlaceholderComponent = {
|
const PlaceholderComponent = {
|
||||||
@@ -44,8 +45,7 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: '/add',
|
path: '/add',
|
||||||
name: 'add-manga',
|
name: 'add-manga',
|
||||||
component: PlaceholderComponent,
|
component: AddManga
|
||||||
props: { title: 'Ajouter un manga' }
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/reader/:chapterId',
|
path: '/reader/:chapterId',
|
||||||
|
|||||||
Reference in New Issue
Block a user