Compare commits
9 Commits
b60a68cbd7
...
style/simp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc27fc4564 | ||
|
|
e1909b9804 | ||
|
|
07d3b56d1b | ||
|
|
ac19cc53ca | ||
|
|
15cb59e420 | ||
|
|
d4e456961a | ||
|
|
465a05c13b | ||
|
|
2ffe559832 | ||
|
|
5eb650df6f |
@@ -3,6 +3,11 @@
|
|||||||
@import "tailwindcss/components";
|
@import "tailwindcss/components";
|
||||||
@import "tailwindcss/utilities";
|
@import "tailwindcss/utilities";
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
}
|
}
|
||||||
@@ -82,6 +87,33 @@ body {
|
|||||||
@apply bg-gray-700;
|
@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 */
|
///* Custom styles for the scrollbar buttons */
|
||||||
//::-webkit-scrollbar-button {
|
//::-webkit-scrollbar-button {
|
||||||
// @apply bg-gray-700;
|
// @apply bg-gray-700;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="overflow-y-auto h-full">
|
||||||
<Toolbar :config="toolbarConfig" class="mb-6" />
|
<Toolbar :config="toolbarConfig" class="mb-6" />
|
||||||
|
|
||||||
<div v-if="activityStore.loading" class="flex justify-center py-8">
|
<div v-if="activityStore.loading" class="flex justify-center py-8">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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 -->
|
<!-- En-tête -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="flex items-center space-x-3 mb-4">
|
<div class="flex items-center space-x-3 mb-4">
|
||||||
@@ -150,7 +150,7 @@
|
|||||||
<XMarkIcon class="w-4 h-4" />
|
<XMarkIcon class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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 -->
|
<!-- Header -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">Import de Bibliothèque</h1>
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">Import de Bibliothèque</h1>
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
<div v-if="store.allFilesProcessed" class="mt-8">
|
<div v-if="store.allFilesProcessed" class="mt-8">
|
||||||
<ImportResults />
|
<ImportResults />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
@click="$emit('manga-click', manga)">
|
@click="$emit('manga-click', manga)">
|
||||||
<!-- Cover Image -->
|
<!-- Cover Image -->
|
||||||
<div class="flex-shrink-0">
|
<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 -->
|
<!-- TODO: Add placeholder image -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="flex flex-col h-full">
|
||||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
<Toolbar :config="toolbarConfig" />
|
||||||
<div class="container mx-auto px-4">
|
<div class="overflow-y-auto flex-1">
|
||||||
|
<div class="w-full">
|
||||||
<MangaGrid v-if="viewMode === 'grid'" :mangas="pagedItems" />
|
<MangaGrid v-if="viewMode === 'grid'" :mangas="pagedItems" />
|
||||||
<MangaList
|
<MangaList
|
||||||
v-else-if="viewMode === 'list'"
|
v-else-if="viewMode === 'list'"
|
||||||
:mangas="pagedItems"
|
:mangas="pagedItems"
|
||||||
@manga-click="handleMangaClick" />
|
@manga-click="handleMangaClick" />
|
||||||
|
<MangaTable v-else-if="viewMode === 'table'" :mangas="pagedItems" />
|
||||||
<Pagination
|
<Pagination
|
||||||
v-if="totalPages > 1"
|
v-if="totalPages > 1"
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -43,6 +46,7 @@ import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
|||||||
import { useMangaStore } from '../../application/store/mangaStore';
|
import { useMangaStore } from '../../application/store/mangaStore';
|
||||||
import MangaGrid from '../components/MangaGrid.vue';
|
import MangaGrid from '../components/MangaGrid.vue';
|
||||||
import MangaList from '../components/MangaList.vue';
|
import MangaList from '../components/MangaList.vue';
|
||||||
|
import MangaTable from '../components/MangaTable.vue';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const mangaStore = useMangaStore();
|
const mangaStore = useMangaStore();
|
||||||
@@ -105,8 +109,9 @@ import MangaList from '../components/MangaList.vue';
|
|||||||
type: 'dropdown',
|
type: 'dropdown',
|
||||||
label: 'View',
|
label: 'View',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'List', onClick: () => { viewMode.value = 'list'; prefs.setDefaultView('list'); } },
|
{ label: 'Overview', onClick: () => { viewMode.value = 'list'; prefs.setDefaultView('list'); } },
|
||||||
{ label: 'Grid', onClick: () => { viewMode.value = 'grid'; prefs.setDefaultView('grid'); } }
|
{ label: 'Grid', onClick: () => { viewMode.value = 'grid'; prefs.setDefaultView('grid'); } },
|
||||||
|
{ label: 'Table', onClick: () => { viewMode.value = 'table'; prefs.setDefaultView('table'); } }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
<div class="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
|
||||||
<!-- Notifications Toast -->
|
<!-- Notifications Toast -->
|
||||||
<NotificationToast />
|
<NotificationToast />
|
||||||
|
|
||||||
|
<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">
|
<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.' }}
|
{{ errorDetails.message || 'Une erreur est survenue lors du chargement des détails.' }}
|
||||||
</div>
|
</div>
|
||||||
@@ -11,8 +15,6 @@
|
|||||||
<!-- Composant invisible qui écoute les mises à jour Mercure -->
|
<!-- Composant invisible qui écoute les mises à jour Mercure -->
|
||||||
<MercureListener :manga-id="String(mangaId)" />
|
<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 dark:text-gray-400 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" />
|
<ArrowPathIcon class="h-5 w-5 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
@@ -87,6 +89,8 @@
|
|||||||
<div v-else class="text-center text-gray-500 dark:text-gray-400 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é.
|
Aucun manga sélectionné ou trouvé.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="flex flex-col h-full">
|
||||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
<Toolbar :config="toolbarConfig" />
|
||||||
|
|
||||||
|
<div class="overflow-y-auto flex-1">
|
||||||
<div class="container mx-auto px-4 py-6">
|
<div class="container mx-auto px-4 py-6">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
@@ -71,6 +72,7 @@
|
|||||||
Configuration exportée !
|
Configuration exportée !
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Import Modal -->
|
<!-- Import Modal -->
|
||||||
<div v-if="showImportModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<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>
|
<template>
|
||||||
<div>
|
<div class="flex flex-col h-full">
|
||||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
<Toolbar :config="toolbarConfig" />
|
||||||
|
|
||||||
|
<div class="overflow-y-auto flex-1">
|
||||||
<div class="container mx-auto px-4 py-6">
|
<div class="container mx-auto px-4 py-6">
|
||||||
<!-- Back Navigation -->
|
<!-- Back Navigation -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
@@ -181,6 +182,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container mx-auto px-4 py-8 max-w-3xl">
|
<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 class="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('preferences.title') }}</h1>
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('preferences.title') }}</h1>
|
||||||
@@ -13,13 +13,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Apparence -->
|
<!-- Apparence -->
|
||||||
<section class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
|
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mb-6">
|
||||||
<h2 class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider px-6 pt-5 pb-3">
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||||
{{ t('preferences.sections.appearance') }}
|
{{ t('preferences.sections.appearance') }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
<div class="space-y-1">
|
||||||
<!-- Thème -->
|
<!-- Thème -->
|
||||||
<div class="flex items-center justify-between px-6 py-4">
|
<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>
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.theme.label') }}</label>
|
||||||
<select
|
<select
|
||||||
:value="store.theme"
|
:value="store.theme"
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<!-- Langue -->
|
<!-- Langue -->
|
||||||
<div class="flex items-center justify-between px-6 py-4">
|
<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>
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.language.label') }}</label>
|
||||||
<select
|
<select
|
||||||
:value="store.language"
|
:value="store.language"
|
||||||
@@ -45,13 +45,13 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Affichage collection -->
|
<!-- Affichage collection -->
|
||||||
<section class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
|
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mb-6">
|
||||||
<h2 class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider px-6 pt-5 pb-3">
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||||
{{ t('preferences.sections.collection') }}
|
{{ t('preferences.sections.collection') }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
<div class="space-y-1">
|
||||||
<!-- Vue par défaut -->
|
<!-- Vue par défaut -->
|
||||||
<div class="flex items-center justify-between px-6 py-4">
|
<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>
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.defaultView.label') }}</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Mangas par page -->
|
<!-- Mangas par page -->
|
||||||
<div class="flex items-center justify-between px-6 py-4">
|
<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>
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.itemsPerPage.label') }}</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Tri par défaut -->
|
<!-- Tri par défaut -->
|
||||||
<div class="flex items-center justify-between px-6 py-4">
|
<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>
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.sortBy.label') }}</label>
|
||||||
<select
|
<select
|
||||||
:value="store.sortBy"
|
:value="store.sortBy"
|
||||||
@@ -95,13 +95,13 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Lecture -->
|
<!-- Lecture -->
|
||||||
<section class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
|
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mb-6">
|
||||||
<h2 class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider px-6 pt-5 pb-3">
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||||
{{ t('preferences.sections.reading') }}
|
{{ t('preferences.sections.reading') }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
<div class="space-y-1">
|
||||||
<!-- Direction de lecture -->
|
<!-- Direction de lecture -->
|
||||||
<div class="flex items-center justify-between px-6 py-4">
|
<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>
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.readingDirection.label') }}</label>
|
||||||
<select
|
<select
|
||||||
:value="store.readingDirection"
|
:value="store.readingDirection"
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<!-- Mode d'affichage -->
|
<!-- Mode d'affichage -->
|
||||||
<div class="flex items-center justify-between px-6 py-4">
|
<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>
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.readingMode.label') }}</label>
|
||||||
<select
|
<select
|
||||||
:value="store.readingMode"
|
:value="store.readingMode"
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<!-- Auto plein écran -->
|
<!-- Auto plein écran -->
|
||||||
<div class="flex items-center justify-between px-6 py-4">
|
<div class="flex items-center justify-between py-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.autoFullscreen.label') }}</p>
|
<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>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ t('preferences.autoFullscreen.description') }}</p>
|
||||||
@@ -138,7 +138,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Auto-hide header -->
|
<!-- Auto-hide header -->
|
||||||
<div class="flex items-center justify-between px-6 py-4">
|
<div class="flex items-center justify-between py-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.autoHideHeaderReader.label') }}</p>
|
<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>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ t('preferences.autoHideHeaderReader.description') }}</p>
|
||||||
@@ -155,13 +155,13 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Notifications -->
|
<!-- Notifications -->
|
||||||
<section class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
|
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mb-6">
|
||||||
<h2 class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider px-6 pt-5 pb-3">
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||||
{{ t('preferences.sections.notifications') }}
|
{{ t('preferences.sections.notifications') }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
<div class="space-y-1">
|
||||||
<!-- Durée des toasts -->
|
<!-- Durée des toasts -->
|
||||||
<div class="flex items-center justify-between px-6 py-4">
|
<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>
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.toastDuration.label') }}</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -175,7 +175,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 flex">
|
<div class="h-screen overflow-hidden bg-gray-50 dark:bg-gray-900 flex">
|
||||||
<Header
|
<Header
|
||||||
:show-menu-button="isReaderMode"
|
:show-menu-button="isReaderMode"
|
||||||
@menu-click="toggleSidebar"
|
@menu-click="toggleSidebar"
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
@add-manga-click="$emit('add-manga-click', $event)" />
|
@add-manga-click="$emit('add-manga-click', $event)" />
|
||||||
|
|
||||||
<main :class="[
|
<main :class="[
|
||||||
'flex-1 pt-16',
|
'flex-1 mt-16 flex flex-col overflow-hidden',
|
||||||
isReaderMode ? '' : 'md:ml-60'
|
isReaderMode ? '' : 'md:ml-60'
|
||||||
]">
|
]">
|
||||||
<RouterView></RouterView>
|
<RouterView></RouterView>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
validator: value => {
|
validator: value => {
|
||||||
// Vérifie que leftSection et rightSection sont des tableaux
|
|
||||||
return Array.isArray(value.leftSection) && Array.isArray(value.rightSection);
|
return Array.isArray(value.leftSection) && Array.isArray(value.rightSection);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,12 @@ readonly class MangadexProvider implements MangaProviderInterface
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$attributes = $result['attributes'];
|
$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) {
|
if (!$title) {
|
||||||
return null;
|
return null;
|
||||||
@@ -77,7 +82,7 @@ readonly class MangadexProvider implements MangaProviderInterface
|
|||||||
}
|
}
|
||||||
if ($relationship['type'] === 'cover_art') {
|
if ($relationship['type'] === 'cover_art') {
|
||||||
$imageUrl = sprintf(
|
$imageUrl = sprintf(
|
||||||
'https://mangadex.org/covers/%s/%s',
|
'https://uploads.mangadex.org/covers/%s/%s.512.jpg',
|
||||||
$result['id'],
|
$result['id'],
|
||||||
$relationship['attributes']['fileName']
|
$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
|
// Priorité au volume précédent : le chapitre appartient à la fin du volume en cours
|
||||||
if ($prevVolume !== null && ($nextVolume === null || $nextVolume === $prevVolume)) {
|
// Couvre les cas : milieu de volume (prev=next), transition entre deux volumes (prev≠next)
|
||||||
|
if ($prevVolume !== null) {
|
||||||
$chaptersByNumber[$currentChapterNum] = new Chapter(
|
$chaptersByNumber[$currentChapterNum] = new Chapter(
|
||||||
new ChapterId($currentChapter->getId()),
|
new ChapterId($currentChapter->getId()),
|
||||||
$currentChapter->getMangaId(),
|
$currentChapter->getMangaId(),
|
||||||
@@ -218,8 +219,8 @@ readonly class MangadxChapterSynchronizationService implements ChapterSynchroniz
|
|||||||
$currentChapter->getCreatedAt()
|
$currentChapter->getCreatedAt()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Si on a trouvé un volume suivant mais pas de précédent, utilise le suivant
|
// Sinon utilise le volume suivant (chapitres en début de série)
|
||||||
elseif ($nextVolume !== null && $prevVolume === null) {
|
elseif ($nextVolume !== null) {
|
||||||
$chaptersByNumber[$currentChapterNum] = new Chapter(
|
$chaptersByNumber[$currentChapterNum] = new Chapter(
|
||||||
new ChapterId($currentChapter->getId()),
|
new ChapterId($currentChapter->getId()),
|
||||||
$currentChapter->getMangaId(),
|
$currentChapter->getMangaId(),
|
||||||
|
|||||||
@@ -267,10 +267,14 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_filter(
|
$result = [];
|
||||||
$this->chapters[$mangaId],
|
foreach ($this->chapters[$mangaId] as $chapter) {
|
||||||
fn (Chapter $chapter) => in_array($chapter->getNumber(), $chapterNumbers)
|
if (in_array($chapter->getNumber(), $chapterNumbers)) {
|
||||||
);
|
$result[$chapter->getNumber()] = $chapter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function findByMonitoringCriteria(MonitoringCriteria $criteria): array
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user