Merge branch 'main' into fix/import-cbz-image-storage

This commit is contained in:
2026-03-15 18:27:07 +01:00
4 changed files with 102 additions and 25 deletions

41
TASK.md
View File

@@ -75,6 +75,47 @@
--- ---
## [Perf] Reader — Lazy-loading des pages (InfiniteReader)
**Problème :** `readerStore.js` charge toutes les pages avec `itemsPerPage=9999`. `InfiniteReader.vue` monte tous les composants `ReaderPage` simultanément dans le DOM. Sur un chapitre de 200 pages, cela représente 200 composants actifs et autant d'images pré-chargées.
- [ ] Implémenter un `IntersectionObserver` sur les wrappers de page pour ne charger les images qu'au moment où elles entrent dans le viewport (`loading="lazy"` ou src conditionnel)
- [ ] Limiter le nombre de composants montés simultanément (virtualisation ou windowing) : ne rendre que les pages proches de la page courante (ex. fenêtre de ±3 pages)
- [ ] Adapter `readerStore.js` : remplacer `itemsPerPage=9999` par la vraie pagination côté API si la virtualisation le justifie, sinon conserver le fetch unique mais différer le rendu
- [ ] Vérifier que le mode `single` n'est pas impacté (il affiche déjà une seule page)
---
## [Bug] Reader — N+1 requêtes SQL dans `getChapterContext()`
**Problème :** `LegacyChapterRepository::getChapterContext()` émet 5 requêtes SQL pour un seul chargement : la requête principale + 2 doublons dans `getPreviousChapterId()` / `getNextChapterId()` (chacune re-fetche le chapitre courant) + les 2 requêtes de navigation.
- [ ] Refactorer `getPreviousChapterId()` et `getNextChapterId()` pour accepter l'entité `ChapterEntity` déjà chargée en paramètre (au lieu de re-fetcher par ID)
- [ ] Appeler ces méthodes depuis `getChapterContext()` en passant l'entité déjà disponible
- [ ] Résultat attendu : 3 requêtes maximum (1 pour le chapitre courant + 1 prev + 1 next), idéalement 1 seule avec une requête SQL combinée
---
## [Bug] Reader — Division par zéro dans `ChapterPagesResponse::getTotalPages()`
**Problème :** `ceil($totalItems / $itemsPerPage)` crashe si `itemsPerPage = 0`. Le test existant documente le bug avec un TODO et assert un HTTP 500 au lieu de corriger.
- [ ] Ajouter une validation dans `ChapterPagesProvider` : rejeter la requête avec HTTP 400 si `itemsPerPage <= 0`
- [ ] Corriger le test `GetChapterPagesTest` pour vérifier HTTP 400 (et non 500)
- [ ] Supprimer le commentaire TODO du test une fois corrigé
---
## [Bug] Reader — `totalPages` toujours égal à 0 dans `ChapterContext`
**Problème :** `LegacyChapterRepository::getChapterContext()` hardcode `totalPages: 0`. La méthode `getTotalPagesForChapter()` existe mais n'est jamais appelée depuis `GetChapterContextHandler`.
- [ ] Appeler `getTotalPagesForChapter()` dans `getChapterContext()` (ou dans le handler) pour calculer le vrai nombre de pages
- [ ] Vérifier que la valeur est correctement sérialisée dans la réponse API Platform (`ChapterContextResponse`)
- [ ] Adapter les tests existants qui pourraient asserter `totalPages: 0`
---
## [Style] Page conversion CBR → CBZ — Simplification UI + notifications toast ## [Style] Page conversion CBR → CBZ — Simplification UI + notifications toast
**Objectif :** Revoir le style de la page de conversion CBR → CBZ pour le simplifier, et remplacer le message statique "Conversion réussie" par les notifications toast de l'application. **Objectif :** Revoir le style de la page de conversion CBR → CBZ pour le simplifier, et remplacer le message statique "Conversion réussie" par les notifications toast de l'application.

View File

@@ -22,6 +22,7 @@
:pages="store.pages" :pages="store.pages"
:zoom="store.zoom" :zoom="store.zoom"
:double-page-mode="store.effectiveDoublePageMode" :double-page-mode="store.effectiveDoublePageMode"
:initial-page="store.currentPage"
@page-visible="store.handlePageVisible" @page-visible="store.handlePageVisible"
ref="infiniteReaderRef" /> ref="infiniteReaderRef" />
</template> </template>

View File

