Compare commits
15 Commits
13653b4ced
...
style/simp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc27fc4564 | ||
|
|
e1909b9804 | ||
|
|
07d3b56d1b | ||
|
|
ac19cc53ca | ||
|
|
15cb59e420 | ||
|
|
d4e456961a | ||
|
|
465a05c13b | ||
|
|
2ffe559832 | ||
|
|
5eb650df6f | ||
| b60a68cbd7 | |||
|
|
ec1ef8fe68 | ||
| 48d819ba72 | |||
| 156d2eea37 | |||
|
|
e5c319db79 | ||
|
|
41ca08f20e |
7
Makefile
7
Makefile
@@ -145,6 +145,13 @@ twig-extension: ## Create a new twig extension
|
||||
stimulus: ## Create a new stimulus controller
|
||||
@$(SYMFONY) make:stimulus-controller
|
||||
|
||||
notify-test: ## Envoie les 4 types de notifications de test avec 2s d'intervalle
|
||||
@for type in info success error warning; do \
|
||||
$(SYMFONY) app:notify:test --type=$$type --message="Test $$type depuis Mangarr"; \
|
||||
echo "[$$type] envoyé"; \
|
||||
sleep 2; \
|
||||
done
|
||||
|
||||
consume-commands: ## Consume commands messages
|
||||
@$(SYMFONY) messenger:consume commands -vv
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
@import "tailwindcss/components";
|
||||
@import "tailwindcss/utilities";
|
||||
|
||||
html, body {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: white;
|
||||
}
|
||||
@@ -82,6 +87,33 @@ body {
|
||||
@apply bg-gray-700;
|
||||
}
|
||||
|
||||
/* Firefox uniquement — évite le conflit avec les pseudo-éléments webkit sur Chrome 121+ */
|
||||
@supports (-moz-appearance: none) {
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #16a34a transparent;
|
||||
}
|
||||
|
||||
.dark * {
|
||||
scrollbar-color: #16a34a #1f2937;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode — webkit track */
|
||||
.dark ::-webkit-scrollbar-track {
|
||||
@apply bg-gray-800;
|
||||
}
|
||||
|
||||
/* Supprime les flèches de la scrollbar */
|
||||
::-webkit-scrollbar-button:start:decrement,
|
||||
::-webkit-scrollbar-button:end:increment,
|
||||
::-webkit-scrollbar-button:start:increment,
|
||||
::-webkit-scrollbar-button:end:decrement {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
///* Custom styles for the scrollbar buttons */
|
||||
//::-webkit-scrollbar-button {
|
||||
// @apply bg-gray-700;
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
<script setup>
|
||||
import NotificationToast from './shared/components/ui/NotificationToast.vue';
|
||||
import { useMercureNotifications } from './shared/composables/useMercureNotifications';
|
||||
|
||||
useMercureNotifications();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<tr
|
||||
class="border-b border-gray-200 hover:bg-gray-50 transition duration-150 ease-in-out"
|
||||
class="border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition duration-150 ease-in-out"
|
||||
:class="{
|
||||
'bg-yellow-50': job.status === 'pending',
|
||||
'bg-blue-50': job.status === 'in_progress',
|
||||
'bg-green-50': job.status === 'completed',
|
||||
'bg-red-50': job.status === 'failed'
|
||||
'bg-yellow-50 dark:bg-yellow-900/20': job.status === 'pending',
|
||||
'bg-blue-50 dark:bg-blue-900/20': job.status === 'in_progress',
|
||||
'bg-green-50 dark:bg-green-900/20': job.status === 'completed',
|
||||
'bg-red-50 dark:bg-red-900/20': job.status === 'failed'
|
||||
}">
|
||||
<td class="py-4 px-4 text-center">
|
||||
<input type="checkbox" class="form-checkbox h-5 w-5 text-green-600" />
|
||||
@@ -20,37 +20,37 @@
|
||||
<span
|
||||
class="px-2 py-1 text-xs rounded-full"
|
||||
:class="{
|
||||
'bg-yellow-100 text-yellow-800': job.status === 'pending',
|
||||
'bg-blue-100 text-blue-800': job.status === 'in_progress',
|
||||
'bg-green-100 text-green-800': job.status === 'completed',
|
||||
'bg-red-100 text-red-800': job.status === 'failed'
|
||||
'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300': job.status === 'pending',
|
||||
'bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300': job.status === 'in_progress',
|
||||
'bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300': job.status === 'completed',
|
||||
'bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300': job.status === 'failed'
|
||||
}">
|
||||
{{ job.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4 px-4">
|
||||
<div v-if="job.error" class="text-sm text-red-600">
|
||||
<div v-if="job.error" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ job.error }}
|
||||
</div>
|
||||
<div v-else-if="job.context?.mangaTitle || job.context?.chapterNumber !== undefined || job.context?.sourceId"
|
||||
class="text-sm text-gray-700 space-y-0.5">
|
||||
class="text-sm text-gray-700 dark:text-gray-300 space-y-0.5">
|
||||
<div v-if="job.context.mangaTitle" class="font-medium">
|
||||
{{ job.context.mangaTitle }}
|
||||
</div>
|
||||
<div v-if="job.context.chapterNumber !== undefined" class="text-gray-500">
|
||||
<div v-if="job.context.chapterNumber !== undefined" class="text-gray-500 dark:text-gray-400">
|
||||
Chapitre {{ job.context.chapterNumber }}
|
||||
</div>
|
||||
<div v-if="job.context.sourceId" class="text-xs text-gray-400">
|
||||
<div v-if="job.context.sourceId" class="text-xs text-gray-400 dark:text-gray-500">
|
||||
Source : {{ job.context.sourceId }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-600">
|
||||
<div v-else class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ formatDate(job.createdAt) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4 px-4">
|
||||
<div v-if="job.status === 'in_progress'" class="mt-2">
|
||||
<div class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
||||
<div class="relative bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full bg-green-400 transition-all duration-300 ease-out"
|
||||
:style="{ width: `${job.progress}%` }"></div>
|
||||
@@ -59,7 +59,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="job.status === 'completed'" class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
||||
<div v-else-if="job.status === 'completed'" class="relative bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full bg-green-400 transition-all duration-300 ease-out"
|
||||
style="width: 100%"></div>
|
||||
@@ -67,7 +67,7 @@
|
||||
100%
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="job.status === 'failed'" class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
||||
<div v-else-if="job.status === 'failed'" class="relative bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full bg-red-400 transition-all duration-300 ease-out"
|
||||
style="width: 100%"></div>
|
||||
@@ -75,17 +75,17 @@
|
||||
Erreur
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
||||
<div v-else class="relative bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full bg-yellow-400 transition-all duration-300 ease-out"
|
||||
style="width: 0%"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-gray-600">
|
||||
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-gray-600 dark:text-gray-300">
|
||||
En attente
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="job.maxAttempts > 1 || job.attempts > 0"
|
||||
class="text-xs text-gray-400 mt-1 text-center">
|
||||
class="text-xs text-gray-400 dark:text-gray-500 mt-1 text-center">
|
||||
{{ job.attempts }} / {{ job.maxAttempts }} tentative{{ job.maxAttempts > 1 ? 's' : '' }}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="overflow-y-auto h-full">
|
||||
<Toolbar :config="toolbarConfig" class="mb-6" />
|
||||
|
||||
<div v-if="activityStore.loading" class="flex justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-indigo-500"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activityStore.error" class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
|
||||
<div v-else-if="activityStore.error" class="bg-red-100 dark:bg-red-900/20 border-l-4 border-red-500 text-red-700 dark:text-red-400 p-4 mb-6">
|
||||
<p>{{ activityStore.error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="container mx-auto p-2">
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full bg-white">
|
||||
<table class="min-w-full bg-white dark:bg-gray-800">
|
||||
<thead>
|
||||
<tr class="bg-gray-200 text-gray-800">
|
||||
<tr class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200">
|
||||
<th class="w-1/12 py-3 px-4 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -29,14 +29,14 @@
|
||||
<th class="w-1/12 py-3 px-4 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-gray-700">
|
||||
<tbody class="text-gray-700 dark:text-gray-300">
|
||||
<template v-if="activityStore.jobs.length === 0">
|
||||
<tr>
|
||||
<td colspan="6" class="py-8 px-4 text-center text-gray-500">
|
||||
<div class="flex flex-col items-center">
|
||||
<ClockIcon class="h-12 w-12 text-gray-300 mb-4" />
|
||||
<p class="text-lg font-medium">Aucune activité trouvée</p>
|
||||
<p class="text-sm">Aucune activité ne correspond aux filtres actuels.</p>
|
||||
<ClockIcon class="h-12 w-12 text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<p class="text-lg font-medium dark:text-gray-300">Aucune activité trouvée</p>
|
||||
<p class="text-sm dark:text-gray-400">Aucune activité ne correspond aux filtres actuels.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -24,10 +24,10 @@
|
||||
|
||||
<!-- Message de statut -->
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ statusMessage }}
|
||||
</p>
|
||||
<p v-if="fileName" class="text-xs text-gray-500">
|
||||
<p v-if="fileName" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ fileName }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -35,11 +35,11 @@
|
||||
|
||||
<!-- Barre de progression -->
|
||||
<div v-if="showProgress" class="space-y-2">
|
||||
<div class="flex justify-between text-xs text-gray-600">
|
||||
<div class="flex justify-between text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>Progression</span>
|
||||
<span>{{ Math.round(progress) }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-500 h-2 rounded-full transition-all duration-300 ease-out"
|
||||
:style="{ width: `${progress}%` }"
|
||||
@@ -48,7 +48,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Détails de la conversion -->
|
||||
<div v-if="showDetails && (originalSize || convertedSize)" class="text-xs text-gray-500 space-y-1">
|
||||
<div v-if="showDetails && (originalSize || convertedSize)" class="text-xs text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<div v-if="originalSize" class="flex justify-between">
|
||||
<span>Taille originale:</span>
|
||||
<span>{{ formatFileSize(originalSize) }}</span>
|
||||
@@ -77,7 +77,7 @@
|
||||
<button
|
||||
v-if="canReset"
|
||||
@click="$emit('reset')"
|
||||
class="flex items-center space-x-2 px-4 py-2 border border-gray-300 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
||||
class="flex items-center space-x-2 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-sm font-medium rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
||||
>
|
||||
<ArrowPathIcon class="w-4 h-4" />
|
||||
<span>Convertir un autre fichier</span>
|
||||
@@ -85,14 +85,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Message d'erreur détaillé -->
|
||||
<div v-if="hasError && errorMessage" class="p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<div v-if="hasError && errorMessage" class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<div class="flex">
|
||||
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 flex-shrink-0" />
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">
|
||||
<h3 class="text-sm font-medium text-red-800 dark:text-red-300">
|
||||
Erreur de conversion
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-red-700">
|
||||
<p class="mt-1 text-sm text-red-700 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
:class="[
|
||||
'border-2 border-dashed rounded-lg p-8 text-center transition-all duration-200',
|
||||
isDragOver
|
||||
? 'border-green-400 bg-green-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
? 'border-green-400 bg-green-50 dark:bg-green-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
|
||||
]"
|
||||
>
|
||||
<!-- Zone d'upload -->
|
||||
@@ -28,13 +28,13 @@
|
||||
|
||||
<!-- Message principal -->
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-lg font-medium text-gray-900">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ isDragOver ? 'Déposez votre fichier ici' : 'Sélectionnez un fichier CBR ou CBZ' }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Glissez-déposez votre fichier ou cliquez pour le sélectionner
|
||||
</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||
Fichiers supportés: .cbr, .cbz (max. 150MB)
|
||||
</p>
|
||||
</div>
|
||||
@@ -63,20 +63,20 @@
|
||||
</div>
|
||||
|
||||
<!-- Informations du fichier sélectionné -->
|
||||
<div v-if="selectedFile" class="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||
<div v-if="selectedFile" class="mt-6 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="flex items-center space-x-3">
|
||||
<DocumentIcon class="w-8 h-8 text-gray-600" />
|
||||
<DocumentIcon class="w-8 h-8 text-gray-600 dark:text-gray-400" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{{ selectedFile.name }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ formatFileSize(selectedFile.size) }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="clearFile"
|
||||
class="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
class="p-1 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
title="Supprimer le fichier"
|
||||
>
|
||||
<XMarkIcon class="w-5 h-5" />
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<div class="overflow-y-auto h-full"><div class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<!-- En-tête -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center space-x-3 mb-4">
|
||||
<ArrowPathIcon class="w-8 h-8 text-green-600" />
|
||||
<h1 class="text-3xl font-bold text-gray-900">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Convertir CBR en CBZ
|
||||
</h1>
|
||||
</div>
|
||||
<p class="text-lg text-gray-600">
|
||||
<p class="text-lg text-gray-600 dark:text-gray-400">
|
||||
Convertissez vos fichiers CBR (Comic Book RAR) en CBZ (Comic Book ZIP) pour une meilleure compatibilité.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Zone principale -->
|
||||
<div class="bg-white shadow-lg rounded-lg overflow-hidden">
|
||||
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-lg overflow-hidden">
|
||||
<!-- En-tête de la carte -->
|
||||
<div class="bg-gray-800 text-white p-6">
|
||||
<div class="flex items-center space-x-3">
|
||||
@@ -75,14 +75,14 @@
|
||||
/>
|
||||
|
||||
<!-- Message d'information -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div class="flex">
|
||||
<InformationCircleIcon class="w-5 h-5 text-blue-500 flex-shrink-0" />
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800">
|
||||
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||
À propos de la conversion
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-blue-700 space-y-1">
|
||||
<div class="mt-2 text-sm text-blue-700 dark:text-blue-400 space-y-1">
|
||||
<p>• Les fichiers CBZ sont plus largement supportés par les lecteurs de bandes dessinées</p>
|
||||
<p>• La compression ZIP permet généralement une meilleure accessibilité</p>
|
||||
<p>• Aucune perte de qualité lors de la conversion</p>
|
||||
@@ -95,34 +95,34 @@
|
||||
<!-- Historique des conversions -->
|
||||
<div v-if="conversionStore.conversionCount > 0" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-gray-900">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
Historique des conversions
|
||||
</h3>
|
||||
<button
|
||||
@click="handleClearHistory"
|
||||
class="text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
||||
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
Effacer l'historique
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(conversion, index) in conversionStore.conversionHistory"
|
||||
:key="index"
|
||||
class="flex items-center justify-between py-2 border-b border-gray-200 last:border-b-0"
|
||||
class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-600 last:border-b-0"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ conversion.originalName }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatDate(conversion.timestamp) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-gray-600">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ formatFileSize(conversion.originalSize) }} → {{ formatFileSize(conversion.convertedSize) }}
|
||||
</p>
|
||||
<p class="text-xs text-green-600">
|
||||
@@ -150,7 +150,7 @@
|
||||
<XMarkIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border dark:border-gray-700 p-6">
|
||||
<div class="flex items-start space-x-4">
|
||||
<!-- File Icon and Info -->
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="w-12 h-12 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
@@ -13,7 +13,7 @@
|
||||
<!-- File Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-gray-900 truncate">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{{ file.filename }}
|
||||
</h3>
|
||||
|
||||
@@ -23,29 +23,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{{ file.getFormattedSize() }} • {{ file.getFileExtension().toUpperCase() }}
|
||||
</p>
|
||||
|
||||
<!-- Extracted Info -->
|
||||
<div v-if="file.isAnalyzed()" class="mt-2 flex gap-3 text-sm">
|
||||
<span v-if="file.getExtractedChapterNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-blue-50 text-blue-700">
|
||||
<span v-if="file.getExtractedChapterNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300">
|
||||
Chapitre {{ file.getExtractedChapterNumber() }}
|
||||
</span>
|
||||
<span v-if="file.getExtractedVolumeNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-purple-50 text-purple-700">
|
||||
<span v-if="file.getExtractedVolumeNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-purple-50 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">
|
||||
Volume {{ file.getExtractedVolumeNumber() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Error Display -->
|
||||
<div v-if="file.hasError()" class="mt-3 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<div v-if="file.hasError()" class="mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<div class="flex">
|
||||
<svg class="flex-shrink-0 h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">Erreur</h3>
|
||||
<div class="mt-2 text-sm text-red-700">{{ file.errorMessage }}</div>
|
||||
<h3 class="text-sm font-medium text-red-800 dark:text-red-300">Erreur</h3>
|
||||
<div class="mt-2 text-sm text-red-700 dark:text-red-400">{{ file.errorMessage }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,7 +53,7 @@
|
||||
<!-- Manga Selection -->
|
||||
<div v-if="file.isAnalyzed() && file.hasMatches()" class="mt-4 space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Sélectionner un manga ({{ file.getMatches().length }} correspondance(s) trouvée(s))
|
||||
</label>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Selected Manga Preview -->
|
||||
<div v-if="file.selectedManga" class="flex items-center gap-3 p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<div v-if="file.selectedManga" class="flex items-center gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md">
|
||||
<img
|
||||
v-if="file.selectedManga.thumbnailUrl"
|
||||
:src="file.selectedManga.thumbnailUrl"
|
||||
@@ -78,9 +78,9 @@
|
||||
class="w-12 h-16 object-cover rounded"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-900">{{ file.selectedManga.title }}</p>
|
||||
<p class="text-sm text-gray-500">{{ file.selectedManga.slug }}</p>
|
||||
<p class="text-xs text-blue-600 mt-1">Score: {{ file.selectedManga.matchScore }}%</p>
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">{{ file.selectedManga.title }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ file.selectedManga.slug }}</p>
|
||||
<p class="text-xs text-blue-600 dark:text-blue-400 mt-1">Score: {{ file.selectedManga.matchScore }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
<div v-if="file.selectedManga" class="grid grid-cols-2 gap-3">
|
||||
<!-- Chapter Number -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Numéro de chapitre
|
||||
</label>
|
||||
<input
|
||||
@@ -97,14 +97,14 @@
|
||||
:value="file.selectedChapterNumber ?? ''"
|
||||
@input="handleChapterNumberInput"
|
||||
:disabled="file.selectedVolumeNumber !== null"
|
||||
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100"
|
||||
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-600"
|
||||
placeholder="Ex: 1, 1.5, 2..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Volume Number -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Numéro de volume
|
||||
</label>
|
||||
<input
|
||||
@@ -113,7 +113,7 @@
|
||||
:value="file.selectedVolumeNumber ?? ''"
|
||||
@input="handleVolumeNumberInput"
|
||||
:disabled="file.selectedChapterNumber !== null"
|
||||
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100"
|
||||
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-600"
|
||||
placeholder="Ex: 1, 1.5, 2..."
|
||||
/>
|
||||
</div>
|
||||
@@ -121,14 +121,14 @@
|
||||
</div>
|
||||
|
||||
<!-- No Matches Message -->
|
||||
<div v-if="file.isAnalyzed() && !file.hasMatches()" class="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||
<div v-if="file.isAnalyzed() && !file.hasMatches()" class="mt-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md">
|
||||
<div class="flex">
|
||||
<svg class="flex-shrink-0 h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">Aucun manga trouvé</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-300">Aucun manga trouvé</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-400">
|
||||
Aucun manga ne correspond à ce fichier. Vérifiez le nom du fichier.
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,7 +138,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-6 flex justify-between items-center">
|
||||
<div class="mt-6 flex justify-between items-center border-t dark:border-gray-700 pt-4">
|
||||
<div class="flex space-x-3">
|
||||
<!-- Import Button -->
|
||||
<button
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border dark:border-gray-700 p-6">
|
||||
<div class="text-center mb-6">
|
||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4">
|
||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 dark:bg-green-900/40 mb-4">
|
||||
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Import terminé</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">Import terminé</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Voici le résumé de votre session d'import
|
||||
</p>
|
||||
</div>
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="grid grid-cols-3 gap-4 mb-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-green-600">{{ importedCount }}</div>
|
||||
<div class="text-sm text-gray-500">Importés</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Importés</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-red-600">{{ errorCount }}</div>
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
<!-- Success Files List -->
|
||||
<div v-if="importedFiles.length > 0" class="mb-6">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
Fichiers importés avec succès ({{ importedFiles.length }})
|
||||
</h4>
|
||||
<ul class="space-y-2">
|
||||
@@ -42,8 +42,8 @@
|
||||
<svg class="flex-shrink-0 h-4 w-4 text-green-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="text-gray-900">{{ file.filename }}</span>
|
||||
<span v-if="file.selectedManga" class="ml-2 text-gray-500">
|
||||
<span class="text-gray-900 dark:text-gray-100">{{ file.filename }}</span>
|
||||
<span v-if="file.selectedManga" class="ml-2 text-gray-500 dark:text-gray-400">
|
||||
→ {{ file.selectedManga.title }}
|
||||
</span>
|
||||
</li>
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
<!-- Error Files List -->
|
||||
<div v-if="errorFiles.length > 0" class="mb-6">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
Fichiers en erreur ({{ errorFiles.length }})
|
||||
</h4>
|
||||
<ul class="space-y-2">
|
||||
@@ -65,15 +65,15 @@
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div>
|
||||
<div class="text-gray-900">{{ file.filename }}</div>
|
||||
<div class="text-red-600 text-xs mt-1">{{ file.errorMessage }}</div>
|
||||
<div class="text-gray-900 dark:text-gray-100">{{ file.filename }}</div>
|
||||
<div class="text-red-600 dark:text-red-400 text-xs mt-1">{{ file.errorMessage }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-center space-x-4 pt-6 border-t">
|
||||
<div class="flex justify-center space-x-4 pt-6 border-t dark:border-gray-700">
|
||||
<button
|
||||
@click="startNewImport"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<div
|
||||
class="border rounded-lg p-4 cursor-pointer transition-all duration-200 hover:shadow-md"
|
||||
:class="{
|
||||
'border-blue-500 bg-blue-50': isSelected,
|
||||
'border-gray-200 hover:border-gray-300': !isSelected
|
||||
'border-blue-500 bg-blue-50 dark:bg-blue-900/20': isSelected,
|
||||
'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-500': !isSelected
|
||||
}"
|
||||
@click="$emit('select-match', match)"
|
||||
>
|
||||
@@ -17,7 +17,7 @@
|
||||
'bg-gray-300': !isSelected
|
||||
}"
|
||||
></div>
|
||||
<span class="text-sm font-medium text-gray-700">Score: {{ match.matchScore }}</span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Score: {{ match.matchScore }}</span>
|
||||
</div>
|
||||
<div v-if="isSelected" class="text-blue-600">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
@@ -37,9 +37,9 @@
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-16 h-20 bg-gray-200 rounded border flex items-center justify-center"
|
||||
class="w-16 h-20 bg-gray-200 dark:bg-gray-700 rounded border dark:border-gray-600 flex items-center justify-center"
|
||||
>
|
||||
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-8 h-8 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
@@ -47,27 +47,27 @@
|
||||
|
||||
<!-- Manga Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-gray-900 truncate" :title="match.title">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate" :title="match.title">
|
||||
{{ match.title }}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500 mt-1 truncate" :title="match.slug">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate" :title="match.slug">
|
||||
{{ match.slug }}
|
||||
</p>
|
||||
|
||||
<!-- Alternative Slugs -->
|
||||
<div v-if="match.alternativeSlugs && match.alternativeSlugs.length > 0" class="mt-2">
|
||||
<p class="text-xs text-gray-400">Autres titres:</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">Autres titres:</p>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
<span
|
||||
v-for="altSlug in match.alternativeSlugs.slice(0, 2)"
|
||||
:key="altSlug"
|
||||
class="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded"
|
||||
class="text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 px-2 py-1 rounded"
|
||||
>
|
||||
{{ altSlug }}
|
||||
</span>
|
||||
<span
|
||||
v-if="match.alternativeSlugs.length > 2"
|
||||
class="text-xs text-gray-400"
|
||||
class="text-xs text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
+{{ match.alternativeSlugs.length - 2 }} autres
|
||||
</span>
|
||||
@@ -78,11 +78,11 @@
|
||||
|
||||
<!-- Score Bar -->
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
|
||||
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
<span>Correspondance</span>
|
||||
<span>{{ match.matchScore }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all duration-300"
|
||||
:class="{
|
||||
|
||||
@@ -49,22 +49,22 @@ const badgeClasses = computed(() => {
|
||||
const baseClasses = 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium';
|
||||
|
||||
if (props.isImporting || props.isAnalyzing) {
|
||||
return `${baseClasses} bg-blue-100 text-blue-800`;
|
||||
return `${baseClasses} bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300`;
|
||||
}
|
||||
|
||||
switch (props.status) {
|
||||
case 'pending':
|
||||
return `${baseClasses} bg-gray-100 text-gray-800`;
|
||||
return `${baseClasses} bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300`;
|
||||
case 'analyzed':
|
||||
return `${baseClasses} bg-yellow-100 text-yellow-800`;
|
||||
return `${baseClasses} bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300`;
|
||||
case 'importing':
|
||||
return `${baseClasses} bg-blue-100 text-blue-800`;
|
||||
return `${baseClasses} bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300`;
|
||||
case 'imported':
|
||||
return `${baseClasses} bg-green-100 text-green-800`;
|
||||
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
|
||||
case 'error':
|
||||
return `${baseClasses} bg-red-100 text-red-800`;
|
||||
return `${baseClasses} bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300`;
|
||||
default:
|
||||
return `${baseClasses} bg-gray-100 text-gray-800`;
|
||||
return `${baseClasses} bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="overflow-y-auto h-full"><div class="container mx-auto px-4 py-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">Import de Bibliothèque</h1>
|
||||
<p class="text-gray-600">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">Import de Bibliothèque</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Importez vos fichiers CBZ/CBR dans votre bibliothèque Mangarr
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar (if files are being processed) -->
|
||||
<div v-if="store.hasFiles && !store.allFilesProcessed" class="mb-8">
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-700">Progression</span>
|
||||
<span class="text-sm text-gray-500">{{ store.progressPercentage }}%</span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Progression</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ store.progressPercentage }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
:style="{ width: store.progressPercentage + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-gray-500 mt-2">
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
<span>{{ store.importedCount }} importés</span>
|
||||
<span>{{ store.errorCount }} erreurs</span>
|
||||
<span>{{ store.totalFiles }} total</span>
|
||||
@@ -92,7 +92,7 @@
|
||||
<div v-if="store.allFilesProcessed" class="mt-8">
|
||||
<ImportResults />
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
@@ -5,32 +5,32 @@
|
||||
<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 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">
|
||||
<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 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100">
|
||||
<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 leading-6">
|
||||
<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 mt-1">{{ manga?.title }}</p>
|
||||
<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 hover:bg-gray-200 flex items-center justify-center transition-colors duration-200"
|
||||
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" />
|
||||
<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 px-6 py-6 sm:px-8 sm:py-8">
|
||||
<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>
|
||||
@@ -38,7 +38,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl mb-6 flex items-center space-x-2">
|
||||
<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>
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Actions avec style Material Design -->
|
||||
<div class="flex items-center justify-between bg-gray-50 rounded-xl p-4">
|
||||
<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"
|
||||
@@ -58,7 +58,7 @@
|
||||
</button>
|
||||
<button
|
||||
@click="showUnassignedChapters = !showUnassignedChapters"
|
||||
class="text-gray-600 hover:text-gray-800 text-sm font-medium hover:bg-gray-100 px-3 py-2 rounded-lg transition-colors duration-200"
|
||||
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>
|
||||
@@ -88,17 +88,17 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 bg-white px-3 py-1.5 rounded-lg border border-gray-200">
|
||||
<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 border border-gray-200 rounded-xl overflow-hidden shadow-sm">
|
||||
<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 border-b border-gray-200">
|
||||
<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 mb-3 flex items-center space-x-2">
|
||||
<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>
|
||||
@@ -119,11 +119,11 @@
|
||||
/>
|
||||
</div>
|
||||
<DocumentIcon class="h-5 w-5 text-gray-400" />
|
||||
<span class="text-sm font-medium text-gray-700 w-12 bg-gray-100 px-2 py-1 rounded text-center">{{ chapter.number }}</span>
|
||||
<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 cursor-pointer hover:text-green-600 transition-colors duration-200"
|
||||
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' }}
|
||||
@@ -173,22 +173,22 @@
|
||||
</div>
|
||||
|
||||
<!-- Volumes avec style Material Design -->
|
||||
<div class="divide-y divide-gray-100">
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<div
|
||||
v-for="volume in volumes"
|
||||
:key="volume.number"
|
||||
class="bg-white"
|
||||
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 border-b border-green-100">
|
||||
<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">Volume {{ volume.number }}</span>
|
||||
<span class="text-xs text-green-600 ml-2">({{ volume.chapters.length }} chapitres)</span>
|
||||
<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">
|
||||
@@ -211,10 +211,10 @@
|
||||
|
||||
<!-- 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">
|
||||
<DocumentIcon class="h-12 w-12 text-gray-300 mx-auto mb-3" />
|
||||
<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 mt-1">Utilisez le bouton "Assigner" sur les chapitres non assignés pour les ajouter.</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
|
||||
@@ -233,11 +233,11 @@
|
||||
/>
|
||||
</div>
|
||||
<DocumentIcon class="h-5 w-5 text-gray-400" />
|
||||
<span class="text-sm font-medium text-gray-700 w-12 bg-gray-100 px-2 py-1 rounded text-center">{{ chapter.number }}</span>
|
||||
<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 cursor-pointer hover:text-green-600 transition-colors duration-200"
|
||||
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' }}
|
||||
@@ -291,12 +291,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Footer Material Design -->
|
||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
||||
<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 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 disabled:opacity-50 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
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>
|
||||
@@ -320,24 +320,24 @@
|
||||
<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 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">
|
||||
<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="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">Créer un nouveau volume</h3>
|
||||
<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 px-6 py-6 sm:px-8 sm:py-6">
|
||||
<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">Numéro du volume</label>
|
||||
<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 rounded-lg px-4 py-3 text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors duration-200"
|
||||
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>
|
||||
@@ -351,7 +351,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
||||
<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"
|
||||
@@ -376,8 +376,8 @@
|
||||
<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 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">
|
||||
<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="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" />
|
||||
@@ -385,7 +385,7 @@
|
||||
<h3 class="text-lg font-medium text-gray-900">Assigner le chapitre {{ selectedChapter?.number }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white px-6 py-6 sm:px-8 sm:py-6">
|
||||
<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>
|
||||
@@ -401,7 +401,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
||||
<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"
|
||||
@@ -426,8 +426,8 @@
|
||||
<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 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">
|
||||
<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="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" />
|
||||
@@ -435,7 +435,7 @@
|
||||
<h3 class="text-lg font-medium text-gray-900">Déplacer {{ selectedChapters.length }} chapitre(s)</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white px-6 py-6 sm:px-8 sm:py-6">
|
||||
<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">
|
||||
@@ -457,7 +457,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
||||
<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"
|
||||
@@ -491,7 +491,7 @@
|
||||
<h3 class="text-lg font-medium text-gray-900">Séparer le volume 00</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white px-6 py-6 sm:px-8 sm:py-6">
|
||||
<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">
|
||||
@@ -517,7 +517,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
||||
<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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
||||
class="bg-white rounded-lg shadow-md overflow-hidden cursor-pointer transition-transform hover:scale-105 block">
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden cursor-pointer transition-transform hover:scale-105 block">
|
||||
<div class="relative pb-[150%]">
|
||||
<img
|
||||
:src="manga.thumbnailUrl || 'https://via.placeholder.com/300x400'"
|
||||
@@ -9,11 +9,11 @@
|
||||
class="absolute inset-0 w-full h-full object-cover bg-gray-100" />
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-1">{{ manga.title }}</h3>
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">{{ manga.title }}</h3>
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm text-gray-500">{{ manga.publicationYear }}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ manga.publicationYear }}</span>
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-500"> Added: {{ formatDate(manga.createdAt) }} </div>
|
||||
<div class="mt-1 text-sm text-gray-500 dark:text-gray-400"> Added: {{ formatDate(manga.createdAt) }} </div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<tr class="border-t hover:bg-green-100">
|
||||
<td class="px-4 py-2" :class="{ 'text-green-500': chapter.isAvailable }">
|
||||
<tr class="border-t dark:border-gray-700 hover:bg-green-100 dark:hover:bg-green-900/20">
|
||||
<td class="px-4 py-2 text-gray-900 dark:text-gray-100" :class="{ 'text-green-500 dark:text-green-400': chapter.isAvailable }">
|
||||
{{ String(chapter.number).padStart(2, '0') }}
|
||||
</td>
|
||||
<td class="px-4 py-2 w-full text-left">
|
||||
<td class="px-4 py-2 w-full text-left text-gray-900 dark:text-gray-100">
|
||||
<router-link
|
||||
v-if="chapter.isAvailable"
|
||||
class="hover:text-green-500 dark:hover:text-green-400"
|
||||
:to="{
|
||||
name: 'reader',
|
||||
params: {
|
||||
@@ -14,7 +15,7 @@
|
||||
}">
|
||||
{{ chapter.title || 'Sans titre' }}
|
||||
</router-link>
|
||||
<span v-else>{{ chapter.title || 'Sans titre' }}</span>
|
||||
<span v-else class="text-gray-500 dark:text-gray-400">{{ chapter.title || 'Sans titre' }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2 flex justify-end gap-2">
|
||||
<button v-if="!chapter.isAvailable" @click="handleSearch" :class="buttonClass">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="p-2 border-t">
|
||||
<div class="p-2 border-t dark:border-gray-700">
|
||||
<table class="min-w-full table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<tr class="text-gray-700 dark:text-gray-300">
|
||||
<th class="px-4 py-2 text-left">#</th>
|
||||
<th class="px-4 py-2 text-left">Titre</th>
|
||||
<th class="px-4 py-2 text-right">Actions</th>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" />
|
||||
</TransitionChild>
|
||||
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
@@ -24,15 +24,15 @@
|
||||
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-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<div class="mb-6">
|
||||
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900">
|
||||
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
||||
Supprimer le manga
|
||||
</DialogTitle>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-if="error" class="mb-6 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
<div v-if="error" class="mb-6 bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded">
|
||||
{{ error.message || 'Une erreur est survenue lors de la suppression.' }}
|
||||
</div>
|
||||
|
||||
@@ -40,19 +40,19 @@
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<ExclamationTriangleIcon class="h-6 w-6 text-red-500 mr-3" />
|
||||
<span class="text-sm font-medium text-gray-900">Action irréversible</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">Action irréversible</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Êtes-vous sûr de vouloir supprimer le manga <strong>"{{ manga?.title }}"</strong> ?
|
||||
</p>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4">
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-md p-4">
|
||||
<div class="flex">
|
||||
<ExclamationTriangleIcon class="h-5 w-5 text-yellow-400" />
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">
|
||||
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-300">
|
||||
Attention
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-400">
|
||||
<p>Cette action supprimera définitivement :</p>
|
||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||
<li>Le manga et toutes ses métadonnées</li>
|
||||
@@ -69,7 +69,7 @@
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
class="inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
@click="closeModal"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" />
|
||||
</TransitionChild>
|
||||
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
@@ -24,15 +24,15 @@
|
||||
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-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-4xl">
|
||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-4xl">
|
||||
<div class="mb-6">
|
||||
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900">
|
||||
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
||||
Edit Manga
|
||||
</DialogTitle>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-if="error" class="mb-6 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
<div v-if="error" class="mb-6 bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded">
|
||||
{{ error.message || 'Une erreur est survenue lors de la sauvegarde.' }}
|
||||
</div>
|
||||
|
||||
@@ -41,49 +41,49 @@
|
||||
<!-- Titre et Slug -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">Titre</label>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Titre</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="formData.title"
|
||||
type="text"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Titre du manga"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="slug" class="block text-sm font-medium text-gray-700 mb-2">Slug</label>
|
||||
<label for="slug" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Slug</label>
|
||||
<input
|
||||
id="slug"
|
||||
:value="manga?.slug || ''"
|
||||
type="text"
|
||||
disabled
|
||||
class="block w-full rounded-md border-gray-300 bg-gray-50 shadow-sm sm:text-sm text-gray-500"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-600 shadow-sm sm:text-sm text-gray-500 dark:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Année de publication -->
|
||||
<div>
|
||||
<label for="publicationYear" class="block text-sm font-medium text-gray-700 mb-2">Année de publication</label>
|
||||
<label for="publicationYear" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Année de publication</label>
|
||||
<input
|
||||
id="publicationYear"
|
||||
v-model.number="formData.publicationYear"
|
||||
type="number"
|
||||
min="1900"
|
||||
:max="new Date().getFullYear()"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="2023"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="formData.description"
|
||||
rows="4"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Description du manga"
|
||||
/>
|
||||
</div>
|
||||
@@ -91,22 +91,22 @@
|
||||
<!-- Auteur et Statut -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="author" class="block text-sm font-medium text-gray-700 mb-2">Auteur</label>
|
||||
<label for="author" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Auteur</label>
|
||||
<input
|
||||
id="author"
|
||||
v-model="formData.author"
|
||||
type="text"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Auteur du manga"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 mb-2">Statut</label>
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Statut</label>
|
||||
<input
|
||||
id="status"
|
||||
v-model="formData.status"
|
||||
type="text"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="ongoing"
|
||||
/>
|
||||
</div>
|
||||
@@ -114,7 +114,7 @@
|
||||
|
||||
<!-- Note -->
|
||||
<div>
|
||||
<label for="rating" class="block text-sm font-medium text-gray-700 mb-2">Note</label>
|
||||
<label for="rating" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Note</label>
|
||||
<input
|
||||
id="rating"
|
||||
v-model.number="formData.rating"
|
||||
@@ -122,20 +122,20 @@
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.001"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="9.541"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Slugs alternatifs -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Slugs alternatifs</label>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Slugs alternatifs</label>
|
||||
<div class="space-y-2">
|
||||
<div v-if="formData.alternativeSlugs.length > 0" class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="(slug, index) in formData.alternativeSlugs"
|
||||
:key="index"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300"
|
||||
>
|
||||
{{ slug }}
|
||||
<button
|
||||
@@ -158,7 +158,7 @@
|
||||
<input
|
||||
v-model="newAlternativeSlug"
|
||||
type="text"
|
||||
class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="flex-1 rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Nouveau slug alternatif"
|
||||
@keyup.enter="addAlternativeSlug"
|
||||
/>
|
||||
@@ -175,19 +175,19 @@
|
||||
|
||||
<!-- Genres -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Genres</label>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Genres</label>
|
||||
<div class="space-y-3">
|
||||
<div v-if="formData.genres.length > 0" class="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
<span
|
||||
v-for="(genre, index) in formData.genres"
|
||||
:key="index"
|
||||
class="inline-flex items-center justify-between px-3 py-1 rounded-md text-sm font-medium bg-gray-100 text-gray-800"
|
||||
class="inline-flex items-center justify-between px-3 py-1 rounded-md text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200"
|
||||
>
|
||||
{{ genre }}
|
||||
<button
|
||||
type="button"
|
||||
@click="removeGenre(index)"
|
||||
class="ml-2 inline-flex items-center justify-center w-4 h-4 text-gray-400 hover:text-gray-600"
|
||||
class="ml-2 inline-flex items-center justify-center w-4 h-4 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<XMarkIcon class="w-3 h-3" />
|
||||
</button>
|
||||
@@ -204,7 +204,7 @@
|
||||
<input
|
||||
v-model="newGenre"
|
||||
type="text"
|
||||
class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="flex-1 rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Nouveau genre"
|
||||
@keyup.enter="addGenre"
|
||||
/>
|
||||
@@ -224,7 +224,7 @@
|
||||
<div class="mt-8 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
@click="closeModal"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
@click="$emit('manga-click', manga)">
|
||||
<!-- Cover Image -->
|
||||
<div class="flex-shrink-0">
|
||||
<img :src="manga.imageUrl || '/placeholder-cover.png'" alt="" class="h-48 w-32 object-cover rounded" />
|
||||
<img :src="manga.imageUrl || '/placeholder-cover.png'" alt="" class="h-48 w-32 object-cover rounded" referrerpolicy="no-referrer" />
|
||||
<!-- TODO: Add placeholder image -->
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" />
|
||||
</TransitionChild>
|
||||
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
@@ -24,17 +24,17 @@
|
||||
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">
|
||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 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">
|
||||
<DialogTitle as="h3" class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
||||
Sources préférées
|
||||
</DialogTitle>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Configurez l'ordre de priorité des sources pour ce manga. Glissez-déposez les sources pour les réorganiser.
|
||||
</p>
|
||||
</div>
|
||||
@@ -47,13 +47,13 @@
|
||||
</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">
|
||||
<div v-else-if="error" class="mt-5 bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 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">
|
||||
<div v-if="localSources.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
Aucune source disponible
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
@@ -63,10 +63,10 @@
|
||||
: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,
|
||||
'bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 border-blue-300 dark:border-blue-700 shadow-md': index === 0,
|
||||
'bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 border-green-300 dark:border-green-700': index === 1,
|
||||
'bg-gradient-to-r from-yellow-50 to-amber-50 dark:from-yellow-900/20 dark:to-amber-900/20 border-yellow-300 dark:border-yellow-700': index === 2,
|
||||
'bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600': index > 2,
|
||||
'scale-105 shadow-lg border-blue-400': draggedIndex === index,
|
||||
'opacity-50': dragOverIndex === index && draggedIndex !== index,
|
||||
'scale-95 active:scale-95': isPressed === index
|
||||
@@ -102,10 +102,10 @@
|
||||
<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
|
||||
'bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300': index === 0,
|
||||
'bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300': index === 1,
|
||||
'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300': index === 2,
|
||||
'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300': index > 2
|
||||
}
|
||||
]">
|
||||
<span v-if="index === 0">🥇 Priorité haute</span>
|
||||
@@ -117,14 +117,14 @@
|
||||
|
||||
<!-- 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">
|
||||
<div class="font-semibold text-gray-900 dark:text-gray-100 truncate">{{ source.name }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 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">
|
||||
<div class="ml-4 text-gray-400 dark:text-gray-500 group-hover:text-gray-600 dark:group-hover:text-gray-300 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>
|
||||
@@ -148,7 +148,7 @@
|
||||
</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"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 sm:col-start-1 sm:mt-0"
|
||||
@click="closeModal"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
<th class="w-10 px-4 py-3"></th>
|
||||
<th class="py-3 pr-4 text-left font-medium">Titre</th>
|
||||
<th class="py-3 pr-4 text-left font-medium w-44">Source préférée</th>
|
||||
<th class="py-3 pr-4 text-left font-medium w-44">Chapitres</th>
|
||||
<th class="py-3 px-4 text-right font-medium w-28">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<tr
|
||||
v-for="manga in mangas"
|
||||
:key="manga.id"
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors">
|
||||
|
||||
<!-- Monitoring -->
|
||||
<td class="px-4 py-3 text-center">
|
||||
<button
|
||||
:title="manga.monitored ? 'Monitoring actif — cliquer pour désactiver' : 'Monitoring inactif — cliquer pour activer'"
|
||||
:class="manga.monitored
|
||||
? 'text-green-500 hover:text-green-600'
|
||||
: 'text-gray-300 dark:text-gray-600 hover:text-gray-400 dark:hover:text-gray-500'"
|
||||
class="transition-colors"
|
||||
@click="doToggleMonitoring(manga)">
|
||||
<component
|
||||
:is="manga.monitored ? BookmarkIcon : BookmarkSlashIcon"
|
||||
class="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
|
||||
<!-- Titre -->
|
||||
<td class="py-3 pr-4">
|
||||
<RouterLink
|
||||
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
||||
class="font-medium text-gray-900 dark:text-gray-100 hover:text-green-500 dark:hover:text-green-400 transition-colors">
|
||||
{{ manga.title }}
|
||||
</RouterLink>
|
||||
</td>
|
||||
|
||||
<!-- Source préférée -->
|
||||
<td class="py-3 pr-4">
|
||||
<MangaPreferredSourceCell :manga-id="manga.id" />
|
||||
</td>
|
||||
|
||||
<!-- Chapitres — barre de progression -->
|
||||
<td class="py-3 pr-4">
|
||||
<div v-if="manga.chaptersTotal > 0">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs tabular-nums text-gray-500 dark:text-gray-400">
|
||||
{{ manga.chaptersScraped }} / {{ manga.chaptersTotal }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ progressPercent(manga) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-100 dark:bg-gray-600 rounded-full h-1.5">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
:class="progressPercent(manga) >= 100
|
||||
? 'bg-green-500'
|
||||
: 'bg-blue-500'"
|
||||
:style="{ width: progressPercent(manga) + '%' }" />
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-400 dark:text-gray-600 text-xs">—</span>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="py-3 px-4">
|
||||
<div class="flex items-center justify-end gap-0.5">
|
||||
<button
|
||||
class="p-1.5 rounded-md text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||
title="Éditer"
|
||||
@click="openEdit(manga)">
|
||||
<PencilIcon class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded-md text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||
title="Sources préférées"
|
||||
@click="openSources(manga)">
|
||||
<Cog6ToothIcon class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded-md transition-colors"
|
||||
:class="refreshingId === manga.id
|
||||
? 'text-blue-400 cursor-not-allowed'
|
||||
: 'text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600'"
|
||||
title="Rafraîchir"
|
||||
:disabled="refreshingId === manga.id"
|
||||
@click="doRefresh(manga)">
|
||||
<ArrowPathIcon
|
||||
class="w-4 h-4"
|
||||
:class="{ 'animate-spin': refreshingId === manga.id }" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Modales -->
|
||||
<MangaEditModal
|
||||
:is-open="isEditModalOpen"
|
||||
:manga="selectedManga"
|
||||
:is-saving="editIsLoading"
|
||||
:error="editError"
|
||||
@close="closeEditModal"
|
||||
@save="handleSaveEdit" />
|
||||
|
||||
<MangaPreferredSourcesModal
|
||||
:is-open="isSourcesModalOpen"
|
||||
:sources="preferredSources"
|
||||
:is-loading="sourcesIsLoading"
|
||||
:error="sourcesError"
|
||||
:is-saving="sourcesIsSaving"
|
||||
@close="isSourcesModalOpen = false"
|
||||
@save="handleSaveSources" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ArrowPathIcon, BookmarkIcon, BookmarkSlashIcon, Cog6ToothIcon, PencilIcon } from '@heroicons/vue/24/outline';
|
||||
import { computed, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { useMangaEdit } from '../composables/useMangaEdit';
|
||||
import { useMangaMonitoring } from '../composables/useMangaMonitoring';
|
||||
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
|
||||
import { useMangaRefresh } from '../composables/useMangaRefresh';
|
||||
import MangaEditModal from './MangaEditModal.vue';
|
||||
import MangaPreferredSourceCell from './MangaPreferredSourceCell.vue';
|
||||
import MangaPreferredSourcesModal from './MangaPreferredSourcesModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
mangas: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
function progressPercent(manga) {
|
||||
if (!manga.chaptersTotal) return 0;
|
||||
return Math.round((manga.chaptersScraped / manga.chaptersTotal) * 100);
|
||||
}
|
||||
|
||||
// ── Monitoring ────────────────────────────────────────────
|
||||
const { toggleMonitoring } = useMangaMonitoring();
|
||||
|
||||
async function doToggleMonitoring(manga) {
|
||||
await toggleMonitoring(manga.id, !manga.monitored);
|
||||
manga.monitored = !manga.monitored;
|
||||
}
|
||||
|
||||
// ── Selected manga ────────────────────────────────────────
|
||||
const selectedManga = ref(null);
|
||||
const isSourcesModalOpen = ref(false);
|
||||
|
||||
// ── Edit ──────────────────────────────────────────────────
|
||||
const { isEditModalOpen, openEditModal, closeEditModal, editManga, isLoading: editIsLoading, error: editError } = useMangaEdit();
|
||||
|
||||
function openEdit(manga) {
|
||||
selectedManga.value = manga;
|
||||
openEditModal();
|
||||
}
|
||||
|
||||
async function handleSaveEdit(data) {
|
||||
if (!selectedManga.value) return;
|
||||
await editManga(selectedManga.value.id, data);
|
||||
}
|
||||
|
||||
// ── Sources préférées ─────────────────────────────────────
|
||||
const selectedMangaId = computed(() => selectedManga.value?.id ?? null);
|
||||
const {
|
||||
sources: preferredSources,
|
||||
isLoading: sourcesIsLoading,
|
||||
error: sourcesError,
|
||||
isSaving: sourcesIsSaving,
|
||||
savePreferredSources
|
||||
} = useMangaPreferredSources(selectedMangaId);
|
||||
|
||||
function openSources(manga) {
|
||||
selectedManga.value = manga;
|
||||
isSourcesModalOpen.value = true;
|
||||
}
|
||||
|
||||
function handleSaveSources(sourceIds) {
|
||||
savePreferredSources(sourceIds);
|
||||
isSourcesModalOpen.value = false;
|
||||
}
|
||||
|
||||
// ── Refresh ───────────────────────────────────────────────
|
||||
const { refreshMetadata } = useMangaRefresh();
|
||||
const refreshingId = ref(null);
|
||||
|
||||
async function doRefresh(manga) {
|
||||
if (refreshingId.value) return;
|
||||
refreshingId.value = manga.id;
|
||||
try {
|
||||
await refreshMetadata(manga.id);
|
||||
} finally {
|
||||
refreshingId.value = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-sm shadow mb-2">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-sm shadow mb-2">
|
||||
<!-- En-tête du volume -->
|
||||
<div class="relative bg-white p-3 sm:p-4 rounded-t-sm">
|
||||
<div class="relative bg-white dark:bg-gray-800 p-3 sm:p-4 rounded-t-sm">
|
||||
<!-- Layout mobile/desktop -->
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Partie gauche -->
|
||||
<div class="flex items-center space-x-1 sm:space-x-4 flex-1 min-w-0">
|
||||
<BookmarkIcon class="h-6 w-6 sm:h-8 sm:w-8 text-gray-500 flex-shrink-0" />
|
||||
<h2 class="text-lg sm:text-xl font-semibold w-20 sm:w-28 flex-shrink-0">Vol {{ String(volume.number).padStart(2, '0') }}</h2>
|
||||
<BookmarkIcon class="h-6 w-6 sm:h-8 sm:w-8 text-gray-500 dark:text-gray-400 flex-shrink-0" />
|
||||
<h2 class="text-lg sm:text-xl font-semibold w-20 sm:w-28 flex-shrink-0 dark:text-gray-100">Vol {{ String(volume.number).padStart(2, '0') }}</h2>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
:class="[
|
||||
@@ -65,7 +65,7 @@
|
||||
<MangaChapterList v-show="isOpen" :chapters="volume.chapters" :manga-slug="mangaSlug" :manga-id="mangaId" />
|
||||
|
||||
<!-- Chevron de fermeture -->
|
||||
<div v-show="isOpen" class="flex justify-center p-2 bg-white rounded-b-sm">
|
||||
<div v-show="isOpen" class="flex justify-center p-2 bg-white dark:bg-gray-800 rounded-b-sm">
|
||||
<button @click="toggleVolume" class="w-8 h-8 flex items-center justify-center">
|
||||
<ChevronUpIcon
|
||||
class="h-5 w-5 sm:h-6 sm:w-6 bg-gray-400 rounded-full p-1 text-white hover:bg-green-500 cursor-pointer"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
v-model="searchQuery"
|
||||
@keyup.enter="performSearch"
|
||||
placeholder="Rechercher un manga..."
|
||||
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||
class="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500" />
|
||||
<button
|
||||
@click="performSearch"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
@@ -20,27 +20,27 @@
|
||||
<!-- État de chargement -->
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p class="mt-4 text-gray-600">Recherche en cours...</p>
|
||||
<p class="mt-4 text-gray-600 dark:text-gray-400">Recherche en cours...</p>
|
||||
</div>
|
||||
|
||||
<!-- Message d'erreur -->
|
||||
<div v-if="error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-6">
|
||||
<div v-if="error" class="bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded relative mb-6">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Résultats de recherche -->
|
||||
<div class="max-w-full overflow-hidden">
|
||||
<MangaList v-if="searchResults.length > 0" :mangas="searchResults" @manga-click="openMangaModal" />
|
||||
<p v-else-if="!loading && searchQuery" class="text-center text-gray-600">Aucun résultat trouvé</p>
|
||||
<p v-else-if="!loading && searchQuery" class="text-center text-gray-600 dark:text-gray-400">Aucun résultat trouvé</p>
|
||||
</div>
|
||||
|
||||
<!-- Modal de confirmation -->
|
||||
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" />
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" aria-hidden="true" />
|
||||
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||
<DialogPanel class="w-full max-w-lg bg-white rounded-xl shadow-xl p-6">
|
||||
<DialogTitle class="text-lg mb-4"> Ajouter à la bibliothèque </DialogTitle>
|
||||
<DialogPanel class="w-full max-w-lg bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6">
|
||||
<DialogTitle class="text-lg mb-4 text-gray-900 dark:text-gray-100"> Ajouter à la bibliothèque </DialogTitle>
|
||||
|
||||
<div v-if="selectedManga">
|
||||
<div class="flex gap-4">
|
||||
@@ -49,8 +49,8 @@
|
||||
:alt="selectedManga.title"
|
||||
class="h-48 w-32 object-cover" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-lg">{{ selectedManga.title }}</h4>
|
||||
<p class="mt-2">
|
||||
<h4 class="text-lg text-gray-900 dark:text-gray-100">{{ selectedManga.title }}</h4>
|
||||
<p class="mt-2 text-gray-700 dark:text-gray-300">
|
||||
{{ truncatedDescription }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -61,7 +61,7 @@
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="px-4 py-2 rounded-lg border border-gray-300 hover:bg-gray-50">
|
||||
class="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 dark:bg-gray-800">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
<template>
|
||||
<div>
|
||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
||||
<div class="container mx-auto px-4">
|
||||
<MangaGrid v-if="viewMode === 'grid'" :mangas="collection?.items || []" />
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="w-full">
|
||||
<MangaGrid v-if="viewMode === 'grid'" :mangas="pagedItems" />
|
||||
<MangaList
|
||||
v-else-if="viewMode === 'list'"
|
||||
:mangas="collection?.items || []"
|
||||
:mangas="pagedItems"
|
||||
@manga-click="handleMangaClick" />
|
||||
<MangaTable v-else-if="viewMode === 'table'" :mangas="pagedItems" />
|
||||
<Pagination
|
||||
v-if="totalPages > 1"
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
:total="sortedCollection.length"
|
||||
:limit="prefs.itemsPerPage"
|
||||
:has-next-page="currentPage < totalPages"
|
||||
:has-previous-page="currentPage > 1"
|
||||
@page-change="currentPage = $event" />
|
||||
<div
|
||||
v-if="isBackgroundLoading"
|
||||
class="fixed bottom-4 right-4 bg-gray-800 text-white px-4 py-2 rounded-lg shadow-lg">
|
||||
Mise à jour en cours...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -26,15 +38,19 @@
|
||||
MagnifyingGlassIcon
|
||||
} from '@heroicons/vue/24/outline';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useUserPreferencesStore } from '../../../../domain/setting/application/store/userPreferencesStore';
|
||||
import Pagination from '../../../../shared/components/ui/Pagination.vue';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
import MangaGrid from '../components/MangaGrid.vue';
|
||||
import MangaList from '../components/MangaList.vue';
|
||||
import MangaTable from '../components/MangaTable.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const mangaStore = useMangaStore();
|
||||
const prefs = useUserPreferencesStore();
|
||||
|
||||
const {
|
||||
collection,
|
||||
@@ -43,7 +59,8 @@ import MangaList from '../components/MangaList.vue';
|
||||
isBackgroundLoadingCollection: isBackgroundLoading
|
||||
} = storeToRefs(mangaStore);
|
||||
|
||||
const viewMode = ref('grid');
|
||||
const viewMode = ref(prefs.defaultView);
|
||||
const currentPage = ref(1);
|
||||
|
||||
onMounted(() => {
|
||||
mangaStore.loadCollection();
|
||||
@@ -53,6 +70,27 @@ import MangaList from '../components/MangaList.vue';
|
||||
router.push({ name: 'manga-details', params: { id: manga.id } });
|
||||
};
|
||||
|
||||
const sortedCollection = computed(() => {
|
||||
const items = [...(collection.value?.items || [])];
|
||||
if (prefs.sortBy === 'title') {
|
||||
items.sort((a, b) => a.title.localeCompare(b.title));
|
||||
} else if (prefs.sortBy === 'addedAt') {
|
||||
items.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
const pagedItems = computed(() => {
|
||||
const start = (currentPage.value - 1) * prefs.itemsPerPage;
|
||||
return sortedCollection.value.slice(start, start + prefs.itemsPerPage);
|
||||
});
|
||||
|
||||
const totalPages = computed(() => Math.ceil(sortedCollection.value.length / prefs.itemsPerPage));
|
||||
|
||||
watch(() => prefs.itemsPerPage, () => {
|
||||
currentPage.value = 1;
|
||||
});
|
||||
|
||||
const toolbarConfig = {
|
||||
leftSection: [
|
||||
{
|
||||
@@ -71,8 +109,9 @@ import MangaList from '../components/MangaList.vue';
|
||||
type: 'dropdown',
|
||||
label: 'View',
|
||||
items: [
|
||||
{ label: 'List', onClick: () => (viewMode.value = 'list') },
|
||||
{ label: 'Grid', onClick: () => (viewMode.value = 'grid') }
|
||||
{ label: 'Overview', onClick: () => { viewMode.value = 'list'; prefs.setDefaultView('list'); } },
|
||||
{ label: 'Grid', onClick: () => { viewMode.value = 'grid'; prefs.setDefaultView('grid'); } },
|
||||
{ label: 'Table', onClick: () => { viewMode.value = 'table'; prefs.setDefaultView('table'); } }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -80,10 +119,9 @@ import MangaList from '../components/MangaList.vue';
|
||||
type: 'dropdown',
|
||||
label: 'Sort',
|
||||
items: [
|
||||
{ label: 'Title', onClick: () => {} },
|
||||
{ label: 'Author', onClick: () => {} },
|
||||
{ label: 'Status', onClick: () => {} },
|
||||
{ label: 'Year', onClick: () => {} }
|
||||
{ label: 'Title', onClick: () => prefs.setSortBy('title') },
|
||||
{ label: "Date d'ajout", onClick: () => prefs.setSortBy('addedAt') },
|
||||
{ label: 'Progression', onClick: () => prefs.setSortBy('progress') }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
|
||||
<!-- Notifications Toast -->
|
||||
<NotificationToast />
|
||||
|
||||
<div v-if="errorDetails" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mx-4 mt-4">
|
||||
<Toolbar v-if="currentManga" :config="toolbarConfig" />
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
|
||||
<div v-if="errorDetails" class="bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded mx-4 mt-4">
|
||||
{{ errorDetails.message || 'Une erreur est survenue lors du chargement des détails.' }}
|
||||
</div>
|
||||
|
||||
@@ -11,9 +15,7 @@
|
||||
<!-- Composant invisible qui écoute les mises à jour Mercure -->
|
||||
<MercureListener :manga-id="String(mangaId)" />
|
||||
|
||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
||||
|
||||
<div v-if="isRefreshingDetails" class="absolute top-2 right-2 text-gray-500 z-20">
|
||||
<div v-if="isRefreshingDetails" class="absolute top-2 right-2 text-gray-500 dark:text-gray-400 z-20">
|
||||
<ArrowPathIcon class="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
|
||||
@@ -24,7 +26,7 @@
|
||||
<div v-if="isLoadingVolumes" 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="errorVolumes" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
<div v-else-if="errorVolumes" class="bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded">
|
||||
{{ errorVolumes.message || 'Une erreur est survenue lors du chargement des volumes.' }}
|
||||
</div>
|
||||
<MangaVolumeList
|
||||
@@ -84,9 +86,11 @@
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center text-gray-500 py-10 px-4">
|
||||
<div v-else class="text-center text-gray-500 dark:text-gray-400 py-10 px-4">
|
||||
Aucun manga sélectionné ou trouvé.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { useUserPreferencesStore } from '../../../setting/application/store/userPreferencesStore';
|
||||
import { Chapter } from '../../domain/entities/Chapter';
|
||||
import { ApiChapterRepository } from '../../infrastructure/repository/ApiChapterRepository';
|
||||
|
||||
@@ -163,6 +164,16 @@ export const useReaderStore = defineStore('reader', {
|
||||
loadPreferences() {
|
||||
try {
|
||||
const stored = localStorage.getItem('mangarr-reader-preferences');
|
||||
if (!stored) {
|
||||
const userPrefs = useUserPreferencesStore();
|
||||
this.readingDirection = userPrefs.readingDirection;
|
||||
const modeMap = { scroll: 'infinite', single: 'single', double: 'single' };
|
||||
this.readingMode = modeMap[userPrefs.readingMode] ?? 'single';
|
||||
if (userPrefs.readingMode === 'double') {
|
||||
this.doublePageSettings.autoDetect = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (stored) {
|
||||
const preferences = JSON.parse(stored);
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
||||
import { useUserPreferencesStore } from '../../../../domain/setting/application/store/userPreferencesStore';
|
||||
import { useReaderStore } from '../../application/store/readerStore';
|
||||
import InfiniteReader from './InfiniteReader.vue';
|
||||
import ReaderControls from './ReaderControls.vue';
|
||||
@@ -84,6 +85,7 @@ import SingleModeReader from './SingleModeReader.vue';
|
||||
|
||||
const store = useReaderStore();
|
||||
const headerStore = useHeaderStore();
|
||||
const prefs = useUserPreferencesStore();
|
||||
|
||||
// Référence vers InfiniteReader pour accéder à ses méthodes
|
||||
const infiniteReaderRef = ref(null);
|
||||
@@ -97,6 +99,7 @@ import SingleModeReader from './SingleModeReader.vue';
|
||||
const toggleReadingMode = () => {
|
||||
const newMode = store.readingMode === 'single' ? 'infinite' : 'single';
|
||||
store.setReadingMode(newMode);
|
||||
prefs.setReadingMode(newMode === 'infinite' ? 'scroll' : 'single');
|
||||
|
||||
// Gérer la visibilité selon le mode
|
||||
if (newMode === 'single') {
|
||||
@@ -111,7 +114,9 @@ import SingleModeReader from './SingleModeReader.vue';
|
||||
};
|
||||
|
||||
const toggleReadingDirection = () => {
|
||||
store.setReadingDirection(store.readingDirection === 'ltr' ? 'rtl' : 'ltr');
|
||||
const newDir = store.readingDirection === 'ltr' ? 'rtl' : 'ltr';
|
||||
store.setReadingDirection(newDir);
|
||||
prefs.setReadingDirection(newDir);
|
||||
resetButtonsTimer();
|
||||
};
|
||||
|
||||
@@ -222,6 +227,16 @@ import SingleModeReader from './SingleModeReader.vue';
|
||||
|
||||
window.addEventListener('keydown', handleKeyPress);
|
||||
|
||||
// Auto-hide header si activé dans les préférences
|
||||
if (prefs.autoHideHeaderReader) {
|
||||
headerStore.enableAutoHide();
|
||||
}
|
||||
|
||||
// Auto-fullscreen si activé dans les préférences
|
||||
if (prefs.autoFullscreen && document.documentElement.requestFullscreen) {
|
||||
document.documentElement.requestFullscreen().catch(() => {});
|
||||
}
|
||||
|
||||
// Afficher les boutons au démarrage
|
||||
showButtonsWithTimer();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
const STORAGE_KEY = 'mangarr_preferences';
|
||||
|
||||
const defaultState = {
|
||||
theme: 'system',
|
||||
language: 'fr',
|
||||
defaultView: 'grid',
|
||||
itemsPerPage: 20,
|
||||
sortBy: 'title',
|
||||
readingDirection: 'ltr',
|
||||
readingMode: 'scroll',
|
||||
autoFullscreen: false,
|
||||
autoHideHeaderReader: true,
|
||||
toastDuration: 5000,
|
||||
};
|
||||
|
||||
function loadFromStorage() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
return { ...defaultState, ...JSON.parse(stored) };
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
return { ...defaultState };
|
||||
}
|
||||
|
||||
let mediaQueryUnsubscribe = null;
|
||||
|
||||
export const useUserPreferencesStore = defineStore('userPreferences', {
|
||||
state: () => loadFromStorage(),
|
||||
|
||||
actions: {
|
||||
applyTheme() {
|
||||
// Nettoyer le listener précédent
|
||||
if (mediaQueryUnsubscribe) {
|
||||
mediaQueryUnsubscribe();
|
||||
mediaQueryUnsubscribe = null;
|
||||
}
|
||||
|
||||
const html = document.documentElement;
|
||||
|
||||
if (this.theme === 'dark') {
|
||||
html.classList.add('dark');
|
||||
} else if (this.theme === 'light') {
|
||||
html.classList.remove('dark');
|
||||
} else {
|
||||
// mode 'system'
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (e) => {
|
||||
if (e.matches) {
|
||||
html.classList.add('dark');
|
||||
} else {
|
||||
html.classList.remove('dark');
|
||||
}
|
||||
};
|
||||
handler(mq);
|
||||
mq.addEventListener('change', handler);
|
||||
mediaQueryUnsubscribe = () => mq.removeEventListener('change', handler);
|
||||
}
|
||||
},
|
||||
|
||||
setTheme(theme) {
|
||||
this.theme = theme;
|
||||
this.persist();
|
||||
this.applyTheme();
|
||||
},
|
||||
|
||||
setLanguage(language) {
|
||||
this.language = language;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setDefaultView(view) {
|
||||
this.defaultView = view;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setItemsPerPage(count) {
|
||||
this.itemsPerPage = count;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setSortBy(sort) {
|
||||
this.sortBy = sort;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setReadingDirection(direction) {
|
||||
this.readingDirection = direction;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setReadingMode(mode) {
|
||||
this.readingMode = mode;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setAutoFullscreen(value) {
|
||||
this.autoFullscreen = value;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setAutoHideHeaderReader(value) {
|
||||
this.autoHideHeaderReader = value;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setToastDuration(duration) {
|
||||
this.toastDuration = duration;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
resetToDefaults() {
|
||||
Object.assign(this, defaultState);
|
||||
this.persist();
|
||||
this.applyTheme();
|
||||
},
|
||||
|
||||
persist() {
|
||||
try {
|
||||
const data = {
|
||||
theme: this.theme,
|
||||
language: this.language,
|
||||
defaultView: this.defaultView,
|
||||
itemsPerPage: this.itemsPerPage,
|
||||
sortBy: this.sortBy,
|
||||
readingDirection: this.readingDirection,
|
||||
readingMode: this.readingMode,
|
||||
autoFullscreen: this.autoFullscreen,
|
||||
autoHideHeaderReader: this.autoHideHeaderReader,
|
||||
toastDuration: this.toastDuration,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
@@ -71,6 +72,7 @@
|
||||
Configuration exportée !
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Modal -->
|
||||
<div v-if="showImportModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<!-- Back Navigation -->
|
||||
<div class="mb-6">
|
||||
@@ -180,6 +181,7 @@
|
||||
Configuration {{ isEditing ? 'mise à jour' : 'créée' }} avec succès !
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<div class="overflow-y-auto h-full"><div class="container mx-auto px-4 py-8 max-w-3xl">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('preferences.title') }}</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ t('preferences.subtitle') }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
@click="handleReset">
|
||||
{{ t('preferences.reset') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Apparence -->
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mb-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||
{{ t('preferences.sections.appearance') }}
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
<!-- Thème -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.theme.label') }}</label>
|
||||
<select
|
||||
:value="store.theme"
|
||||
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@change="store.setTheme($event.target.value)">
|
||||
<option value="light">{{ t('preferences.theme.light') }}</option>
|
||||
<option value="dark">{{ t('preferences.theme.dark') }}</option>
|
||||
<option value="system">{{ t('preferences.theme.system') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Langue -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.language.label') }}</label>
|
||||
<select
|
||||
:value="store.language"
|
||||
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@change="handleLanguageChange($event.target.value)">
|
||||
<option value="fr">{{ t('preferences.language.fr') }}</option>
|
||||
<option value="en">{{ t('preferences.language.en') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Affichage collection -->
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mb-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||
{{ t('preferences.sections.collection') }}
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
<!-- Vue par défaut -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.defaultView.label') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
:class="viewButtonClass('grid')"
|
||||
@click="store.setDefaultView('grid')">
|
||||
{{ t('preferences.defaultView.grid') }}
|
||||
</button>
|
||||
<button
|
||||
:class="viewButtonClass('list')"
|
||||
@click="store.setDefaultView('list')">
|
||||
{{ t('preferences.defaultView.list') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mangas par page -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.itemsPerPage.label') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="n in [12, 20, 40]"
|
||||
:key="n"
|
||||
:class="countButtonClass(n)"
|
||||
@click="store.setItemsPerPage(n)">
|
||||
{{ n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tri par défaut -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.sortBy.label') }}</label>
|
||||
<select
|
||||
:value="store.sortBy"
|
||||
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@change="store.setSortBy($event.target.value)">
|
||||
<option value="title">{{ t('preferences.sortBy.title') }}</option>
|
||||
<option value="addedAt">{{ t('preferences.sortBy.addedAt') }}</option>
|
||||
<option value="progress">{{ t('preferences.sortBy.progress') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Lecture -->
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mb-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||
{{ t('preferences.sections.reading') }}
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
<!-- Direction de lecture -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.readingDirection.label') }}</label>
|
||||
<select
|
||||
:value="store.readingDirection"
|
||||
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@change="store.setReadingDirection($event.target.value)">
|
||||
<option value="ltr">{{ t('preferences.readingDirection.ltr') }}</option>
|
||||
<option value="rtl">{{ t('preferences.readingDirection.rtl') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Mode d'affichage -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.readingMode.label') }}</label>
|
||||
<select
|
||||
:value="store.readingMode"
|
||||
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@change="store.setReadingMode($event.target.value)">
|
||||
<option value="scroll">{{ t('preferences.readingMode.scroll') }}</option>
|
||||
<option value="single">{{ t('preferences.readingMode.single') }}</option>
|
||||
<option value="double">{{ t('preferences.readingMode.double') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Auto plein écran -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.autoFullscreen.label') }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ t('preferences.autoFullscreen.description') }}</p>
|
||||
</div>
|
||||
<button
|
||||
:class="toggleClass(store.autoFullscreen)"
|
||||
role="switch"
|
||||
:aria-checked="store.autoFullscreen"
|
||||
@click="store.setAutoFullscreen(!store.autoFullscreen)">
|
||||
<span :class="toggleKnobClass(store.autoFullscreen)" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Auto-hide header -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.autoHideHeaderReader.label') }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ t('preferences.autoHideHeaderReader.description') }}</p>
|
||||
</div>
|
||||
<button
|
||||
:class="toggleClass(store.autoHideHeaderReader)"
|
||||
role="switch"
|
||||
:aria-checked="store.autoHideHeaderReader"
|
||||
@click="store.setAutoHideHeaderReader(!store.autoHideHeaderReader)">
|
||||
<span :class="toggleKnobClass(store.autoHideHeaderReader)" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Notifications -->
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mb-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||
{{ t('preferences.sections.notifications') }}
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
<!-- Durée des toasts -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.toastDuration.label') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="[val, label] in toastOptions"
|
||||
:key="val"
|
||||
:class="countButtonClass(val, store.toastDuration)"
|
||||
@click="store.setToastDuration(val)">
|
||||
{{ t(label) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useUserPreferencesStore } from '../../application/store/userPreferencesStore';
|
||||
import { i18n } from '../../../../shared/i18n';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const store = useUserPreferencesStore();
|
||||
|
||||
const toastOptions = [
|
||||
[3000, 'preferences.toastDuration.3s'],
|
||||
[5000, 'preferences.toastDuration.5s'],
|
||||
[10000, 'preferences.toastDuration.10s'],
|
||||
];
|
||||
|
||||
function handleLanguageChange(lang) {
|
||||
store.setLanguage(lang);
|
||||
i18n.global.locale.value = lang;
|
||||
locale.value = lang;
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
if (confirm(t('preferences.resetConfirm'))) {
|
||||
store.resetToDefaults();
|
||||
i18n.global.locale.value = store.language;
|
||||
locale.value = store.language;
|
||||
}
|
||||
}
|
||||
|
||||
function viewButtonClass(view) {
|
||||
const active = store.defaultView === view;
|
||||
return [
|
||||
'px-3 py-1.5 text-sm rounded-lg border transition-colors',
|
||||
active
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700',
|
||||
];
|
||||
}
|
||||
|
||||
function countButtonClass(val, current = store.itemsPerPage) {
|
||||
const active = current === val;
|
||||
return [
|
||||
'px-3 py-1.5 text-sm rounded-lg border transition-colors',
|
||||
active
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700',
|
||||
];
|
||||
}
|
||||
|
||||
function toggleClass(active) {
|
||||
return [
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
|
||||
active ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-600',
|
||||
];
|
||||
}
|
||||
|
||||
function toggleKnobClass(active) {
|
||||
return [
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
||||
active ? 'translate-x-6' : 'translate-x-1',
|
||||
];
|
||||
}
|
||||
</script>
|
||||
@@ -4,6 +4,9 @@ import App from './App.vue';
|
||||
import { router } from './router';
|
||||
import '../../styles/app.scss';
|
||||
import { installVueQuery } from './shared/plugin/vueQuery';
|
||||
import { i18n } from './shared/i18n';
|
||||
import { useUserPreferencesStore } from './domain/setting/application/store/userPreferencesStore';
|
||||
|
||||
// Création du store
|
||||
const pinia = createPinia();
|
||||
|
||||
@@ -14,5 +17,12 @@ const app = createApp(App);
|
||||
app.use(router);
|
||||
app.use(pinia);
|
||||
app.use(installVueQuery);
|
||||
app.use(i18n);
|
||||
|
||||
// Appliquer le thème et la langue sauvegardés
|
||||
const prefs = useUserPreferencesStore();
|
||||
prefs.applyTheme();
|
||||
i18n.global.locale.value = prefs.language;
|
||||
|
||||
// Montage de l'application
|
||||
app.mount('#vue-app');
|
||||
|
||||
@@ -8,6 +8,7 @@ import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue';
|
||||
import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue';
|
||||
import ScrapperConfigurations from '../domain/setting/presentation/pages/ScrapperConfigurations.vue';
|
||||
import ScrapperEdit from '../domain/setting/presentation/pages/ScrapperEdit.vue';
|
||||
import UserPreferencesPage from '../domain/setting/presentation/pages/UserPreferencesPage.vue';
|
||||
import Layout from '../shared/components/layout/Layout.vue';
|
||||
|
||||
// Placeholder component for new routes
|
||||
@@ -129,8 +130,7 @@ const routes = [
|
||||
{
|
||||
path: '/settings/ui',
|
||||
name: 'settings-ui',
|
||||
component: PlaceholderComponent,
|
||||
props: { title: "Paramètres de l'interface" }
|
||||
component: UserPreferencesPage
|
||||
},
|
||||
// Système
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 flex">
|
||||
<div class="h-screen overflow-hidden bg-gray-50 dark:bg-gray-900 flex">
|
||||
<Header
|
||||
:show-menu-button="isReaderMode"
|
||||
@menu-click="toggleSidebar"
|
||||
@@ -12,7 +12,7 @@
|
||||
@add-manga-click="$emit('add-manga-click', $event)" />
|
||||
|
||||
<main :class="[
|
||||
'flex-1 pt-16',
|
||||
'flex-1 mt-16 flex flex-col overflow-hidden',
|
||||
isReaderMode ? '' : 'md:ml-60'
|
||||
]">
|
||||
<RouterView></RouterView>
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
<template>
|
||||
<div class="fixed top-4 right-4 z-50 space-y-2">
|
||||
<div class="fixed bottom-4 left-4 z-50 flex flex-col-reverse gap-2">
|
||||
<TransitionGroup
|
||||
name="notification"
|
||||
tag="div"
|
||||
class="space-y-2"
|
||||
class="flex flex-col-reverse gap-2"
|
||||
>
|
||||
<div
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
:class="[
|
||||
'max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden',
|
||||
'max-w-md w-full bg-white dark:bg-gray-800 shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden',
|
||||
getNotificationClass(notification.type)
|
||||
]"
|
||||
>
|
||||
<div class="p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<component :is="getIcon(notification.type)" :class="[
|
||||
'h-6 w-6',
|
||||
getIconClass(notification.type)
|
||||
]" />
|
||||
</div>
|
||||
<div class="ml-3 w-0 flex-1 pt-0.5">
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{{ notification.message }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-4 flex-shrink-0 flex">
|
||||
<div class="flex-shrink-0 mr-3">
|
||||
<button
|
||||
@click="removeNotification(notification.id)"
|
||||
class="bg-white rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
class="bg-white dark:bg-gray-800 rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<span class="sr-only">Close</span>
|
||||
<XMarkIcon class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 pt-0.5 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 break-words">
|
||||
{{ notification.message }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-shrink-0 ml-3">
|
||||
<component :is="getIcon(notification.type)" :class="[
|
||||
'h-6 w-6',
|
||||
getIconClass(notification.type)
|
||||
]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,10 +66,10 @@ const getIcon = (type) => {
|
||||
|
||||
const getNotificationClass = (type) => {
|
||||
const classes = {
|
||||
success: 'border-l-4 border-green-400',
|
||||
error: 'border-l-4 border-red-400',
|
||||
warning: 'border-l-4 border-yellow-400',
|
||||
info: 'border-l-4 border-blue-400'
|
||||
success: 'border-r-4 border-green-400',
|
||||
error: 'border-r-4 border-red-400',
|
||||
warning: 'border-r-4 border-yellow-400',
|
||||
info: 'border-r-4 border-blue-400'
|
||||
};
|
||||
return classes[type] || classes.info;
|
||||
};
|
||||
@@ -93,11 +93,11 @@ const getIconClass = (type) => {
|
||||
|
||||
.notification-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.notification-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-between px-4 py-3 bg-white border-t border-gray-200">
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-between px-4 py-3 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
|
||||
<!-- Informations de pagination -->
|
||||
<div class="flex items-center text-sm text-gray-700">
|
||||
<div class="flex items-center text-sm text-gray-700 dark:text-gray-300">
|
||||
<span>
|
||||
Affichage de
|
||||
<span class="font-medium">{{ startItem }}</span>
|
||||
@@ -22,8 +22,8 @@
|
||||
:class="[
|
||||
'relative inline-flex items-center px-2 py-2 text-sm font-medium rounded-md',
|
||||
hasPreviousPage
|
||||
? 'text-gray-500 bg-white border border-gray-300 hover:bg-gray-50'
|
||||
: 'text-gray-300 bg-gray-100 border border-gray-200 cursor-not-allowed'
|
||||
? 'text-gray-500 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
: 'text-gray-300 dark:text-gray-600 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 cursor-not-allowed'
|
||||
]">
|
||||
<span class="sr-only">Précédent</span>
|
||||
<ChevronLeftIcon class="h-5 w-5" />
|
||||
@@ -38,14 +38,14 @@
|
||||
:class="[
|
||||
'relative inline-flex items-center px-3 py-2 text-sm font-medium rounded-md',
|
||||
currentPage === 1
|
||||
? 'z-10 bg-indigo-50 border-indigo-500 text-indigo-600'
|
||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||
? 'z-10 bg-indigo-50 dark:bg-indigo-900/30 border-indigo-500 text-indigo-600 dark:text-indigo-400'
|
||||
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
]">
|
||||
1
|
||||
</button>
|
||||
|
||||
<!-- Points de suspension gauche -->
|
||||
<span v-if="showLeftDots" class="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700">
|
||||
<span v-if="showLeftDots" class="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
...
|
||||
</span>
|
||||
|
||||
@@ -57,14 +57,14 @@
|
||||
:class="[
|
||||
'relative inline-flex items-center px-3 py-2 text-sm font-medium rounded-md',
|
||||
currentPage === page
|
||||
? 'z-10 bg-indigo-50 border-indigo-500 text-indigo-600'
|
||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||
? 'z-10 bg-indigo-50 dark:bg-indigo-900/30 border-indigo-500 text-indigo-600 dark:text-indigo-400'
|
||||
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
]">
|
||||
{{ page }}
|
||||
</button>
|
||||
|
||||
<!-- Points de suspension droite -->
|
||||
<span v-if="showRightDots" class="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700">
|
||||
<span v-if="showRightDots" class="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
...
|
||||
</span>
|
||||
|
||||
@@ -75,8 +75,8 @@
|
||||
:class="[
|
||||
'relative inline-flex items-center px-3 py-2 text-sm font-medium rounded-md',
|
||||
currentPage === totalPages
|
||||
? 'z-10 bg-indigo-50 border-indigo-500 text-indigo-600'
|
||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||
? 'z-10 bg-indigo-50 dark:bg-indigo-900/30 border-indigo-500 text-indigo-600 dark:text-indigo-400'
|
||||
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
]">
|
||||
{{ totalPages }}
|
||||
</button>
|
||||
@@ -84,7 +84,7 @@
|
||||
|
||||
<!-- Pagination mobile -->
|
||||
<div class="md:hidden flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ currentPage }} / {{ totalPages }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -96,8 +96,8 @@
|
||||
:class="[
|
||||
'relative inline-flex items-center px-2 py-2 text-sm font-medium rounded-md',
|
||||
hasNextPage
|
||||
? 'text-gray-500 bg-white border border-gray-300 hover:bg-gray-50'
|
||||
: 'text-gray-300 bg-gray-100 border border-gray-200 cursor-not-allowed'
|
||||
? 'text-gray-500 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
: 'text-gray-300 dark:text-gray-600 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 cursor-not-allowed'
|
||||
]">
|
||||
<span class="sr-only">Suivant</span>
|
||||
<ChevronRightIcon class="h-5 w-5" />
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
type: Object,
|
||||
required: true,
|
||||
validator: value => {
|
||||
// Vérifie que leftSection et rightSection sont des tableaux
|
||||
return Array.isArray(value.leftSection) && Array.isArray(value.rightSection);
|
||||
}
|
||||
}
|
||||
|
||||
45
assets/vue/app/shared/composables/useMercureNotifications.js
Normal file
45
assets/vue/app/shared/composables/useMercureNotifications.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useNotifications } from './useNotifications';
|
||||
|
||||
export function useMercureNotifications() {
|
||||
const { showSuccess, showError, showInfo, showWarning } = useNotifications();
|
||||
let eventSource = null;
|
||||
|
||||
const handleNotification = data => {
|
||||
const message = data.message ?? 'Notification';
|
||||
switch (data.status) {
|
||||
case 'success': showSuccess(message); break;
|
||||
case 'error': showError(message); break;
|
||||
case 'warning': showWarning(message); break;
|
||||
default: showInfo(message);
|
||||
}
|
||||
};
|
||||
|
||||
const setup = () => {
|
||||
const url = new URL('/.well-known/mercure', window.location.origin);
|
||||
url.searchParams.append('topic', 'notifications');
|
||||
|
||||
eventSource = new EventSource(url, { withCredentials: true });
|
||||
|
||||
eventSource.onmessage = event => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
handleNotification(data);
|
||||
} catch (e) {
|
||||
console.error('useMercureNotifications: erreur de parsing', e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource?.close();
|
||||
setTimeout(setup, 5000);
|
||||
};
|
||||
};
|
||||
|
||||
onMounted(setup);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
eventSource?.close();
|
||||
eventSource = null;
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ref } from 'vue';
|
||||
import { useUserPreferencesStore } from '../../domain/setting/application/store/userPreferencesStore';
|
||||
|
||||
const notifications = ref([]);
|
||||
let nextId = 1;
|
||||
@@ -36,20 +37,24 @@ export function useNotifications() {
|
||||
notifications.value = [];
|
||||
};
|
||||
|
||||
const showSuccess = (message, duration = 4000) => {
|
||||
return addNotification(message, 'success', duration);
|
||||
const showSuccess = (message, duration) => {
|
||||
const prefs = useUserPreferencesStore();
|
||||
return addNotification(message, 'success', duration ?? prefs.toastDuration);
|
||||
};
|
||||
|
||||
const showError = (message, duration = 6000) => {
|
||||
return addNotification(message, 'error', duration);
|
||||
const showError = (message, duration) => {
|
||||
const prefs = useUserPreferencesStore();
|
||||
return addNotification(message, 'error', duration ?? prefs.toastDuration);
|
||||
};
|
||||
|
||||
const showWarning = (message, duration = 5000) => {
|
||||
return addNotification(message, 'warning', duration);
|
||||
const showWarning = (message, duration) => {
|
||||
const prefs = useUserPreferencesStore();
|
||||
return addNotification(message, 'warning', duration ?? prefs.toastDuration);
|
||||
};
|
||||
|
||||
const showInfo = (message, duration = 4000) => {
|
||||
return addNotification(message, 'info', duration);
|
||||
const showInfo = (message, duration) => {
|
||||
const prefs = useUserPreferencesStore();
|
||||
return addNotification(message, 'info', duration ?? prefs.toastDuration);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
10
assets/vue/app/shared/i18n/index.js
Normal file
10
assets/vue/app/shared/i18n/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import fr from './locales/fr.json';
|
||||
import en from './locales/en.json';
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'fr',
|
||||
fallbackLocale: 'fr',
|
||||
messages: { fr, en },
|
||||
});
|
||||
67
assets/vue/app/shared/i18n/locales/en.json
Normal file
67
assets/vue/app/shared/i18n/locales/en.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"nav": {
|
||||
"preferences": "Preferences"
|
||||
},
|
||||
"preferences": {
|
||||
"title": "Preferences",
|
||||
"subtitle": "Customize the interface to your liking",
|
||||
"reset": "Reset",
|
||||
"resetConfirm": "Reset to default values?",
|
||||
"sections": {
|
||||
"appearance": "Appearance",
|
||||
"collection": "Collection display",
|
||||
"reading": "Reading",
|
||||
"notifications": "Notifications"
|
||||
},
|
||||
"theme": {
|
||||
"label": "Theme",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System (automatic)"
|
||||
},
|
||||
"language": {
|
||||
"label": "Language",
|
||||
"fr": "Français",
|
||||
"en": "English"
|
||||
},
|
||||
"defaultView": {
|
||||
"label": "Default view",
|
||||
"grid": "Grid",
|
||||
"list": "List"
|
||||
},
|
||||
"itemsPerPage": {
|
||||
"label": "Mangas per page"
|
||||
},
|
||||
"sortBy": {
|
||||
"label": "Default sort",
|
||||
"title": "Title",
|
||||
"addedAt": "Date added",
|
||||
"progress": "Progress"
|
||||
},
|
||||
"readingDirection": {
|
||||
"label": "Reading direction",
|
||||
"ltr": "Left → Right (western)",
|
||||
"rtl": "Right → Left (manga)"
|
||||
},
|
||||
"readingMode": {
|
||||
"label": "Display mode",
|
||||
"scroll": "Vertical scroll",
|
||||
"single": "Single page",
|
||||
"double": "Double page"
|
||||
},
|
||||
"autoFullscreen": {
|
||||
"label": "Auto fullscreen",
|
||||
"description": "Enter fullscreen when starting the reader"
|
||||
},
|
||||
"autoHideHeaderReader": {
|
||||
"label": "Auto-hide header",
|
||||
"description": "Hide the navigation bar in reading mode"
|
||||
},
|
||||
"toastDuration": {
|
||||
"label": "Notification duration",
|
||||
"3s": "3 seconds",
|
||||
"5s": "5 seconds",
|
||||
"10s": "10 seconds"
|
||||
}
|
||||
}
|
||||
}
|
||||
67
assets/vue/app/shared/i18n/locales/fr.json
Normal file
67
assets/vue/app/shared/i18n/locales/fr.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"nav": {
|
||||
"preferences": "Préférences"
|
||||
},
|
||||
"preferences": {
|
||||
"title": "Préférences",
|
||||
"subtitle": "Personnalisez l'interface selon vos goûts",
|
||||
"reset": "Réinitialiser",
|
||||
"resetConfirm": "Remettre les valeurs par défaut ?",
|
||||
"sections": {
|
||||
"appearance": "Apparence",
|
||||
"collection": "Affichage de la collection",
|
||||
"reading": "Lecture",
|
||||
"notifications": "Notifications"
|
||||
},
|
||||
"theme": {
|
||||
"label": "Thème",
|
||||
"light": "Clair",
|
||||
"dark": "Sombre",
|
||||
"system": "Système (automatique)"
|
||||
},
|
||||
"language": {
|
||||
"label": "Langue",
|
||||
"fr": "Français",
|
||||
"en": "English"
|
||||
},
|
||||
"defaultView": {
|
||||
"label": "Vue par défaut",
|
||||
"grid": "Grille",
|
||||
"list": "Liste"
|
||||
},
|
||||
"itemsPerPage": {
|
||||
"label": "Mangas par page"
|
||||
},
|
||||
"sortBy": {
|
||||
"label": "Tri par défaut",
|
||||
"title": "Titre",
|
||||
"addedAt": "Date d'ajout",
|
||||
"progress": "Progression"
|
||||
},
|
||||
"readingDirection": {
|
||||
"label": "Direction de lecture",
|
||||
"ltr": "Gauche → Droite (occidental)",
|
||||
"rtl": "Droite → Gauche (manga)"
|
||||
},
|
||||
"readingMode": {
|
||||
"label": "Mode d'affichage",
|
||||
"scroll": "Défilement vertical",
|
||||
"single": "Page unique",
|
||||
"double": "Double page"
|
||||
},
|
||||
"autoFullscreen": {
|
||||
"label": "Plein écran automatique",
|
||||
"description": "Passer en plein écran au démarrage du lecteur"
|
||||
},
|
||||
"autoHideHeaderReader": {
|
||||
"label": "Masquer automatiquement l'en-tête",
|
||||
"description": "Masquer la barre de navigation en mode lecture"
|
||||
},
|
||||
"toastDuration": {
|
||||
"label": "Durée des notifications",
|
||||
"3s": "3 secondes",
|
||||
"5s": "5 secondes",
|
||||
"10s": "10 secondes"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ framework:
|
||||
'App\Domain\Manga\Application\Command\RefreshMangaChapters': commands
|
||||
|
||||
# Events spécifiques (pour compatibilité, peuvent être supprimés si tous implémentent AsyncDomainEvent)
|
||||
'App\Domain\Scraping\Domain\Event\ChapterScrapingStarted': events
|
||||
# ChapterScrapingStarted est synchrone pour que la notif "démarrage" arrive AVANT le scraping
|
||||
'App\Domain\Scraping\Domain\Event\ChapterScrapingCompleted': events
|
||||
'App\Domain\Scraping\Domain\Event\ChapterScrapingFailed': events
|
||||
'App\Domain\Manga\Domain\Event\ChapterReadyForScraping': events
|
||||
|
||||
1965
package-lock.json
generated
1965
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,7 @@
|
||||
"react-router-dom": "^7.1.5",
|
||||
"sortablejs": "^1.15.2",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"vue-i18n": "^11.3.0",
|
||||
"vuedraggable": "^2.24.3"
|
||||
}
|
||||
}
|
||||
|
||||
55
src/Command/SendTestNotificationCommand.php
Normal file
55
src/Command/SendTestNotificationCommand.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Domain\Shared\Domain\Contract\NotificationInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:notify:test',
|
||||
description: 'Envoie une notification de test via Mercure (utile en dev/prod pour vérifier le système)',
|
||||
)]
|
||||
class SendTestNotificationCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NotificationInterface $notification
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('type', 't', InputOption::VALUE_REQUIRED, 'Type de notification : info, success, error, warning', 'info')
|
||||
->addOption('message', 'm', InputOption::VALUE_REQUIRED, 'Message à envoyer', 'Notification de test depuis Mangarr');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$type = $input->getOption('type');
|
||||
$message = $input->getOption('message');
|
||||
|
||||
$allowed = ['info', 'success', 'error', 'warning'];
|
||||
if (!in_array($type, $allowed, true)) {
|
||||
$output->writeln(sprintf('<error>Type invalide "%s". Valeurs acceptées : %s</error>', $type, implode(', ', $allowed)));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
match ($type) {
|
||||
'success' => $this->notification->sendSuccess($message),
|
||||
'error' => $this->notification->sendError($message),
|
||||
'warning' => $this->notification->sendWarning($message),
|
||||
default => $this->notification->sendInfo($message),
|
||||
};
|
||||
|
||||
$output->writeln(sprintf('<info>[%s] Notification envoyée : %s</info>', strtoupper($type), $message));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,12 @@ readonly class MangadexProvider implements MangaProviderInterface
|
||||
{
|
||||
try {
|
||||
$attributes = $result['attributes'];
|
||||
$title = $attributes['title']['en'] ?? null;
|
||||
$title = $attributes['title']['en']
|
||||
?? $attributes['title']['fr']
|
||||
?? $attributes['title']['ja-ro']
|
||||
?? $attributes['title']['ko-ro']
|
||||
?? $attributes['title']['zh-ro']
|
||||
?? (!empty($attributes['title']) ? reset($attributes['title']) : null);
|
||||
|
||||
if (!$title) {
|
||||
return null;
|
||||
@@ -77,7 +82,7 @@ readonly class MangadexProvider implements MangaProviderInterface
|
||||
}
|
||||
if ($relationship['type'] === 'cover_art') {
|
||||
$imageUrl = sprintf(
|
||||
'https://mangadex.org/covers/%s/%s',
|
||||
'https://uploads.mangadex.org/covers/%s/%s.512.jpg',
|
||||
$result['id'],
|
||||
$relationship['attributes']['fileName']
|
||||
);
|
||||
|
||||
@@ -204,8 +204,9 @@ readonly class MangadxChapterSynchronizationService implements ChapterSynchroniz
|
||||
}
|
||||
}
|
||||
|
||||
// Si on a trouvé un volume précédent et que le suivant est le même ou null, alors utilise le précédent
|
||||
if ($prevVolume !== null && ($nextVolume === null || $nextVolume === $prevVolume)) {
|
||||
// Priorité au volume précédent : le chapitre appartient à la fin du volume en cours
|
||||
// Couvre les cas : milieu de volume (prev=next), transition entre deux volumes (prev≠next)
|
||||
if ($prevVolume !== null) {
|
||||
$chaptersByNumber[$currentChapterNum] = new Chapter(
|
||||
new ChapterId($currentChapter->getId()),
|
||||
$currentChapter->getMangaId(),
|
||||
@@ -218,8 +219,8 @@ readonly class MangadxChapterSynchronizationService implements ChapterSynchroniz
|
||||
$currentChapter->getCreatedAt()
|
||||
);
|
||||
}
|
||||
// Si on a trouvé un volume suivant mais pas de précédent, utilise le suivant
|
||||
elseif ($nextVolume !== null && $prevVolume === null) {
|
||||
// Sinon utilise le volume suivant (chapitres en début de série)
|
||||
elseif ($nextVolume !== null) {
|
||||
$chaptersByNumber[$currentChapterNum] = new Chapter(
|
||||
new ChapterId($currentChapter->getId()),
|
||||
$currentChapter->getMangaId(),
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Domain\Scraping\Domain\Contract\Service\ImageDownloaderInterface;
|
||||
use App\Domain\Scraping\Domain\Contract\Service\ScraperFactoryInterface;
|
||||
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
|
||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted;
|
||||
use App\Domain\Scraping\Domain\Model\Chapter;
|
||||
use App\Domain\Scraping\Domain\Model\ScrapingJob;
|
||||
use App\Domain\Scraping\Domain\Model\Source;
|
||||
@@ -53,13 +54,16 @@ readonly class ScrapeChapterHandler
|
||||
throw new \InvalidArgumentException("Manga not found with ID: {$chapter->mangaId}");
|
||||
}
|
||||
|
||||
// 3. Détermination des sources à utiliser
|
||||
// 3. Dispatch de l'événement de démarrage
|
||||
$this->eventBus->dispatch(new ChapterScrapingStarted($manga->getTitle(), $chapter->chapterNumber));
|
||||
|
||||
// 4. Détermination des sources à utiliser
|
||||
$sources = $this->getSourcesToTry($manga);
|
||||
if (empty($sources)) {
|
||||
throw new \InvalidArgumentException("No sources available for scraping");
|
||||
}
|
||||
|
||||
// 4. Essai de scraping sur chaque source jusqu'à succès
|
||||
// 5. Essai de scraping sur chaque source jusqu'à succès
|
||||
$success = false;
|
||||
$lastException = null;
|
||||
|
||||
|
||||
@@ -5,12 +5,18 @@ namespace App\Domain\Scraping\Domain\Event;
|
||||
class ChapterScrapingStarted
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $jobId
|
||||
private readonly string $mangaTitle,
|
||||
private readonly float $chapterNumber,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getJobId(): string
|
||||
public function getMangaTitle(): string
|
||||
{
|
||||
return $this->jobId;
|
||||
return $this->mangaTitle;
|
||||
}
|
||||
|
||||
public function getChapterNumber(): float
|
||||
{
|
||||
return $this->chapterNumber;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ namespace App\Domain\Scraping\Infrastructure\EventSubscriber;
|
||||
|
||||
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
|
||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted;
|
||||
use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface;
|
||||
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
|
||||
use App\Domain\Shared\Domain\Contract\NotificationInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\Mercure\HubInterface;
|
||||
@@ -18,15 +20,25 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
|
||||
private readonly HubInterface $hub,
|
||||
private readonly ChapterRepositoryInterface $chapterRepository,
|
||||
private readonly JobRepositoryInterface $jobRepository,
|
||||
private readonly NotificationInterface $notification,
|
||||
private readonly LoggerInterface $logger
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
// Les événements sont capturés via le système de message handlers
|
||||
];
|
||||
return [];
|
||||
}
|
||||
|
||||
#[AsMessageHandler]
|
||||
public function onChapterScrapingStarted(ChapterScrapingStarted $event): void
|
||||
{
|
||||
$chapterNumber = $event->getChapterNumber();
|
||||
$mangaTitle = $event->getMangaTitle();
|
||||
|
||||
$this->notification->sendInfo(
|
||||
sprintf('Scraping du chapitre %s de "%s" démarré', $chapterNumber, $mangaTitle)
|
||||
);
|
||||
}
|
||||
|
||||
#[AsMessageHandler]
|
||||
@@ -42,7 +54,6 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer le chapitre associé au job
|
||||
$chapterId = $job->context['chapterId'] ?? null;
|
||||
$this->logger->info('ChapterId extrait du job: ' . $chapterId);
|
||||
|
||||
@@ -55,7 +66,6 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
|
||||
|
||||
$this->logger->info('Chapitre trouvé - ID: ' . $chapter->id . ', MangaId: ' . $chapter->mangaId . ', Number: ' . $chapter->chapterNumber);
|
||||
|
||||
// Préparer les données à envoyer au front
|
||||
$data = [
|
||||
'type' => 'chapter.scraped',
|
||||
'chapterId' => $chapter->id,
|
||||
@@ -65,21 +75,19 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
|
||||
'timestamp' => (new \DateTimeImmutable())->format('c')
|
||||
];
|
||||
|
||||
$this->logger->info('Données préparées pour Mercure: ' . json_encode($data));
|
||||
|
||||
// Publier une mise à jour sur le hub Mercure
|
||||
$topics = [
|
||||
'manga/chapter/' . $chapter->id, // Topic spécifique au chapitre
|
||||
'manga/' . $chapter->mangaId . '/chapters', // Topic pour tous les chapitres d'un manga
|
||||
'scraping/status' // Topic général pour les événements de scraping
|
||||
'manga/chapter/' . $chapter->id,
|
||||
'manga/' . $chapter->mangaId . '/chapters',
|
||||
'scraping/status'
|
||||
];
|
||||
|
||||
$this->logger->info('Topics Mercure: ' . implode(', ', $topics));
|
||||
|
||||
$update = new Update($topics, json_encode($data));
|
||||
$this->hub->publish($update);
|
||||
|
||||
$this->logger->info('Mise à jour publiée sur Mercure');
|
||||
$mangaTitle = $job->context['mangaTitle'] ?? 'manga inconnu';
|
||||
$this->notification->sendSuccess(
|
||||
sprintf('Chapitre %s de "%s" scrappé avec succès', $chapter->chapterNumber, $mangaTitle)
|
||||
);
|
||||
}
|
||||
|
||||
#[AsMessageHandler]
|
||||
@@ -87,7 +95,6 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
$this->logger->info('ChapterScrapingFailed reçu pour mangaId: ' . $event->getMangaId() . ', chapter: ' . $event->getChapterNumber());
|
||||
|
||||
// Préparer les données à envoyer au front
|
||||
$data = [
|
||||
'type' => 'chapter.scraping.failed',
|
||||
'mangaId' => $event->getMangaId(),
|
||||
@@ -96,19 +103,16 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
|
||||
'timestamp' => (new \DateTimeImmutable())->format('c')
|
||||
];
|
||||
|
||||
$this->logger->info('Données préparées pour Mercure: ' . json_encode($data));
|
||||
|
||||
// Publier une mise à jour sur le hub Mercure
|
||||
$topics = [
|
||||
'manga/' . $event->getMangaId() . '/chapters', // Topic pour tous les chapitres d'un manga
|
||||
'scraping/status' // Topic général pour les événements de scraping
|
||||
'manga/' . $event->getMangaId() . '/chapters',
|
||||
'scraping/status'
|
||||
];
|
||||
|
||||
$this->logger->info('Topics Mercure: ' . implode(', ', $topics));
|
||||
|
||||
$update = new Update($topics, json_encode($data));
|
||||
$this->hub->publish($update);
|
||||
|
||||
$this->logger->info('Mise à jour publiée sur Mercure');
|
||||
$this->notification->sendError(
|
||||
sprintf('Échec du scraping du chapitre %s : %s', $event->getChapterNumber(), $event->getReason())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,13 @@ namespace App\Domain\Shared\Domain\Contract;
|
||||
|
||||
interface NotificationInterface
|
||||
{
|
||||
/**
|
||||
* Envoie une notification de succès
|
||||
*/
|
||||
public function sendSuccess(string $message): void;
|
||||
|
||||
/**
|
||||
* Envoie une notification d'erreur
|
||||
*/
|
||||
public function sendError(string $message): void;
|
||||
|
||||
/**
|
||||
* Envoie une notification avec un statut personnalisé
|
||||
*/
|
||||
public function sendInfo(string $message): void;
|
||||
|
||||
public function sendWarning(string $message): void;
|
||||
|
||||
public function sendUpdate(array $data): void;
|
||||
}
|
||||
|
||||
@@ -17,18 +17,22 @@ readonly class SymfonyNotification implements NotificationInterface
|
||||
|
||||
public function sendSuccess(string $message): void
|
||||
{
|
||||
$this->sendUpdate([
|
||||
'status' => 'success',
|
||||
'message' => $message
|
||||
]);
|
||||
$this->sendUpdate(['status' => 'success', 'message' => $message]);
|
||||
}
|
||||
|
||||
public function sendError(string $message): void
|
||||
{
|
||||
$this->sendUpdate([
|
||||
'status' => 'error',
|
||||
'message' => $message
|
||||
]);
|
||||
$this->sendUpdate(['status' => 'error', 'message' => $message]);
|
||||
}
|
||||
|
||||
public function sendInfo(string $message): void
|
||||
{
|
||||
$this->sendUpdate(['status' => 'info', 'message' => $message]);
|
||||
}
|
||||
|
||||
public function sendWarning(string $message): void
|
||||
{
|
||||
$this->sendUpdate(['status' => 'warning', 'message' => $message]);
|
||||
}
|
||||
|
||||
public function sendUpdate(array $data): void
|
||||
|
||||
@@ -267,10 +267,14 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_filter(
|
||||
$this->chapters[$mangaId],
|
||||
fn (Chapter $chapter) => in_array($chapter->getNumber(), $chapterNumbers)
|
||||
);
|
||||
$result = [];
|
||||
foreach ($this->chapters[$mangaId] as $chapter) {
|
||||
if (in_array($chapter->getNumber(), $chapterNumbers)) {
|
||||
$result[$chapter->getNumber()] = $chapter;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function findByMonitoringCriteria(MonitoringCriteria $criteria): array
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Domain\Manga\Infrastructure\Service;
|
||||
|
||||
use App\Domain\Manga\Domain\Model\Chapter;
|
||||
use App\Domain\Manga\Domain\Model\Manga;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||
use App\Domain\Manga\Infrastructure\Service\MangadxChapterSynchronizationService;
|
||||
use App\Tests\Domain\Manga\Adapter\InMemoryMangadexClient;
|
||||
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class MangadxChapterSynchronizationServiceTest extends TestCase
|
||||
{
|
||||
private InMemoryMangadexClient $client;
|
||||
private InMemoryMangaRepository $repository;
|
||||
private MangadxChapterSynchronizationService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = new InMemoryMangadexClient();
|
||||
$this->repository = new InMemoryMangaRepository();
|
||||
$this->service = new MangadxChapterSynchronizationService($this->client, $this->repository);
|
||||
}
|
||||
|
||||
private function makeManga(string $externalId = 'manga-123'): Manga
|
||||
{
|
||||
return new Manga(
|
||||
new MangaId('manga-id'),
|
||||
new MangaTitle('Test'),
|
||||
new MangaSlug('test'),
|
||||
'Desc',
|
||||
'Author',
|
||||
2024,
|
||||
[],
|
||||
'ongoing',
|
||||
new ExternalId($externalId)
|
||||
);
|
||||
}
|
||||
|
||||
private function chapterEntry(string $number, string $lang, ?string $volume): array
|
||||
{
|
||||
return [
|
||||
'attributes' => [
|
||||
'chapter' => $number,
|
||||
'translatedLanguage' => $lang,
|
||||
'title' => "Chapter $number",
|
||||
'volume' => $volume,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Chapitres sans volume entre deux volumes différents → assignés au volume précédent
|
||||
*
|
||||
* Ch1→Vol1, Ch2→null, Ch3→null, Ch4→Vol2
|
||||
* Après sync : Ch2 et Ch3 doivent avoir Vol1
|
||||
*/
|
||||
public function testVolumeTransitionIsAssignedToPreviousVolume(): void
|
||||
{
|
||||
$manga = $this->makeManga();
|
||||
$this->client->addFeed('manga-123', [
|
||||
$this->chapterEntry('1', 'en', '1'),
|
||||
$this->chapterEntry('2', 'en', null),
|
||||
$this->chapterEntry('3', 'en', null),
|
||||
$this->chapterEntry('4', 'en', '2'),
|
||||
]);
|
||||
|
||||
$this->service->synchronizeChapters($manga);
|
||||
|
||||
$chapters = $this->indexedByNumber($manga->pullNewChapters());
|
||||
|
||||
$this->assertSame(1, $chapters[1.0]->getVolume(), 'Ch1 doit rester Vol1');
|
||||
$this->assertSame(1, $chapters[2.0]->getVolume(), 'Ch2 (transition) doit être assigné à Vol1');
|
||||
$this->assertSame(1, $chapters[3.0]->getVolume(), 'Ch3 (transition) doit être assigné à Vol1');
|
||||
$this->assertSame(2, $chapters[4.0]->getVolume(), 'Ch4 doit rester Vol2');
|
||||
}
|
||||
|
||||
/**
|
||||
* Chapitres en début de série sans volume → assignés au premier volume trouvé
|
||||
*
|
||||
* Ch1→null, Ch2→null, Ch3→Vol1
|
||||
* Après sync : Ch1 et Ch2 doivent avoir Vol1
|
||||
*/
|
||||
public function testChaptersWithoutVolumeAtStartGetNextVolume(): void
|
||||
{
|
||||
$manga = $this->makeManga();
|
||||
$this->client->addFeed('manga-123', [
|
||||
$this->chapterEntry('1', 'en', null),
|
||||
$this->chapterEntry('2', 'en', null),
|
||||
$this->chapterEntry('3', 'en', '1'),
|
||||
]);
|
||||
|
||||
$this->service->synchronizeChapters($manga);
|
||||
|
||||
$chapters = $this->indexedByNumber($manga->pullNewChapters());
|
||||
|
||||
$this->assertSame(1, $chapters[1.0]->getVolume(), 'Ch1 (début de série) doit prendre Vol1');
|
||||
$this->assertSame(1, $chapters[2.0]->getVolume(), 'Ch2 (début de série) doit prendre Vol1');
|
||||
$this->assertSame(1, $chapters[3.0]->getVolume(), 'Ch3 doit rester Vol1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Chapitres avec volumes explicites ne sont pas modifiés
|
||||
*
|
||||
* Ch1→Vol1, Ch2→Vol1, Ch3→Vol2 → inchangé
|
||||
*/
|
||||
public function testChaptersWithExplicitVolumesArePreserved(): void
|
||||
{
|
||||
$manga = $this->makeManga();
|
||||
$this->client->addFeed('manga-123', [
|
||||
$this->chapterEntry('1', 'en', '1'),
|
||||
$this->chapterEntry('2', 'en', '1'),
|
||||
$this->chapterEntry('3', 'en', '2'),
|
||||
]);
|
||||
|
||||
$this->service->synchronizeChapters($manga);
|
||||
|
||||
$chapters = $this->indexedByNumber($manga->pullNewChapters());
|
||||
|
||||
$this->assertSame(1, $chapters[1.0]->getVolume());
|
||||
$this->assertSame(1, $chapters[2.0]->getVolume());
|
||||
$this->assertSame(2, $chapters[3.0]->getVolume());
|
||||
}
|
||||
|
||||
/**
|
||||
* La version française est prioritaire sur l'anglaise
|
||||
*
|
||||
* Même chapitre disponible EN (volume 1) et FR (volume 2) → FR gagne
|
||||
*/
|
||||
public function testFrenchChaptersTakePriorityOverEnglish(): void
|
||||
{
|
||||
$manga = $this->makeManga();
|
||||
$this->client->addFeed('manga-123', [
|
||||
$this->chapterEntry('1', 'en', '1'),
|
||||
$this->chapterEntry('1', 'fr', '2'),
|
||||
]);
|
||||
|
||||
$this->service->synchronizeChapters($manga);
|
||||
|
||||
$chapters = $this->indexedByNumber($manga->pullNewChapters());
|
||||
|
||||
$this->assertCount(1, $chapters, 'Un seul chapitre 1 doit exister');
|
||||
$this->assertSame(2, $chapters[1.0]->getVolume(), 'La version FR (Vol2) doit prendre la priorité');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seuls les nouveaux chapitres sont sauvegardés (pas les doublons)
|
||||
*
|
||||
* Ch1 déjà en DB + Ch2 nouveau → seul Ch2 est retourné
|
||||
*/
|
||||
public function testOnlyNewChaptersAreSaved(): void
|
||||
{
|
||||
$manga = $this->makeManga();
|
||||
|
||||
// Pré-peupler la DB avec Ch1
|
||||
$existingChapter = new Chapter(
|
||||
new ChapterId('existing-uuid'),
|
||||
new MangaId('manga-id'),
|
||||
1.0,
|
||||
'Chapter 1',
|
||||
1,
|
||||
true
|
||||
);
|
||||
$manga->addChapter($existingChapter);
|
||||
$this->repository->save($manga);
|
||||
|
||||
// Feed contient Ch1 (déjà en DB) et Ch2 (nouveau)
|
||||
$this->client->addFeed('manga-123', [
|
||||
$this->chapterEntry('1', 'en', '1'),
|
||||
$this->chapterEntry('2', 'en', '1'),
|
||||
]);
|
||||
|
||||
$newChapterIds = $this->service->synchronizeChapters($manga);
|
||||
|
||||
$this->assertCount(1, $newChapterIds, 'Seul Ch2 doit être retourné comme nouveau');
|
||||
|
||||
$newChapters = $manga->pullNewChapters();
|
||||
$this->assertCount(1, $newChapters);
|
||||
$this->assertSame(2.0, $newChapters[0]->getNumber(), 'Le nouveau chapitre doit être Ch2');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Chapter[] $chapters
|
||||
* @return array<float, Chapter>
|
||||
*/
|
||||
private function indexedByNumber(array $chapters): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($chapters as $chapter) {
|
||||
$result[$chapter->getNumber()] = $chapter;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Tests\Domain\Scraping\Application\CommandHandler;
|
||||
use App\Domain\Scraping\Application\Command\ScrapeChapter;
|
||||
use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler;
|
||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
|
||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted;
|
||||
use App\Domain\Scraping\Domain\Model\Chapter;
|
||||
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
||||
use App\Tests\Domain\Scraping\Adapter\InMemoryChapterRepository;
|
||||
@@ -81,11 +82,15 @@ class ScrapeChapterHandlerTest extends TestCase
|
||||
$job = array_values($job)[0];
|
||||
|
||||
$dispatchedMessages = $this->eventBus->getDispatchedMessages();
|
||||
$this->assertCount(1, $dispatchedMessages);
|
||||
$this->assertInstanceOf(ChapterScraped::class, $dispatchedMessages[0]);
|
||||
$this->assertEquals($job->id, $dispatchedMessages[0]->getJobId());
|
||||
$this->assertEquals('1', $dispatchedMessages[0]->chapterId);
|
||||
$this->assertEquals('/fake/pages/1', $dispatchedMessages[0]->pagesDirectory);
|
||||
$this->assertCount(2, $dispatchedMessages);
|
||||
|
||||
$this->assertInstanceOf(ChapterScrapingStarted::class, $dispatchedMessages[0]);
|
||||
$this->assertSame(2.0, $dispatchedMessages[0]->getChapterNumber());
|
||||
|
||||
$this->assertInstanceOf(ChapterScraped::class, $dispatchedMessages[1]);
|
||||
$this->assertEquals($job->id, $dispatchedMessages[1]->getJobId());
|
||||
$this->assertEquals('1', $dispatchedMessages[1]->chapterId);
|
||||
$this->assertEquals('/fake/pages/1', $dispatchedMessages[1]->pagesDirectory);
|
||||
$this->assertNotNull($this->imageStorage->stored['1'] ?? null);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user