9 Commits

Author SHA1 Message Date
ext.jeremy.guillot@maxicoffee.domains
cc27fc4564 style(homepage): supprimer px-4 pour tableau pleine largeur sans marges 2026-03-14 00:22:35 +01:00
ext.jeremy.guillot@maxicoffee.domains
e1909b9804 style(homepage): remplacer container par w-full pour pleine largeur en vue table 2026-03-14 00:21:20 +01:00
ext.jeremy.guillot@maxicoffee.domains
07d3b56d1b style(manga-table): supprimer le padding du wrapper pour pleine largeur 2026-03-14 00:19:40 +01:00
ext.jeremy.guillot@maxicoffee.domains
ac19cc53ca style(manga-table): supprimer wrapper card + hover vert + icônes Bookmark 2026-03-14 00:18:23 +01:00
ext.jeremy.guillot@maxicoffee.domains
15cb59e420 style: scrollbar isolée dans la zone de contenu + suppression des flèches
All checks were successful
Deploy / deploy (push) Successful in 2m38s
- Layout: h-screen overflow-hidden, <main> flex-col avec mt-16
- Pages avec toolbar: toolbar hors du conteneur scrollable (flex-col + overflow-y-auto flex-1)
- Pages sans toolbar: wrapper overflow-y-auto h-full
- app.scss: scrollbar-width/color limité à Firefox via @supports (-moz-appearance: none) pour éviter le conflit avec les pseudo-éléments webkit sur Chrome 121+
- Suppression des flèches de scrollbar via ::-webkit-scrollbar-button
- html/body overflow:hidden pour éviter la double scrollbar
2026-03-13 19:32:45 +01:00
ext.jeremy.guillot@maxicoffee.domains
d4e456961a fix: volume gap filling for chapter transitions between different volumes
All checks were successful
Deploy / deploy (push) Successful in 3m3s
`fillVolumeGaps` incorrectly left chapters null when surrounded by two
different non-null volumes (e.g. Vol10 → null → Vol11). Simplify the
condition to always prefer the previous volume, covering all cases.

Also fix `InMemoryMangaRepository::findExistingChaptersByNumbers` to
return an array keyed by chapter number, matching the Doctrine contract.

Add 5 unit tests for MangadxChapterSynchronizationService covering
volume transitions, start-of-series gaps, explicit volumes, FR/EN
priority, and deduplication of existing chapters.
2026-03-13 18:43:51 +01:00
ext.jeremy.guillot@maxicoffee.domains
465a05c13b fix: disable referrer on MangaDex cover images to prevent hotlink blocking
All checks were successful
Deploy / deploy (push) Successful in 2m59s
2026-03-13 18:15:16 +01:00
ext.jeremy.guillot@maxicoffee.domains
2ffe559832 fix: MangaDex title fallback + image CDN URL
All checks were successful
Deploy / deploy (push) Successful in 2m31s
- Title: cascade en → fr → ja-ro → ko-ro → zh-ro → first available to avoid silently dropping mangas without English title (e.g. One Piece stored as ja-ro)
- Image: use uploads.mangadex.org CDN with .512.jpg thumbnail suffix instead of mangadex.org/covers which fails in prod
2026-03-13 18:08:35 +01:00
ext.jeremy.guillot@maxicoffee.domains
5eb650df6f style: simplify settings page — replace cards with border-top sections
All checks were successful
Deploy / deploy (push) Successful in 2m46s
2026-03-13 17:47:47 +01:00
17 changed files with 518 additions and 55 deletions

View File

@@ -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;

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"
@@ -22,6 +24,7 @@
Mise à jour en cours... Mise à jour en cours...
</div> </div>
</div> </div>
</div>
</div> </div>
</template> </template>
@@ -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'); } }
] ]
}, },
{ {

View File

@@ -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>

View File

@@ -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">

View File

@@ -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">
@@ -180,6 +181,7 @@
Configuration {{ isEditing ? 'mise à jour' : 'créée' }} avec succès ! Configuration {{ isEditing ? 'mise à jour' : 'créée' }} avec succès !
</div> </div>
</div> </div>
</div>
</div> </div>
</template> </template>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
} }
} }

View File

@@ -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']
); );

View File

@@ -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(),

View File

@@ -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

View File

@@ -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;
}
}