@@ -1,10 +1,15 @@
<template> <template>
<div class="infinite-reader" ref="containerRef"> <div class="infinite-reader" ref="containerRef">
<div v-for="(page, index) in pages" :key="index" class="page-wrapper"> <div v-for="(page, index) in pages" :key="index" class="page-wrapper" :data-page-index="index">
<div v-if="!page?.url" class="loading"> <ReaderPage
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div> v-if="isPageInWindow(index) && page?.url"
</div> :page-data="page"
<ReaderPage v-else :page-data="page" :page-number="index + 1" :zoom="zoom" :double-page-mode="doublePageMode" loading="lazy" /> :page-number="index + 1"
:zoom="zoom"
:double-page-mode="doublePageMode"
loading="eager"
/>
<div v-else class="page-placeholder" :style="getPlaceholderStyle(page)" />
</div> </div>
<!-- Bouton flottant pour revenir en haut --> <!-- Bouton flottant pour revenir en haut -->
@@ -37,6 +42,25 @@
import { useHeaderStore } from '../../../../shared/stores/headerStore'; import { useHeaderStore } from '../../../../shared/stores/headerStore';
import ReaderPage from './ReaderPage.vue'; import ReaderPage from './ReaderPage.vue';
const WINDOW_SIZE = 3;
const currentVisibleIndex = ref(0); // initialisé via prop initialPage dans onMounted
const isPageInWindow = (index) => Math.abs(index - currentVisibleIndex.value) <= WINDOW_SIZE;
const getPlaceholderStyle = (page) => {
if (page?.dimensions?.width && page?.dimensions?.height) {
const maxW = windowWidth.value < 1200
? windowWidth.value * 0.95
: 1200;
return {
aspectRatio: `${page.dimensions.width} / ${page.dimensions.height}`,
width: '100%',
maxWidth: `${Math.min(page.dimensions.width, maxW)}px`,
};
}
return { height: '800px', width: '100%' };
};
const props = defineProps({ const props = defineProps({
pages: { pages: {
type: Array, type: Array,
@@ -49,6 +73,10 @@ import ReaderPage from './ReaderPage.vue';
doublePageMode: { doublePageMode: {
type: String, type: String,
required: true required: true
},
initialPage: {
type: Number,
default: 0
} }
}); });
@@ -68,12 +96,15 @@ import ReaderPage from './ReaderPage.vue';
let scrollDirection = 'down'; let scrollDirection = 'down';
const observeIntersection = entries => { const observeIntersection = entries => {
entries.forEach(entry => { const intersectingIndices = entries
if (entry.isIntersecting) { .filter(e => e.isIntersecting)
const pageIndex = parseInt(entry.target.getAttribute('data-page-index')); .map(e => parseInt(e.target.getAttribute('data-page-index')));
emit('pageVisible', pageIndex);
} if (intersectingIndices.length > 0) {
}); const minIdx = Math.min(...intersectingIndices);
currentVisibleIndex.value = minIdx;
emit('pageVisible', minIdx);
}
}; };
const setupIntersectionObserver = () => { const setupIntersectionObserver = () => {
@@ -89,8 +120,7 @@ import ReaderPage from './ReaderPage.vue';
nextTick(() => { nextTick(() => {
const pageElements = containerRef.value?.querySelectorAll('.page-wrapper'); const pageElements = containerRef.value?.querySelectorAll('.page-wrapper');
if (pageElements) { if (pageElements) {
pageElements.forEach((element, index) => { pageElements.forEach(element => {
element.setAttribute('data-page-index', index);
observer.value.observe(element); observer.value.observe(element);
}); });
} }
@@ -247,6 +277,7 @@ import ReaderPage from './ReaderPage.vue';
}; };
onMounted(() => { onMounted(() => {
currentVisibleIndex.value = props.initialPage;
setupIntersectionObserver(); setupIntersectionObserver();
// Activer l'auto-hide du header si la largeur < 1200px // Activer l'auto-hide du header si la largeur < 1200px
@@ -303,29 +334,26 @@ import ReaderPage from './ReaderPage.vue';
@apply mb-2 sm:mb-4 px-1 sm:px-4; @apply mb-2 sm:mb-4 px-1 sm:px-4;
} }
.loading, .page-placeholder {
@apply flex justify-center;
background: transparent;
}
.error { .error {
@apply flex items-center justify-center min-h-[400px]; @apply text-red-500 text-xl bg-red-500/10 rounded-lg flex items-center justify-center min-h-[400px];
/* Largeur adaptative selon la taille d'écran */ width: 95vw;
width: 95vw; /* Mobile : 95% de la largeur */
} }
@screen sm { @screen sm {
.loading,
.error { .error {
width: 80vw; /* Tablette : 80% de la largeur */ width: 80vw;
} }
} }
@screen lg { @screen lg {
.loading,
.error { .error {
width: 70vw; /* Desktop : 70% de la largeur */ width: 70vw;
} }
} }
.error {
@apply text-red-500 text-xl bg-red-500/10 rounded-lg;
}
</style> </style>

View File

@@ -15,6 +15,7 @@
:alt="`Page ${pageNumber} (Double page)`" :alt="`Page ${pageNumber} (Double page)`"
class="page-image rotated" class="page-image rotated"
:style="doublePageRotatedStyle" :style="doublePageRotatedStyle"
:loading="loading"
@load="handleImageLoad" @load="handleImageLoad"
ref="imageRef" /> ref="imageRef" />
<div class="rotation-hint"> <div class="rotation-hint">
@@ -33,6 +34,7 @@
:alt="`Page ${pageNumber} (Double page)`" :alt="`Page ${pageNumber} (Double page)`"
class="page-image scrollable" class="page-image scrollable"
:style="doublePageScrollStyle" :style="doublePageScrollStyle"
:loading="loading"
@load="handleImageLoad" @load="handleImageLoad"
ref="imageRef" /> ref="imageRef" />
</div> </div>
@@ -52,6 +54,7 @@
:alt="`Page ${pageNumber}`" :alt="`Page ${pageNumber}`"
class="page-image" class="page-image"
:style="imageStyle" :style="imageStyle"
:loading="loading"
@load="handleImageLoad" @load="handleImageLoad"
ref="imageRef" /> ref="imageRef" />
</div> </div>
@@ -78,6 +81,10 @@ import { useReaderStore } from '../../application/store/readerStore';
type: String, type: String,
default: 'rotate', // 'rotate', 'scroll', 'normal' default: 'rotate', // 'rotate', 'scroll', 'normal'
validator: (value) => ['rotate', 'scroll', 'normal'].includes(value) validator: (value) => ['rotate', 'scroll', 'normal'].includes(value)
},
loading: {
type: String,
default: 'lazy',
} }
}); });