- Ajout du store userPreferencesStore (thème, vue, tri, pagination, lecteur) - Page UserPreferencesPage pour configurer toutes les préférences - Câblage des prefs dans HomePage (viewMode, sortBy, itemsPerPage), readerStore (fallback prefs), ChapterReader (autoHide, autoFullscreen, sync), useNotifications (toastDuration) - Thème sombre (dark: Tailwind) sur tous les composants Vue : Layout, Pagination, NotificationToast, MangaCard, MangaVolume, MangaDetails, AddManga, HomePage, ActivityPage, JobItem, MangaDeleteModal, MangaEditModal, MangaPreferredSourcesModal, ManageChaptersModal, MangaChapterList, MangaChapter, ConversionPage, FileUploadArea, ConversionProgress, NewImportPage, FileImportCard, MangaMatchCard, StatusBadge, ImportResults - i18n partiellement initialisé Jeremy Guillot
907 lines
53 KiB
Vue
907 lines
53 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 avec effet de flou Material Design -->
|
|
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="handleClose"></div>
|
|
|
|
<!-- Modal avec style Material Design -->
|
|
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-5xl sm:w-full border border-gray-100 dark:border-gray-700">
|
|
<!-- Header Material Design -->
|
|
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100 dark:border-gray-700">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center space-x-3">
|
|
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
|
<FolderIcon class="h-5 w-5 text-green-600" />
|
|
</div>
|
|
<div>
|
|
<h3 class="text-xl font-medium text-gray-900 dark:text-gray-100 leading-6">
|
|
Gérer les chapitres
|
|
</h3>
|
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">{{ manga?.title }}</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
@click="handleClose"
|
|
class="w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center transition-colors duration-200"
|
|
>
|
|
<XMarkIcon class="h-5 w-5 text-gray-600 dark:text-gray-300" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Content avec style Material Design -->
|
|
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-8">
|
|
<div v-if="isLoading" class="flex justify-center items-center h-32">
|
|
<div class="relative">
|
|
<div class="w-8 h-8 border-4 border-green-200 rounded-full"></div>
|
|
<div class="absolute top-0 left-0 w-8 h-8 border-4 border-green-600 rounded-full border-t-transparent animate-spin"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="error" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded-xl mb-6 flex items-center space-x-2">
|
|
<div class="w-5 h-5 bg-red-100 rounded-full flex items-center justify-center">
|
|
<XMarkIcon class="h-3 w-3 text-red-600" />
|
|
</div>
|
|
<span>{{ error }}</span>
|
|
</div>
|
|
|
|
<div v-else class="space-y-6">
|
|
<!-- Actions avec style Material Design -->
|
|
<div class="flex items-center justify-between bg-gray-50 dark:bg-gray-700/50 rounded-xl p-4">
|
|
<div class="flex items-center space-x-3">
|
|
<button
|
|
@click="showCreateVolumeModal = true"
|
|
class="bg-green-600 text-white px-4 py-2.5 rounded-lg text-sm font-medium hover:bg-green-700 shadow-md hover:shadow-lg transition-all duration-200 flex items-center space-x-2"
|
|
>
|
|
<PlusIcon class="h-4 w-4" />
|
|
<span>Créer un volume</span>
|
|
</button>
|
|
<button
|
|
@click="showUnassignedChapters = !showUnassignedChapters"
|
|
class="text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-100 text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700 px-3 py-2 rounded-lg transition-colors duration-200"
|
|
>
|
|
{{ showUnassignedChapters ? 'Masquer' : 'Afficher' }} les chapitres non assignés
|
|
</button>
|
|
<!-- Bouton de séparation automatique du volume fourre-tout -->
|
|
<button
|
|
v-if="hasVolumeZero && canSplitVolumeZero"
|
|
@click="showSplitVolumeZeroModal = true"
|
|
class="bg-green-600 text-white px-4 py-2.5 rounded-lg text-sm font-medium hover:bg-green-700 shadow-md hover:shadow-lg transition-all duration-200 flex items-center space-x-2"
|
|
>
|
|
<ArrowPathIcon class="h-4 w-4" />
|
|
<span>Séparer le volume 00</span>
|
|
</button>
|
|
<!-- Actions de sélection multiple -->
|
|
<div v-if="selectedChapters.length > 0" class="flex items-center space-x-3 bg-green-50 px-4 py-2 rounded-xl border border-green-200">
|
|
<span class="text-sm font-medium text-green-700">{{ selectedChapters.length }} chapitre(s) sélectionné(s)</span>
|
|
<button
|
|
@click="showMoveToVolumeModal = true"
|
|
class="bg-green-600 text-white px-3 py-1.5 rounded-lg text-xs font-medium hover:bg-green-700 shadow-sm transition-colors duration-200"
|
|
>
|
|
Déplacer vers un volume
|
|
</button>
|
|
<button
|
|
@click="clearSelection"
|
|
class="text-green-600 hover:text-green-800 text-xs font-medium hover:bg-green-100 px-2 py-1 rounded transition-colors duration-200"
|
|
>
|
|
Annuler
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="text-sm text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-700 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-600">
|
|
{{ totalChapters }} chapitres, {{ volumes.length }} volumes
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Arborescence avec style Material Design -->
|
|
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden shadow-sm">
|
|
<!-- Chapitres non assignés -->
|
|
<div v-if="showUnassignedChapters && unassignedChapters.length > 0" class="bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-700/50 dark:to-gray-700/30 border-b border-gray-200 dark:border-gray-600">
|
|
<div class="px-6 py-4">
|
|
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center space-x-2">
|
|
<DocumentIcon class="h-4 w-4 text-gray-500" />
|
|
<span>Chapitres non assignés ({{ unassignedChapters.length }})</span>
|
|
</h4>
|
|
<div class="space-y-2">
|
|
<div
|
|
v-for="chapter in unassignedChapters"
|
|
:key="chapter.id"
|
|
class="flex items-center space-x-3 p-3 hover:bg-white rounded-lg transition-colors duration-200 border border-transparent hover:border-gray-200"
|
|
:class="{ 'bg-green-50 border-green-200 shadow-sm': isChapterSelected(chapter) }"
|
|
>
|
|
<!-- Checkbox de sélection Material Design -->
|
|
<div class="relative">
|
|
<input
|
|
type="checkbox"
|
|
:checked="isChapterSelected(chapter)"
|
|
@change="toggleChapterSelection(chapter)"
|
|
class="h-5 w-5 text-green-600 border-gray-300 rounded focus:ring-green-500 focus:ring-2 transition-colors duration-200"
|
|
/>
|
|
</div>
|
|
<DocumentIcon class="h-5 w-5 text-gray-400" />
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 w-12 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-center">{{ chapter.number }}</span>
|
|
<div class="flex-1">
|
|
<div v-if="!chapter.isEditing" class="flex items-center">
|
|
<span
|
|
class="text-sm text-gray-900 dark:text-gray-100 cursor-pointer hover:text-green-600 dark:hover:text-green-400 transition-colors duration-200"
|
|
@click="startEditingTitle(chapter)"
|
|
>
|
|
{{ chapter.title || 'Sans titre' }}
|
|
</span>
|
|
<button
|
|
@click="startEditingTitle(chapter)"
|
|
class="ml-2 text-gray-400 hover:text-gray-600 p-1 rounded-full hover:bg-gray-100 transition-colors duration-200"
|
|
>
|
|
<PencilIcon class="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
<div v-else class="flex items-center space-x-2">
|
|
<input
|
|
v-model="chapter.editingTitle"
|
|
type="text"
|
|
class="flex-1 border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors duration-200"
|
|
@keyup.enter="saveTitle(chapter)"
|
|
@keyup.esc="cancelEditingTitle(chapter)"
|
|
ref="titleInput"
|
|
/>
|
|
<button
|
|
@click="saveTitle(chapter)"
|
|
class="text-green-600 hover:text-green-800 p-1 rounded-full hover:bg-green-100 transition-colors duration-200"
|
|
>
|
|
<CheckIcon class="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
@click="cancelEditingTitle(chapter)"
|
|
class="text-red-600 hover:text-red-800 p-1 rounded-full hover:bg-red-100 transition-colors duration-200"
|
|
>
|
|
<XMarkIcon class="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center space-x-2">
|
|
<button
|
|
@click="assignToVolume(chapter)"
|
|
class="bg-green-600 text-white px-3 py-1.5 rounded-lg text-xs font-medium hover:bg-green-700 shadow-sm transition-colors duration-200"
|
|
>
|
|
Assigner
|
|
</button>
|
|
</div>
|
|
<div v-if="chapter.isModified" class="w-3 h-3 bg-yellow-400 rounded-full shadow-sm"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Volumes avec style Material Design -->
|
|
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
|
<div
|
|
v-for="volume in volumes"
|
|
:key="volume.number"
|
|
class="bg-white dark:bg-gray-800"
|
|
>
|
|
<!-- En-tête du volume Material Design -->
|
|
<div class="px-6 py-4 bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 border-b border-green-100 dark:border-green-900/30">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center space-x-3">
|
|
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
|
|
<FolderIcon class="h-4 w-4 text-green-600" />
|
|
</div>
|
|
<div>
|
|
<span class="text-sm font-semibold text-green-900 dark:text-green-300">Volume {{ volume.number }}</span>
|
|
<span class="text-xs text-green-600 dark:text-green-400 ml-2">({{ volume.chapters.length }} chapitres)</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center space-x-2">
|
|
<button
|
|
@click="toggleVolumeExpanded(volume)"
|
|
class="w-8 h-8 rounded-full bg-green-100 hover:bg-green-200 flex items-center justify-center transition-colors duration-200"
|
|
>
|
|
<ChevronDownIcon v-if="volume.isExpanded" class="h-4 w-4 text-green-600" />
|
|
<ChevronRightIcon v-else class="h-4 w-4 text-green-600" />
|
|
</button>
|
|
<button
|
|
@click="deleteVolume(volume.number)"
|
|
class="text-red-600 hover:text-red-800 text-sm font-medium hover:bg-red-100 px-3 py-1.5 rounded-lg transition-colors duration-200"
|
|
>
|
|
Supprimer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Chapitres du volume -->
|
|
<div v-if="volume.isExpanded" class="px-6 py-4">
|
|
<div v-if="volume.chapters.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
|
<DocumentIcon class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-3" />
|
|
<p class="text-sm">Aucun chapitre assigné à ce volume.</p>
|
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Utilisez le bouton "Assigner" sur les chapitres non assignés pour les ajouter.</p>
|
|
</div>
|
|
<div v-else class="space-y-2">
|
|
<div
|
|
v-for="chapter in volume.chapters"
|
|
:key="chapter.id"
|
|
class="flex items-center space-x-3 p-3 hover:bg-gray-50 rounded-lg transition-colors duration-200 border border-transparent hover:border-gray-200"
|
|
:class="{ 'bg-green-50 border-green-200 shadow-sm': isChapterSelected(chapter) }"
|
|
>
|
|
<!-- Checkbox de sélection -->
|
|
<div class="relative">
|
|
<input
|
|
type="checkbox"
|
|
:checked="isChapterSelected(chapter)"
|
|
@change="toggleChapterSelection(chapter)"
|
|
class="h-5 w-5 text-green-600 border-gray-300 rounded focus:ring-green-500 focus:ring-2 transition-colors duration-200"
|
|
/>
|
|
</div>
|
|
<DocumentIcon class="h-5 w-5 text-gray-400" />
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 w-12 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-center">{{ chapter.number }}</span>
|
|
<div class="flex-1">
|
|
<div v-if="!chapter.isEditing" class="flex items-center">
|
|
<span
|
|
class="text-sm text-gray-900 dark:text-gray-100 cursor-pointer hover:text-green-600 dark:hover:text-green-400 transition-colors duration-200"
|
|
@click="startEditingTitle(chapter)"
|
|
>
|
|
{{ chapter.title || 'Sans titre' }}
|
|
</span>
|
|
<button
|
|
@click="startEditingTitle(chapter)"
|
|
class="ml-2 text-gray-400 hover:text-gray-600 p-1 rounded-full hover:bg-gray-100 transition-colors duration-200"
|
|
>
|
|
<PencilIcon class="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
<div v-else class="flex items-center space-x-2">
|
|
<input
|
|
v-model="chapter.editingTitle"
|
|
type="text"
|
|
class="flex-1 border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors duration-200"
|
|
@keyup.enter="saveTitle(chapter)"
|
|
@keyup.esc="cancelEditingTitle(chapter)"
|
|
ref="titleInput"
|
|
/>
|
|
<button
|
|
@click="saveTitle(chapter)"
|
|
class="text-green-600 hover:text-green-800 p-1 rounded-full hover:bg-green-100 transition-colors duration-200"
|
|
>
|
|
<CheckIcon class="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
@click="cancelEditingTitle(chapter)"
|
|
class="text-red-600 hover:text-red-800 p-1 rounded-full hover:bg-red-100 transition-colors duration-200"
|
|
>
|
|
<XMarkIcon class="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center space-x-2">
|
|
<button
|
|
@click="removeFromVolume(chapter)"
|
|
class="text-red-600 hover:text-red-800 text-xs font-medium hover:bg-red-100 px-3 py-1.5 rounded-lg transition-colors duration-200"
|
|
>
|
|
Retirer
|
|
</button>
|
|
</div>
|
|
<div v-if="chapter.isModified" class="w-3 h-3 bg-yellow-400 rounded-full shadow-sm"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer Material Design -->
|
|
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
|
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
|
<button
|
|
@click="handleClose"
|
|
:disabled="isSaving"
|
|
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-6 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 transition-all duration-200 shadow-sm hover:shadow-md"
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
@click="handleSave"
|
|
:disabled="isSaving || !hasChanges"
|
|
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-transparent bg-green-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 transition-all duration-200 shadow-md hover:shadow-lg"
|
|
>
|
|
<span v-if="isSaving" class="flex items-center space-x-2">
|
|
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
|
<span>Sauvegarde...</span>
|
|
</span>
|
|
<span v-else>Sauvegarder</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal de création de volume Material Design -->
|
|
<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-black/40 backdrop-blur-sm transition-opacity" @click="showCreateVolumeModal = false"></div>
|
|
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100 dark:border-gray-700">
|
|
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100 dark:border-gray-700">
|
|
<div class="flex items-center space-x-3">
|
|
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
|
<PlusIcon class="h-5 w-5 text-green-600" />
|
|
</div>
|
|
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">Créer un nouveau volume</h3>
|
|
</div>
|
|
</div>
|
|
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-6">
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Numéro du volume</label>
|
|
<input
|
|
v-model="newVolumeNumber"
|
|
type="number"
|
|
min="1"
|
|
class="block w-full border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-3 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors duration-200"
|
|
placeholder="Ex: 1"
|
|
/>
|
|
</div>
|
|
<div v-if="volumeExists" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg flex items-center space-x-2">
|
|
<XMarkIcon class="h-4 w-4 text-red-600" />
|
|
<span class="text-sm">Ce volume existe déjà.</span>
|
|
</div>
|
|
<div v-if="newVolumeNumber && !isValidVolumeNumber" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg flex items-center space-x-2">
|
|
<XMarkIcon class="h-4 w-4 text-red-600" />
|
|
<span class="text-sm">Le numéro de volume doit être entre 1 et 999.</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
|
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
|
<button
|
|
@click="showCreateVolumeModal = false"
|
|
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-gray-300 bg-white px-6 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-all duration-200 shadow-sm hover:shadow-md"
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
@click="createVolume"
|
|
:disabled="!isValidVolumeNumber || volumeExists"
|
|
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-transparent bg-green-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 transition-all duration-200 shadow-md hover:shadow-lg"
|
|
>
|
|
Créer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal d'assignation Material Design -->
|
|
<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-black/40 backdrop-blur-sm transition-opacity" @click="showAssignModal = false"></div>
|
|
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100 dark:border-gray-700">
|
|
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100 dark:border-gray-700">
|
|
<div class="flex items-center space-x-3">
|
|
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
|
<DocumentIcon class="h-5 w-5 text-green-600" />
|
|
</div>
|
|
<h3 class="text-lg font-medium text-gray-900">Assigner le chapitre {{ selectedChapter?.number }}</h3>
|
|
</div>
|
|
</div>
|
|
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-6">
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">Volume</label>
|
|
<select
|
|
v-model="selectedVolumeForAssignment"
|
|
class="block w-full border border-gray-300 rounded-lg px-4 py-3 text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors duration-200"
|
|
>
|
|
<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 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
|
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
|
<button
|
|
@click="showAssignModal = false"
|
|
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-gray-300 bg-white px-6 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-all duration-200 shadow-sm hover:shadow-md"
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
@click="confirmAssignToVolume"
|
|
:disabled="!selectedVolumeForAssignment"
|
|
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-transparent bg-green-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 transition-all duration-200 shadow-md hover:shadow-lg"
|
|
>
|
|
Assigner
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal de déplacement multiple Material Design -->
|
|
<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-black/40 backdrop-blur-sm transition-opacity" @click="showMoveToVolumeModal = false"></div>
|
|
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100 dark:border-gray-700">
|
|
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100 dark:border-gray-700">
|
|
<div class="flex items-center space-x-3">
|
|
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
|
<ArrowPathIcon class="h-5 w-5 text-green-600" />
|
|
</div>
|
|
<h3 class="text-lg font-medium text-gray-900">Déplacer {{ selectedChapters.length }} chapitre(s)</h3>
|
|
</div>
|
|
</div>
|
|
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-6">
|
|
<div class="space-y-4">
|
|
<div class="bg-green-50 p-4 rounded-lg border border-green-200">
|
|
<p class="text-sm text-green-800 font-medium">
|
|
Chapitres sélectionnés : {{ selectedChapters.map(c => c.number).join(', ') }}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">Volume de destination</label>
|
|
<select
|
|
v-model="selectedVolumeForMove"
|
|
class="block w-full border border-gray-300 rounded-lg px-4 py-3 text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors duration-200"
|
|
>
|
|
<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 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
|
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
|
<button
|
|
@click="showMoveToVolumeModal = false"
|
|
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-gray-300 bg-white px-6 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-all duration-200 shadow-sm hover:shadow-md"
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
@click="confirmMoveToVolume"
|
|
:disabled="!selectedVolumeForMove"
|
|
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-transparent bg-green-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 transition-all duration-200 shadow-md hover:shadow-lg"
|
|
>
|
|
Déplacer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal de séparation du volume fourre-tout Material Design -->
|
|
<div v-if="showSplitVolumeZeroModal" 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-black/40 backdrop-blur-sm transition-opacity" @click="showSplitVolumeZeroModal = false"></div>
|
|
<div class="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full border border-gray-100">
|
|
<div class="bg-gradient-to-r from-green-50 to-emerald-50 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100">
|
|
<div class="flex items-center space-x-3">
|
|
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
|
<ArrowPathIcon class="h-5 w-5 text-green-600" />
|
|
</div>
|
|
<h3 class="text-lg font-medium text-gray-900">Séparer le volume 00</h3>
|
|
</div>
|
|
</div>
|
|
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-6">
|
|
<div class="space-y-4">
|
|
<div class="bg-green-50 p-4 rounded-lg border border-green-200">
|
|
<p class="text-sm text-green-800 font-medium">
|
|
Le volume 00 contient {{ volumeZeroChapters.length }} chapitres et sera séparé en {{ numberOfNewVolumes }} nouveaux volumes.
|
|
</p>
|
|
</div>
|
|
<div class="bg-green-50 p-4 rounded-lg border border-green-200">
|
|
<p class="text-sm text-green-800">
|
|
<strong>Moyenne des autres volumes :</strong> {{ averageChaptersPerVolume.toFixed(1) }} chapitres par volume
|
|
</p>
|
|
<p class="text-sm text-green-800 mt-1">
|
|
<strong>Chapitres par nouveau volume :</strong> {{ chaptersPerNewVolume }}
|
|
</p>
|
|
</div>
|
|
<div class="space-y-3">
|
|
<h4 class="text-sm font-medium text-gray-700">Répartition proposée :</h4>
|
|
<div class="space-y-2 max-h-32 overflow-y-auto">
|
|
<div v-for="(group, index) in proposedVolumeGroups" :key="index" class="text-sm text-gray-600 bg-gray-50 p-3 rounded-lg border border-gray-200">
|
|
<strong>Volume {{ group.volumeNumber }} :</strong>
|
|
Chapitres {{ group.chapters.map(c => c.number).join(', ') }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
|
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
|
<button
|
|
@click="showSplitVolumeZeroModal = false"
|
|
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-gray-300 bg-white px-6 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-all duration-200 shadow-sm hover:shadow-md"
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
@click="confirmSplitVolumeZero"
|
|
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-transparent bg-green-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-all duration-200 shadow-md hover:shadow-lg"
|
|
>
|
|
Confirmer la séparation
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import {
|
|
ArrowPathIcon,
|
|
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 showSplitVolumeZeroModal = 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);
|
|
});
|
|
|
|
// Computed properties pour la séparation du volume fourre-tout
|
|
const volumeZeroChapters = computed(() => {
|
|
return localChapters.value.filter(chapter => chapter.volume === 0);
|
|
});
|
|
|
|
const hasVolumeZero = computed(() => {
|
|
return volumeZeroChapters.value.length > 0;
|
|
});
|
|
|
|
const otherVolumes = computed(() => {
|
|
return volumes.value.filter(volume => volume.number !== 0 && volume.chapters.length > 0);
|
|
});
|
|
|
|
const averageChaptersPerVolume = computed(() => {
|
|
if (otherVolumes.value.length === 0) return 10; // Valeur par défaut si aucun autre volume
|
|
const totalChapters = otherVolumes.value.reduce((sum, volume) => sum + volume.chapters.length, 0);
|
|
return totalChapters / otherVolumes.value.length;
|
|
});
|
|
|
|
const canSplitVolumeZero = computed(() => {
|
|
return hasVolumeZero.value && volumeZeroChapters.value.length > averageChaptersPerVolume.value;
|
|
});
|
|
|
|
const numberOfNewVolumes = computed(() => {
|
|
if (!canSplitVolumeZero.value) return 0;
|
|
return Math.ceil(volumeZeroChapters.value.length / averageChaptersPerVolume.value);
|
|
});
|
|
|
|
const chaptersPerNewVolume = computed(() => {
|
|
if (!canSplitVolumeZero.value) return 0;
|
|
return Math.ceil(volumeZeroChapters.value.length / numberOfNewVolumes.value);
|
|
});
|
|
|
|
const proposedVolumeGroups = computed(() => {
|
|
if (!canSplitVolumeZero.value) return [];
|
|
|
|
const chapters = [...volumeZeroChapters.value].sort((a, b) => a.number - b.number); // Tri croissant par numéro
|
|
const groups = [];
|
|
const chaptersPerGroup = chaptersPerNewVolume.value;
|
|
|
|
// Trouver le prochain numéro de volume disponible
|
|
const existingVolumeNumbers = volumes.value.map(v => v.number).filter(n => n !== 0);
|
|
const maxVolumeNumber = existingVolumeNumbers.length > 0 ? Math.max(...existingVolumeNumbers) : 0;
|
|
|
|
for (let i = 0; i < numberOfNewVolumes.value; i++) {
|
|
const startIndex = i * chaptersPerGroup;
|
|
const endIndex = Math.min(startIndex + chaptersPerGroup, chapters.length);
|
|
const groupChapters = chapters.slice(startIndex, endIndex);
|
|
|
|
if (groupChapters.length > 0) {
|
|
groups.push({
|
|
volumeNumber: maxVolumeNumber + i + 1,
|
|
chapters: groupChapters
|
|
});
|
|
}
|
|
}
|
|
|
|
return groups;
|
|
});
|
|
|
|
// 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 = '';
|
|
}
|
|
};
|
|
|
|
const confirmSplitVolumeZero = () => {
|
|
if (!canSplitVolumeZero.value) return;
|
|
|
|
// Appliquer la répartition proposée
|
|
proposedVolumeGroups.value.forEach(group => {
|
|
group.chapters.forEach(chapter => {
|
|
chapter.volume = group.volumeNumber;
|
|
chapter.isModified = true;
|
|
});
|
|
});
|
|
|
|
// Fermer la modale
|
|
showSplitVolumeZeroModal.value = false;
|
|
};
|
|
|
|
// 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;
|
|
showSplitVolumeZeroModal.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>
|