278 lines
14 KiB
Vue
278 lines
14 KiB
Vue
<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. Glissez-déposez les sources pour les réorganiser.
|
|
</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-3">
|
|
<div
|
|
v-for="(source, index) in localSources"
|
|
:key="source.id"
|
|
:class="[
|
|
'group relative flex items-center p-4 rounded-lg border-2 transition-all duration-200 cursor-grab active:cursor-grabbing select-none',
|
|
{
|
|
'bg-gradient-to-r from-blue-50 to-indigo-50 border-blue-300 shadow-md': index === 0,
|
|
'bg-gradient-to-r from-green-50 to-emerald-50 border-green-300': index === 1,
|
|
'bg-gradient-to-r from-yellow-50 to-amber-50 border-yellow-300': index === 2,
|
|
'bg-gray-50 border-gray-200': index > 2,
|
|
'scale-105 shadow-lg border-blue-400': draggedIndex === index,
|
|
'opacity-50': dragOverIndex === index && draggedIndex !== index,
|
|
'scale-95 active:scale-95': isPressed === index
|
|
}
|
|
]"
|
|
draggable="true"
|
|
@dragstart="handleDragStart(index, $event)"
|
|
@dragover="handleDragOver(index, $event)"
|
|
@dragleave="handleDragLeave"
|
|
@drop="handleDrop(index, $event)"
|
|
@dragend="handleDragEnd"
|
|
@mousedown="handleMouseDown(index)"
|
|
@mouseup="handleMouseUp"
|
|
@mouseleave="handleMouseUp"
|
|
>
|
|
<!-- Badge de priorité -->
|
|
<div class="absolute -top-2 -left-2 z-10">
|
|
<div :class="[
|
|
'inline-flex items-center justify-center w-8 h-8 rounded-full text-sm font-bold text-white shadow-lg',
|
|
{
|
|
'bg-gradient-to-r from-blue-500 to-indigo-600': index === 0,
|
|
'bg-gradient-to-r from-green-500 to-emerald-600': index === 1,
|
|
'bg-gradient-to-r from-yellow-500 to-amber-600': index === 2,
|
|
'bg-gradient-to-r from-gray-500 to-slate-600': index > 2
|
|
}
|
|
]">
|
|
{{ index + 1 }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Indicateur de priorité -->
|
|
<div class="mr-4">
|
|
<div :class="[
|
|
'flex items-center space-x-1 px-3 py-1 rounded-full text-xs font-semibold',
|
|
{
|
|
'bg-blue-100 text-blue-800': index === 0,
|
|
'bg-green-100 text-green-800': index === 1,
|
|
'bg-yellow-100 text-yellow-800': index === 2,
|
|
'bg-gray-100 text-gray-600': index > 2
|
|
}
|
|
]">
|
|
<span v-if="index === 0">🥇 Priorité haute</span>
|
|
<span v-else-if="index === 1">🥈 Priorité moyenne</span>
|
|
<span v-else-if="index === 2">🥉 Priorité basse</span>
|
|
<span v-else>Priorité {{ index + 1 }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Informations de la source -->
|
|
<div class="flex-1 min-w-0">
|
|
<div class="font-semibold text-gray-900 truncate">{{ source.name }}</div>
|
|
<div class="text-sm text-gray-600 truncate">
|
|
<a :href="source.baseUrl" target="_blank" class="hover:text-blue-600 hover:underline">{{ source.baseUrl }}</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Indicateur de drag -->
|
|
<div class="ml-4 text-gray-400 group-hover:text-gray-600 transition-colors duration-200">
|
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9h8M8 15h8" />
|
|
</svg>
|
|
</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 { 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([]);
|
|
|
|
// États pour le drag & drop
|
|
const draggedIndex = ref(null);
|
|
const dragOverIndex = ref(null);
|
|
|
|
// État pour l'effet de clic
|
|
const isPressed = ref(null);
|
|
|
|
// 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');
|
|
};
|
|
|
|
// Fonctions pour l'effet de clic
|
|
const handleMouseDown = (index) => {
|
|
isPressed.value = index;
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
isPressed.value = null;
|
|
};
|
|
|
|
// Fonctions pour le drag & drop
|
|
const handleDragStart = (index, event) => {
|
|
draggedIndex.value = index;
|
|
event.dataTransfer.effectAllowed = 'move';
|
|
event.dataTransfer.setData('text/html', event.target);
|
|
};
|
|
|
|
const handleDragOver = (index, event) => {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = 'move';
|
|
dragOverIndex.value = index;
|
|
};
|
|
|
|
const handleDragLeave = () => {
|
|
dragOverIndex.value = null;
|
|
};
|
|
|
|
const handleDrop = (dropIndex, event) => {
|
|
event.preventDefault();
|
|
|
|
if (draggedIndex.value !== null && draggedIndex.value !== dropIndex) {
|
|
const sources = [...localSources.value];
|
|
const draggedItem = sources[draggedIndex.value];
|
|
|
|
// Supprimer l'élément de sa position actuelle
|
|
sources.splice(draggedIndex.value, 1);
|
|
|
|
// L'insérer à la nouvelle position
|
|
// Si on drop après l'élément original, on doit ajuster l'index
|
|
const insertIndex = draggedIndex.value < dropIndex ? dropIndex - 1 : dropIndex;
|
|
sources.splice(insertIndex, 0, draggedItem);
|
|
|
|
localSources.value = sources;
|
|
}
|
|
|
|
draggedIndex.value = null;
|
|
dragOverIndex.value = null;
|
|
};
|
|
|
|
const handleDragEnd = () => {
|
|
draggedIndex.value = null;
|
|
dragOverIndex.value = null;
|
|
};
|
|
|
|
const saveChanges = () => {
|
|
// Extraire seulement les IDs dans l'ordre actuel
|
|
const sourceIds = localSources.value.map(source => source.id);
|
|
emit('save', sourceIds);
|
|
};
|
|
</script>
|