Files
Mangarr/assets/vue/app/domain/manga/presentation/components/ManageChaptersModal.vue

704 lines
36 KiB
Vue

<template>
<div v-if="isOpen" class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!-- Overlay -->
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" @click="handleClose"></div>
<!-- Modal -->
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-5xl sm:w-full">
<!-- Header -->
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="flex items-center justify-between">
<h3 class="text-lg leading-6 font-medium text-gray-900">
Gérer les chapitres - {{ manga?.title }}
</h3>
<button
@click="handleClose"
class="text-gray-400 hover:text-gray-600 transition-colors"
>
<XMarkIcon class="h-6 w-6" />
</button>
</div>
</div>
<!-- Content -->
<div class="bg-white px-4 pb-5 sm:p-6">
<div v-if="isLoading" class="flex justify-center items-center h-32">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<div v-else-if="error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{{ error }}
</div>
<div v-else class="space-y-6">
<!-- Actions -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<button
@click="showCreateVolumeModal = true"
class="bg-blue-600 text-white px-4 py-2 rounded text-sm hover:bg-blue-700"
>
<PlusIcon class="h-4 w-4 inline mr-1" />
Créer un volume
</button>
<button
@click="showUnassignedChapters = !showUnassignedChapters"
class="text-gray-600 hover:text-gray-800 text-sm"
>
{{ showUnassignedChapters ? 'Masquer' : 'Afficher' }} les chapitres non assignés
</button>
<!-- Actions de sélection multiple -->
<div v-if="selectedChapters.length > 0" class="flex items-center space-x-2 bg-blue-50 px-3 py-1 rounded-lg">
<span class="text-sm text-blue-700">{{ selectedChapters.length }} chapitre(s) sélectionné(s)</span>
<button
@click="showMoveToVolumeModal = true"
class="bg-blue-600 text-white px-2 py-1 rounded text-xs hover:bg-blue-700"
>
Déplacer vers un volume
</button>
<button
@click="clearSelection"
class="text-blue-600 hover:text-blue-800 text-xs"
>
Annuler
</button>
</div>
</div>
<div class="text-sm text-gray-500">
{{ totalChapters }} chapitres, {{ volumes.length }} volumes
</div>
</div>
<!-- Arborescence -->
<div class="border border-gray-200 rounded-lg overflow-hidden">
<!-- Chapitres non assignés -->
<div v-if="showUnassignedChapters && unassignedChapters.length > 0" class="bg-gray-50 border-b border-gray-200">
<div class="px-4 py-3">
<h4 class="text-sm font-medium text-gray-700 mb-2">
Chapitres non assignés ({{ unassignedChapters.length }})
</h4>
<div class="space-y-1">
<div
v-for="chapter in unassignedChapters"
:key="chapter.id"
class="flex items-center space-x-3 p-2 hover:bg-gray-100 rounded"
:class="{ 'bg-blue-50 border border-blue-200': isChapterSelected(chapter) }"
>
<!-- Checkbox de sélection -->
<input
type="checkbox"
:checked="isChapterSelected(chapter)"
@change="toggleChapterSelection(chapter)"
class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<DocumentIcon class="h-4 w-4 text-gray-400" />
<span class="text-sm font-medium text-gray-700 w-12">{{ chapter.number }}</span>
<div class="flex-1">
<div v-if="!chapter.isEditing" class="flex items-center">
<span
class="text-sm text-gray-900 cursor-pointer hover:text-blue-600"
@click="startEditingTitle(chapter)"
>
{{ chapter.title || 'Sans titre' }}
</span>
<button
@click="startEditingTitle(chapter)"
class="ml-2 text-gray-400 hover:text-gray-600"
>
<PencilIcon class="h-3 w-3" />
</button>
</div>
<div v-else class="flex items-center space-x-1">
<input
v-model="chapter.editingTitle"
type="text"
class="flex-1 border border-gray-300 rounded px-2 py-1 text-xs"
@keyup.enter="saveTitle(chapter)"
@keyup.esc="cancelEditingTitle(chapter)"
ref="titleInput"
/>
<button
@click="saveTitle(chapter)"
class="text-green-600 hover:text-green-800"
>
<CheckIcon class="h-3 w-3" />
</button>
<button
@click="cancelEditingTitle(chapter)"
class="text-red-600 hover:text-red-800"
>
<XMarkIcon class="h-3 w-3" />
</button>
</div>
</div>
<div class="flex items-center space-x-1">
<button
@click="assignToVolume(chapter)"
class="text-blue-600 hover:text-blue-800 text-xs"
>
Assigner
</button>
</div>
<div v-if="chapter.isModified" class="w-2 h-2 bg-yellow-400 rounded-full"></div>
</div>
</div>
</div>
</div>
<!-- Volumes -->
<div class="divide-y divide-gray-200">
<div
v-for="volume in volumes"
:key="volume.number"
class="bg-white"
>
<!-- En-tête du volume -->
<div class="px-4 py-3 bg-blue-50 border-b border-blue-100">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<FolderIcon class="h-5 w-5 text-blue-600" />
<span class="text-sm font-medium text-blue-900">Volume {{ volume.number }}</span>
<span class="text-xs text-blue-600">({{ volume.chapters.length }} chapitres)</span>
</div>
<div class="flex items-center space-x-2">
<button
@click="toggleVolumeExpanded(volume)"
class="text-blue-600 hover:text-blue-800"
>
<ChevronDownIcon v-if="volume.isExpanded" class="h-4 w-4" />
<ChevronRightIcon v-else class="h-4 w-4" />
</button>
<button
@click="deleteVolume(volume.number)"
class="text-red-600 hover:text-red-800 text-sm"
>
Supprimer
</button>
</div>
</div>
</div>
<!-- Chapitres du volume -->
<div v-if="volume.isExpanded" class="px-4 py-2">
<div v-if="volume.chapters.length === 0" class="text-center py-4 text-gray-500 text-sm">
Aucun chapitre assigné à ce volume.
<br>
<span class="text-xs">Utilisez le bouton "Assigner" sur les chapitres non assignés pour les ajouter.</span>
</div>
<div v-else class="space-y-1">
<div
v-for="chapter in volume.chapters"
:key="chapter.id"
class="flex items-center space-x-3 p-2 hover:bg-gray-50 rounded"
:class="{ 'bg-blue-50 border border-blue-200': isChapterSelected(chapter) }"
>
<!-- Checkbox de sélection -->
<input
type="checkbox"
:checked="isChapterSelected(chapter)"
@change="toggleChapterSelection(chapter)"
class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<DocumentIcon class="h-4 w-4 text-gray-400" />
<span class="text-sm font-medium text-gray-700 w-12">{{ chapter.number }}</span>
<div class="flex-1">
<div v-if="!chapter.isEditing" class="flex items-center">
<span
class="text-sm text-gray-900 cursor-pointer hover:text-blue-600"
@click="startEditingTitle(chapter)"
>
{{ chapter.title || 'Sans titre' }}
</span>
<button
@click="startEditingTitle(chapter)"
class="ml-2 text-gray-400 hover:text-gray-600"
>
<PencilIcon class="h-3 w-3" />
</button>
</div>
<div v-else class="flex items-center space-x-1">
<input
v-model="chapter.editingTitle"
type="text"
class="flex-1 border border-gray-300 rounded px-2 py-1 text-xs"
@keyup.enter="saveTitle(chapter)"
@keyup.esc="cancelEditingTitle(chapter)"
ref="titleInput"
/>
<button
@click="saveTitle(chapter)"
class="text-green-600 hover:text-green-800"
>
<CheckIcon class="h-3 w-3" />
</button>
<button
@click="cancelEditingTitle(chapter)"
class="text-red-600 hover:text-red-800"
>
<XMarkIcon class="h-3 w-3" />
</button>
</div>
</div>
<div class="flex items-center space-x-1">
<button
@click="removeFromVolume(chapter)"
class="text-red-600 hover:text-red-800 text-xs"
>
Retirer
</button>
</div>
<div v-if="chapter.isModified" class="w-2 h-2 bg-yellow-400 rounded-full"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
@click="handleSave"
:disabled="isSaving || !hasChanges"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
>
<span v-if="isSaving">Sauvegarde...</span>
<span v-else>Sauvegarder</span>
</button>
<button
@click="handleClose"
:disabled="isSaving"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
>
Annuler
</button>
</div>
</div>
</div>
<!-- Modal de création de volume -->
<div v-if="showCreateVolumeModal" class="fixed inset-0 z-60 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" @click="showCreateVolumeModal = false"></div>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">Créer un nouveau volume</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Numéro du volume</label>
<input
v-model="newVolumeNumber"
type="number"
min="1"
class="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
placeholder="Ex: 1"
/>
</div>
<div v-if="volumeExists" class="text-red-600 text-sm">
Ce volume existe déjà.
</div>
<div v-if="newVolumeNumber && !isValidVolumeNumber" class="text-red-600 text-sm">
Le numéro de volume doit être entre 1 et 999.
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
@click="createVolume"
:disabled="!isValidVolumeNumber || volumeExists"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
>
Créer
</button>
<button
@click="showCreateVolumeModal = false"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Annuler
</button>
</div>
</div>
</div>
</div>
<!-- Modal d'assignation -->
<div v-if="showAssignModal" class="fixed inset-0 z-60 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" @click="showAssignModal = false"></div>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">Assigner le chapitre {{ selectedChapter?.number }}</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Volume</label>
<select
v-model="selectedVolumeForAssignment"
class="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
>
<option value="">Sélectionner un volume</option>
<option v-for="volume in volumes" :key="volume.number" :value="volume.number">
Volume {{ volume.number }}
</option>
</select>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
@click="confirmAssignToVolume"
:disabled="!selectedVolumeForAssignment"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
>
Assigner
</button>
<button
@click="showAssignModal = false"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Annuler
</button>
</div>
</div>
</div>
</div>
<!-- Modal de déplacement multiple -->
<div v-if="showMoveToVolumeModal" class="fixed inset-0 z-60 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" @click="showMoveToVolumeModal = false"></div>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">Déplacer {{ selectedChapters.length }} chapitre(s)</h3>
<div class="space-y-4">
<div class="bg-blue-50 p-3 rounded-lg">
<p class="text-sm text-blue-800">
Chapitres sélectionnés : {{ selectedChapters.map(c => c.number).join(', ') }}
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Volume de destination</label>
<select
v-model="selectedVolumeForMove"
class="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
>
<option value="">Sélectionner un volume</option>
<option v-for="volume in volumes" :key="volume.number" :value="volume.number">
Volume {{ volume.number }}
</option>
<option value="unassigned">Chapitres non assignés</option>
</select>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
@click="confirmMoveToVolume"
:disabled="!selectedVolumeForMove"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
>
Déplacer
</button>
<button
@click="showMoveToVolumeModal = false"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Annuler
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
DocumentIcon,
FolderIcon,
PencilIcon,
PlusIcon,
XMarkIcon
} from '@heroicons/vue/24/outline';
import { computed, nextTick, ref, watch } from 'vue';
const props = defineProps({
isOpen: {
type: Boolean,
required: true
},
manga: {
type: Object,
default: null
},
chapters: {
type: Array,
default: () => []
},
isLoading: {
type: Boolean,
default: false
},
isSaving: {
type: Boolean,
default: false
},
error: {
type: String,
default: null
}
});
const emit = defineEmits(['close', 'save']);
// État local
const localChapters = ref([]);
const showCreateVolumeModal = ref(false);
const showAssignModal = ref(false);
const showMoveToVolumeModal = ref(false);
const showUnassignedChapters = ref(true);
const newVolumeNumber = ref('');
const selectedChapter = ref(null);
const selectedVolumeForAssignment = ref('');
const selectedVolumeForMove = ref('');
const titleInput = ref(null);
const expandedVolumes = ref(new Set());
const selectedChapters = ref([]);
// Computed properties
const volumes = computed(() => {
const volumeMap = new Map();
// Ajouter les volumes existants avec leurs chapitres
localChapters.value.forEach(chapter => {
if (chapter.volume) {
if (!volumeMap.has(chapter.volume)) {
volumeMap.set(chapter.volume, {
number: chapter.volume,
chapters: []
});
}
volumeMap.get(chapter.volume).chapters.push(chapter);
}
});
// Ajouter les volumes vides qui sont dans expandedVolumes
expandedVolumes.value.forEach(volumeNumber => {
if (!volumeMap.has(volumeNumber)) {
volumeMap.set(volumeNumber, {
number: volumeNumber,
chapters: []
});
}
});
const volumesArray = Array.from(volumeMap.values())
.sort((a, b) => b.number - a.number) // Tri décroissant (derniers volumes en premier)
.map(volume => ({
...volume,
chapters: volume.chapters.sort((a, b) => b.number - a.number), // Chapitres décroissants
isExpanded: expandedVolumes.value.has(volume.number)
}));
return volumesArray;
});
const unassignedChapters = computed(() => {
return localChapters.value
.filter(chapter => !chapter.volume)
.sort((a, b) => b.number - a.number); // Tri décroissant (derniers chapitres en premier)
});
const totalChapters = computed(() => localChapters.value.length);
const volumeExists = computed(() => {
if (!newVolumeNumber.value) return false;
const volumeNumber = parseInt(newVolumeNumber.value);
return volumes.value.some(volume => volume.number === volumeNumber);
});
const isValidVolumeNumber = computed(() => {
if (!newVolumeNumber.value) return false;
const volumeNumber = parseInt(newVolumeNumber.value);
return volumeNumber > 0 && volumeNumber <= 999;
});
const hasChanges = computed(() => {
return localChapters.value.some(chapter => chapter.isModified);
});
// Méthodes
const handleClose = () => {
if (!props.isSaving) {
emit('close');
}
};
const handleSave = () => {
const modifiedChapters = localChapters.value
.filter(chapter => chapter.isModified)
.map(chapter => ({
id: chapter.id,
title: chapter.title,
volume: chapter.volume || null
}));
if (modifiedChapters.length > 0) {
emit('save', modifiedChapters);
}
};
const toggleVolumeExpanded = (volume) => {
if (expandedVolumes.value.has(volume.number)) {
expandedVolumes.value.delete(volume.number);
} else {
expandedVolumes.value.add(volume.number);
}
};
const createVolume = () => {
if (newVolumeNumber.value && !volumeExists.value && isValidVolumeNumber.value) {
const volumeNumber = parseInt(newVolumeNumber.value);
// Ajouter le nouveau volume à la liste des volumes dépliés
expandedVolumes.value.add(volumeNumber);
// Fermer la modale et réinitialiser
showCreateVolumeModal.value = false;
newVolumeNumber.value = '';
}
};
const deleteVolume = (volumeNumber) => {
localChapters.value.forEach(chapter => {
if (chapter.volume === volumeNumber) {
chapter.volume = null;
chapter.isModified = true;
}
});
};
const assignToVolume = (chapter) => {
selectedChapter.value = chapter;
selectedVolumeForAssignment.value = '';
showAssignModal.value = true;
};
const confirmAssignToVolume = () => {
if (selectedChapter.value && selectedVolumeForAssignment.value) {
selectedChapter.value.volume = parseInt(selectedVolumeForAssignment.value);
selectedChapter.value.isModified = true;
showAssignModal.value = false;
selectedChapter.value = null;
selectedVolumeForAssignment.value = '';
}
};
const removeFromVolume = (chapter) => {
chapter.volume = null;
chapter.isModified = true;
};
const startEditingTitle = async (chapter) => {
chapter.isEditing = true;
chapter.editingTitle = chapter.title || '';
await nextTick();
if (titleInput.value) {
titleInput.value.focus();
}
};
const saveTitle = (chapter) => {
if (chapter.editingTitle !== chapter.title) {
chapter.title = chapter.editingTitle;
chapter.isModified = true;
}
chapter.isEditing = false;
chapter.editingTitle = '';
};
const cancelEditingTitle = (chapter) => {
chapter.isEditing = false;
chapter.editingTitle = '';
};
// Méthodes de sélection multiple
const isChapterSelected = (chapter) => {
return selectedChapters.value.some(selected => selected.id === chapter.id);
};
const toggleChapterSelection = (chapter) => {
const index = selectedChapters.value.findIndex(selected => selected.id === chapter.id);
if (index > -1) {
selectedChapters.value.splice(index, 1);
} else {
selectedChapters.value.push(chapter);
}
};
const clearSelection = () => {
selectedChapters.value = [];
};
const confirmMoveToVolume = () => {
if (selectedVolumeForMove.value && selectedChapters.value.length > 0) {
const targetVolume = selectedVolumeForMove.value === 'unassigned' ? null : parseInt(selectedVolumeForMove.value);
selectedChapters.value.forEach(chapter => {
chapter.volume = targetVolume;
chapter.isModified = true;
});
// Vider la sélection et fermer la modale
selectedChapters.value = [];
showMoveToVolumeModal.value = false;
selectedVolumeForMove.value = '';
}
};
// Initialiser les chapitres locaux quand les props changent
watch(() => props.chapters, (newChapters) => {
localChapters.value = newChapters.map(chapter => ({
...chapter,
isModified: false,
isEditing: false,
editingTitle: ''
}));
}, { immediate: true });
// Réinitialiser quand la modale s'ouvre
watch(() => props.isOpen, (isOpen) => {
if (isOpen) {
localChapters.value = props.chapters.map(chapter => ({
...chapter,
isModified: false,
isEditing: false,
editingTitle: ''
}));
showCreateVolumeModal.value = false;
showAssignModal.value = false;
showMoveToVolumeModal.value = false;
showUnassignedChapters.value = true;
newVolumeNumber.value = '';
selectedChapter.value = null;
selectedVolumeForAssignment.value = '';
selectedVolumeForMove.value = '';
expandedVolumes.value.clear();
selectedChapters.value = [];
// S'assurer que le dernier volume est déplié après l'initialisation
nextTick(() => {
if (volumes.value.length > 0) {
expandedVolumes.value.add(volumes.value[0].number);
}
});
}
});
</script>