feat: ajout de la gestion des sources préférées pour les mangas, incluant la récupération et la configuration des sources via l'API, ainsi que l'intégration d'une modale pour l'interface utilisateur.

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-06-20 15:33:54 +02:00
parent 15d92d1aff
commit 75f8e1686c
22 changed files with 1168 additions and 41 deletions

View File

@@ -141,4 +141,36 @@ export class ApiMangaRepository {
throw error;
}
}
async getPreferredSources(mangaId) {
try {
const response = await fetch(`/api/mangas/${mangaId}/preferred-sources`);
if (!response.ok) {
throw new Error('Failed to fetch preferred sources');
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async setPreferredSources(mangaId, sourceIds) {
try {
const response = await fetch(`/api/mangas/${mangaId}/preferred-sources`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ sourceIds })
});
if (!response.ok) {
throw new Error('Failed to set preferred sources');
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
}

View File

@@ -0,0 +1,191 @@
<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-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
<div>
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-100">
<Cog6ToothIcon class="h-6 w-6 text-blue-600" aria-hidden="true" />
</div>
<div class="mt-3 text-center sm:mt-5">
<DialogTitle as="h3" class="text-base font-semibold leading-6 text-gray-900">
Sources préférées
</DialogTitle>
<div class="mt-2">
<p class="text-sm text-gray-500">
Configurez l'ordre de priorité des sources pour ce manga. Les sources en haut de la liste seront privilégiées.
</p>
</div>
</div>
</div>
<!-- Loading state -->
<div v-if="isLoading" class="mt-5 flex justify-center items-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
<!-- Error state -->
<div v-else-if="error" class="mt-5 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
{{ error.message || 'Une erreur est survenue lors du chargement des sources.' }}
</div>
<!-- Sources list -->
<div v-else class="mt-5">
<div v-if="localSources.length === 0" class="text-center py-8 text-gray-500">
Aucune source disponible
</div>
<div v-else class="space-y-2">
<div
v-for="(source, index) in localSources"
:key="source.id"
class="flex items-center p-3 bg-gray-50 rounded-lg border border-gray-200"
>
<div class="flex items-center space-x-2 mr-3">
<button
type="button"
class="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-50"
:disabled="index === 0"
@click="moveUp(index)"
>
<ChevronUpIcon class="h-4 w-4" />
</button>
<button
type="button"
class="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-50"
:disabled="index === localSources.length - 1"
@click="moveDown(index)"
>
<ChevronDownIcon class="h-4 w-4" />
</button>
</div>
<div class="flex-1">
<div class="font-medium text-gray-900">{{ source.name }}</div>
<div class="text-sm text-gray-500">{{ source.description || source.baseUrl }}</div>
</div>
<div class="text-sm text-gray-400">
Priorité {{ index + 1 }}
</div>
</div>
</div>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<button
type="button"
class="inline-flex w-full justify-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 sm:col-start-2 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isSaving || isLoading"
@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>
Sauvegarde...
</div>
<span v-else>Sauvegarder</span>
</button>
<button
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:col-start-1 sm:mt-0"
@click="closeModal"
:disabled="isSaving"
>
Annuler
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup>
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue';
import { ChevronDownIcon, ChevronUpIcon, Cog6ToothIcon } from '@heroicons/vue/24/outline';
import { ref, watch } from 'vue';
const props = defineProps({
isOpen: {
type: Boolean,
required: true
},
sources: {
type: Array,
default: () => []
},
isLoading: {
type: Boolean,
default: false
},
error: {
type: Object,
default: null
},
isSaving: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['close', 'save']);
// Copie locale des sources pour le drag & drop
const localSources = ref([]);
// Watcher pour mettre à jour la copie locale quand les props changent
watch(
() => props.sources,
(newSources) => {
localSources.value = [...newSources];
},
{ immediate: true, deep: true }
);
const closeModal = () => {
emit('close');
};
const moveUp = (index) => {
if (index > 0) {
const sources = [...localSources.value];
[sources[index - 1], sources[index]] = [sources[index], sources[index - 1]];
localSources.value = sources;
}
};
const moveDown = (index) => {
if (index < localSources.value.length - 1) {
const sources = [...localSources.value];
[sources[index], sources[index + 1]] = [sources[index + 1], sources[index]];
localSources.value = sources;
}
};
const saveChanges = () => {
// Extraire seulement les IDs dans l'ordre actuel
const sourceIds = localSources.value.map(source => source.id);
emit('save', sourceIds);
};
</script>

View File

@@ -0,0 +1,52 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
import { computed } from 'vue';
import { ApiMangaRepository } from '../../infrastructure/api/apiMangaRepository';
export function useMangaPreferredSources(mangaId) {
const mangaRepository = new ApiMangaRepository();
const queryClient = useQueryClient();
// Query pour récupérer les sources préférées
const preferredSourcesQuery = useQuery({
queryKey: ['manga', mangaId, 'preferred-sources'],
queryFn: () => {
if (!mangaId.value) {
return Promise.resolve(null);
}
return mangaRepository.getPreferredSources(mangaId.value);
},
enabled: computed(() => !!mangaId.value)
});
// Mutation pour sauvegarder les sources préférées
const setPreferredSourcesMutation = useMutation({
mutationFn: ({ sourceIds }) => {
return mangaRepository.setPreferredSources(mangaId.value, sourceIds);
},
onSuccess: () => {
// Invalider le cache pour refaire la requête de récupération
queryClient.invalidateQueries({
queryKey: ['manga', mangaId.value, 'preferred-sources']
});
}
});
const sources = computed(() => preferredSourcesQuery.data.value?.sources || []);
const hasPreferredSources = computed(() => preferredSourcesQuery.data.value?.hasPreferredSources || false);
const isLoading = computed(() => preferredSourcesQuery.isLoading.value);
const error = computed(() => preferredSourcesQuery.error.value);
const isSaving = computed(() => setPreferredSourcesMutation.isPending.value);
const savePreferredSources = (sourceIds) => {
return setPreferredSourcesMutation.mutate({ sourceIds });
};
return {
sources,
hasPreferredSources,
isLoading,
error,
isSaving,
savePreferredSources
};
}

View File

@@ -24,6 +24,17 @@
</div>
<MangaVolumeList v-else :volumes="volumes" :manga-slug="currentManga.slug" />
</div>
<!-- Modale des sources préférées -->
<MangaPreferredSourcesModal
:is-open="isPreferredSourcesModalOpen"
:sources="preferredSources"
:is-loading="isLoadingSources"
:error="sourcesError"
:is-saving="isSavingSources"
@close="closePreferredSourcesModal"
@save="savePreferredSources"
/>
</div>
<div v-else-if="isLoadingDetails" class="flex justify-center items-center h-64">
@@ -35,33 +46,38 @@
<script setup>
import {
ArrowPathIcon,
BookmarkIcon,
ChevronDoubleDownIcon,
Cog6ToothIcon,
DocumentArrowDownIcon,
PencilSquareIcon,
TrashIcon,
WrenchIcon
} from '@heroicons/vue/24/outline';
import { computed, onUnmounted, watch } from 'vue';
import { useRoute } from 'vue-router';
ArrowPathIcon,
BookmarkIcon,
ChevronDoubleDownIcon,
Cog6ToothIcon,
DocumentArrowDownIcon,
PencilSquareIcon,
TrashIcon,
WrenchIcon
} from '@heroicons/vue/24/outline';
import { computed, onUnmounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useMangaDetails } from '../composables/useMangaDetails';
import { useMangaVolumes } from '../composables/useMangaVolumes';
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
import { useMangaVolumes } from '../composables/useMangaVolumes';
import MangaHeader from '../components/MangaHeader.vue';
import MangaVolumeList from '../components/MangaVolumeList.vue';
import MercureListener from '../components/MercureListener.vue';
import MangaPreferredSourcesModal from '../components/MangaPreferredSourcesModal.vue';
import MangaVolumeList from '../components/MangaVolumeList.vue';
import MercureListener from '../components/MercureListener.vue';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useMangaStore } from '../../application/store/mangaStore';
import { useMangaStore } from '../../application/store/mangaStore';
const route = useRoute();
const mangaStore = useMangaStore();
const mangaId = computed(() => route.params.id || null);
// État de la modale
const isPreferredSourcesModalOpen = ref(false);
const {
data: currentManga,
isLoading: isLoadingDetails,
@@ -76,6 +92,14 @@
error: errorVolumes
} = useMangaVolumes(mangaId);
const {
sources: preferredSources,
isLoading: isLoadingSources,
error: sourcesError,
isSaving: isSavingSources,
savePreferredSources: saveSourcesOrder
} = useMangaPreferredSources(mangaId);
// Charger les chapitres dans le store quand le manga est chargé
watch(
mangaId,
@@ -87,6 +111,23 @@
{ immediate: true }
);
const openPreferredSourcesModal = () => {
isPreferredSourcesModalOpen.value = true;
};
const closePreferredSourcesModal = () => {
isPreferredSourcesModalOpen.value = false;
};
const savePreferredSources = async (sourceIds) => {
try {
await saveSourcesOrder(sourceIds);
closePreferredSourcesModal();
} catch (error) {
console.error('Erreur lors de la sauvegarde des sources préférées:', error);
}
};
const toolbarConfig = computed(() => ({
leftSection: [
{
@@ -111,7 +152,7 @@
icon: Cog6ToothIcon,
label: 'Preferred Sources',
type: 'button',
onClick: () => console.log('Preferred Sources')
onClick: openPreferredSourcesModal
}
],
rightSection: [