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:
parent
15d92d1aff
commit
75f8e1686c
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user