feat: ajout de la fonctionnalité d'édition des mangas, incluant la création d'un modal d'édition, la mise à jour de l'API pour gérer les modifications, et l'intégration de la logique de gestion des erreurs. Tests ajoutés pour valider le bon fonctionnement de l'édition.

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-06-30 20:00:09 +02:00
parent 896c57ac34
commit 9255509042
20 changed files with 1185 additions and 11 deletions

View File

@@ -174,6 +174,25 @@ export class ApiMangaRepository {
}
}
async editManga(mangaId, updateData) {
try {
const response = await fetch(`/api/mangas/${mangaId}/edit`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(updateData)
});
if (!response.ok) {
throw new Error('Failed to edit manga');
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async deleteChapter(chapterId) {
try {
const response = await fetch(`/api/manga/chapters/${chapterId}/cbz`, {

View File

@@ -0,0 +1,371 @@
<template>
<TransitionRoot as="template" :show="isOpen">
<Dialog as="div" class="relative z-50" @close="closeModal">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in duration-200"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</TransitionChild>
<div class="fixed inset-0 z-10 overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-4xl">
<div class="mb-6">
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900">
Edit Manga
</DialogTitle>
</div>
<!-- Error state -->
<div v-if="error" class="mb-6 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
{{ error.message || 'Une erreur est survenue lors de la sauvegarde.' }}
</div>
<!-- Form -->
<form @submit.prevent="saveChanges" class="space-y-6">
<!-- Titre et Slug -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">Titre</label>
<input
id="title"
v-model="formData.title"
type="text"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
placeholder="Titre du manga"
/>
</div>
<div>
<label for="slug" class="block text-sm font-medium text-gray-700 mb-2">Slug</label>
<input
id="slug"
:value="manga?.slug || ''"
type="text"
disabled
class="block w-full rounded-md border-gray-300 bg-gray-50 shadow-sm sm:text-sm text-gray-500"
/>
</div>
</div>
<!-- Année de publication -->
<div>
<label for="publicationYear" class="block text-sm font-medium text-gray-700 mb-2">Année de publication</label>
<input
id="publicationYear"
v-model.number="formData.publicationYear"
type="number"
min="1900"
:max="new Date().getFullYear()"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
placeholder="2023"
/>
</div>
<!-- Description -->
<div>
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">Description</label>
<textarea
id="description"
v-model="formData.description"
rows="4"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
placeholder="Description du manga"
/>
</div>
<!-- Auteur et Statut -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="author" class="block text-sm font-medium text-gray-700 mb-2">Auteur</label>
<input
id="author"
v-model="formData.author"
type="text"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
placeholder="Auteur du manga"
/>
</div>
<div>
<label for="status" class="block text-sm font-medium text-gray-700 mb-2">Statut</label>
<input
id="status"
v-model="formData.status"
type="text"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
placeholder="ongoing"
/>
</div>
</div>
<!-- Note -->
<div>
<label for="rating" class="block text-sm font-medium text-gray-700 mb-2">Note</label>
<input
id="rating"
v-model.number="formData.rating"
type="number"
min="0"
max="10"
step="0.001"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
placeholder="9.541"
/>
</div>
<!-- Slugs alternatifs -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Slugs alternatifs</label>
<div class="space-y-2">
<div v-if="formData.alternativeSlugs.length > 0" class="flex flex-wrap gap-2">
<span
v-for="(slug, index) in formData.alternativeSlugs"
:key="index"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
>
{{ slug }}
<button
type="button"
@click="removeAlternativeSlug(index)"
class="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full text-green-400 hover:text-green-600"
>
<XMarkIcon class="w-3 h-3" />
</button>
</span>
</div>
<button
type="button"
@click="showAlternativeSlugInput = !showAlternativeSlugInput"
class="text-green-600 hover:text-green-700 text-sm font-medium"
>
+ Ajouter un slug alternatif
</button>
<div v-if="showAlternativeSlugInput" class="flex gap-2">
<input
v-model="newAlternativeSlug"
type="text"
class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
placeholder="Nouveau slug alternatif"
@keyup.enter="addAlternativeSlug"
/>
<button
type="button"
@click="addAlternativeSlug"
class="px-3 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm"
>
Ajouter
</button>
</div>
</div>
</div>
<!-- Genres -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Genres</label>
<div class="space-y-3">
<div v-if="formData.genres.length > 0" class="grid grid-cols-2 md:grid-cols-4 gap-2">
<span
v-for="(genre, index) in formData.genres"
:key="index"
class="inline-flex items-center justify-between px-3 py-1 rounded-md text-sm font-medium bg-gray-100 text-gray-800"
>
{{ genre }}
<button
type="button"
@click="removeGenre(index)"
class="ml-2 inline-flex items-center justify-center w-4 h-4 text-gray-400 hover:text-gray-600"
>
<XMarkIcon class="w-3 h-3" />
</button>
</span>
</div>
<button
type="button"
@click="showGenreInput = !showGenreInput"
class="text-green-600 hover:text-green-700 text-sm font-medium"
>
+ Ajouter un genre
</button>
<div v-if="showGenreInput" class="flex gap-2">
<input
v-model="newGenre"
type="text"
class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
placeholder="Nouveau genre"
@keyup.enter="addGenre"
/>
<button
type="button"
@click="addGenre"
class="px-3 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm"
>
Ajouter
</button>
</div>
</div>
</div>
</form>
<!-- Boutons -->
<div class="mt-8 flex justify-end space-x-3">
<button
type="button"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
@click="closeModal"
:disabled="isSaving"
>
Cancel
</button>
<button
type="button"
class="px-4 py-2 text-sm font-medium text-white bg-green-600 border border-transparent rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isSaving"
@click="saveChanges"
>
<div v-if="isSaving" class="flex items-center">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</div>
<span v-else>Save</span>
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup>
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue';
import { XMarkIcon } from '@heroicons/vue/24/outline';
import { ref, watch } from 'vue';
const props = defineProps({
isOpen: {
type: Boolean,
required: true
},
manga: {
type: Object,
default: null
},
isSaving: {
type: Boolean,
default: false
},
error: {
type: Object,
default: null
}
});
const emit = defineEmits(['close', 'save']);
// Données du formulaire
const formData = ref({
title: '',
description: '',
author: '',
publicationYear: null,
status: '',
rating: null,
genres: [],
alternativeSlugs: []
});
// Contrôle de l'affichage des inputs
const showGenreInput = ref(false);
const showAlternativeSlugInput = ref(false);
// Champs temporaires pour ajouter des genres et slugs
const newGenre = ref('');
const newAlternativeSlug = ref('');
// Initialiser le formulaire avec les données du manga
watch(() => props.manga, (newManga) => {
if (newManga) {
formData.value = {
title: newManga.title || '',
description: newManga.description || '',
author: newManga.author || '',
publicationYear: newManga.publicationYear || null,
status: newManga.status || '',
rating: newManga.rating || null,
genres: Array.isArray(newManga.genres) ? [...newManga.genres] : [],
alternativeSlugs: Array.isArray(newManga.alternativeSlugs) ? [...newManga.alternativeSlugs] : []
};
}
}, { immediate: true });
const closeModal = () => {
// Réinitialiser les états d'affichage
showGenreInput.value = false;
showAlternativeSlugInput.value = false;
newGenre.value = '';
newAlternativeSlug.value = '';
emit('close');
};
const saveChanges = () => {
// Nettoyer les données avant de les envoyer
const dataToSave = {
title: formData.value.title || undefined,
description: formData.value.description || undefined,
author: formData.value.author || undefined,
publicationYear: formData.value.publicationYear || undefined,
status: formData.value.status || undefined,
rating: formData.value.rating || undefined,
genres: formData.value.genres.length > 0 ? formData.value.genres : undefined,
alternativeSlugs: formData.value.alternativeSlugs.length > 0 ? formData.value.alternativeSlugs : undefined
};
// Supprimer les valeurs undefined
Object.keys(dataToSave).forEach(key => {
if (dataToSave[key] === undefined) {
delete dataToSave[key];
}
});
emit('save', dataToSave);
};
const addGenre = () => {
if (newGenre.value.trim() && !formData.value.genres.includes(newGenre.value.trim())) {
formData.value.genres.push(newGenre.value.trim());
newGenre.value = '';
showGenreInput.value = false;
}
};
const removeGenre = (index) => {
formData.value.genres.splice(index, 1);
};
const addAlternativeSlug = () => {
if (newAlternativeSlug.value.trim() && !formData.value.alternativeSlugs.includes(newAlternativeSlug.value.trim())) {
formData.value.alternativeSlugs.push(newAlternativeSlug.value.trim());
newAlternativeSlug.value = '';
showAlternativeSlugInput.value = false;
}
};
const removeAlternativeSlug = (index) => {
formData.value.alternativeSlugs.splice(index, 1);
};
</script>

View File

@@ -0,0 +1,48 @@
import { useMutation, useQueryClient } from '@tanstack/vue-query';
import { ref } from 'vue';
import { ApiMangaRepository } from '../../infrastructure/api/apiMangaRepository';
export function useMangaEdit() {
const mangaRepository = new ApiMangaRepository();
const queryClient = useQueryClient();
const isEditModalOpen = ref(false);
const editMutation = useMutation({
mutationFn: ({ mangaId, updateData }) => {
return mangaRepository.editManga(mangaId, updateData);
},
onSuccess: (data, variables) => {
// Invalider et refetch les données du manga
queryClient.invalidateQueries({ queryKey: ['manga', variables.mangaId] });
queryClient.invalidateQueries({ queryKey: ['mangas'] });
}
});
const openEditModal = () => {
isEditModalOpen.value = true;
};
const closeEditModal = () => {
isEditModalOpen.value = false;
};
const editManga = async (mangaId, updateData) => {
try {
await editMutation.mutateAsync({ mangaId, updateData });
closeEditModal();
} catch (error) {
console.error('Erreur lors de l\'édition du manga:', error);
throw error;
}
};
return {
isEditModalOpen,
openEditModal,
closeEditModal,
editManga,
isLoading: editMutation.isPending,
error: editMutation.error,
isSuccess: editMutation.isSuccess
};
}

View File

@@ -37,6 +37,16 @@
@close="closePreferredSourcesModal"
@save="savePreferredSources"
/>
<!-- Modale d'édition du manga -->
<MangaEditModal
:is-open="isEditModalOpen"
:manga="currentManga"
:is-saving="isEditLoading"
:error="editError"
@close="closeEditModal"
@save="saveMangaEdit"
/>
</div>
<div v-else-if="isLoadingDetails" class="flex justify-center items-center h-64">
@@ -64,10 +74,12 @@ import { computed, onUnmounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useMangaDetails } from '../composables/useMangaDetails';
import { useMangaEdit } from '../composables/useMangaEdit';
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
import { useMangaVolumes } from '../composables/useMangaVolumes';
import MangaHeader from '../components/MangaHeader.vue';
import MangaEditModal from '../components/MangaEditModal.vue';
import MangaHeader from '../components/MangaHeader.vue';
import MangaPreferredSourcesModal from '../components/MangaPreferredSourcesModal.vue';
import MangaVolumeList from '../components/MangaVolumeList.vue';
import MercureListener from '../components/MercureListener.vue';
@@ -105,6 +117,16 @@ import { useMangaStore } from '../../application/store/mangaStore';
savePreferredSources: saveSourcesOrder
} = useMangaPreferredSources(mangaId);
// Composable pour l'édition des mangas
const {
isEditModalOpen,
openEditModal,
closeEditModal,
editManga,
isLoading: isEditLoading,
error: editError
} = useMangaEdit();
// Charger les chapitres dans le store quand le manga est chargé
watch(
mangaId,
@@ -133,6 +155,15 @@ import { useMangaStore } from '../../application/store/mangaStore';
}
};
// Fonction pour sauvegarder l'édition du manga
const saveMangaEdit = async (updateData) => {
try {
await editManga(mangaId.value, updateData);
} catch (error) {
console.error('Erreur lors de l\'édition du manga:', error);
}
};
const toolbarConfig = computed(() => ({
leftSection: [
{
@@ -171,7 +202,7 @@ import { useMangaStore } from '../../application/store/mangaStore';
icon: WrenchIcon,
label: 'Edit',
type: 'button',
onClick: () => console.log('Edit')
onClick: openEditModal
},
{
icon: TrashIcon,