feat: amélioration de la navigation du Reader + correction affichage des chapitres non visibles

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-06-06 15:46:44 +02:00
parent 72d7c233f7
commit 05dd7262eb
10 changed files with 627 additions and 22 deletions

View File

@@ -52,9 +52,12 @@ export class ApiMangaRepository {
page++;
}
// Filtrer pour ne garder que les chapitres visibles
const visibleChapters = allChapters.filter(chapter => chapter.isVisible === true);
return {
items: allChapters,
total: allChapters.length
items: visibleChapters,
total: visibleChapters.length
};
} catch (error) {
console.error('API Error:', error);

View File

@@ -19,7 +19,9 @@ export const useReaderStore = defineStore('reader', {
getters: {
isFirstPage: state => state.currentPage === 0,
isLastPage: state => state.currentPage === state.totalPages - 1,
currentPageData: state => state.pages[state.currentPage]
currentPageData: state => state.pages[state.currentPage],
hasPreviousChapter: state => Boolean(state.currentChapter?.navigation?.previousChapter),
hasNextChapter: state => Boolean(state.currentChapter?.navigation?.nextChapter)
},
actions: {
@@ -159,6 +161,24 @@ export const useReaderStore = defineStore('reader', {
setZoom(level) {
this.zoom = level;
},
async goToPreviousChapter() {
if (this.currentChapter?.navigation?.previousChapter) {
await this.loadChapter(this.currentChapter.navigation.previousChapter);
// Aller à la dernière page du chapitre précédent
this.currentPage = Math.max(0, this.totalPages - 1);
// S'assurer que la page est chargée
if (this.totalPages > 0) {
await this.loadPageData(this.currentPage);
}
}
},
async goToNextChapter() {
if (this.currentChapter?.navigation?.nextChapter) {
await this.loadChapter(this.currentChapter.navigation.nextChapter);
}
}
}
});

View File

@@ -1,5 +1,5 @@
export class Chapter {
constructor({ id, mangaId, number, title, pages = [], read = false, lastReadPage = 0 }) {
constructor({ id, mangaId, number, title, pages = [], read = false, lastReadPage = 0, navigation = {} }) {
this.id = id;
this.mangaId = mangaId;
this.number = number;
@@ -7,6 +7,10 @@ export class Chapter {
this.pages = pages;
this.read = read;
this.lastReadPage = lastReadPage;
this.navigation = {
previousChapter: navigation.previousChapter || null,
nextChapter: navigation.nextChapter || null
};
}
static create(data) {

View File

@@ -0,0 +1,89 @@
<template>
<div class="chapter-navigation">
<button
v-if="hasPreviousChapter"
@click="goToPreviousChapter"
class="nav-button nav-button-previous"
:disabled="isLoading"
title="Chapitre précédent"
>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span class="hidden sm:inline">Chapitre précédent</span>
<span class="sm:hidden">Précédent</span>
</button>
<div class="flex-1"></div>
<button
v-if="hasNextChapter"
@click="goToNextChapter"
class="nav-button nav-button-next"
:disabled="isLoading"
title="Chapitre suivant"
>
<span class="hidden sm:inline">Chapitre suivant</span>
<span class="sm:hidden">Suivant</span>
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useReaderStore } from '../../application/store/readerStore';
const store = useReaderStore();
const props = defineProps({
position: {
type: String,
default: 'top', // 'top' ou 'bottom'
validator: (value) => ['top', 'bottom'].includes(value)
}
});
const hasPreviousChapter = computed(() => store.hasPreviousChapter);
const hasNextChapter = computed(() => store.hasNextChapter);
const isLoading = computed(() => store.isLoading);
const goToPreviousChapter = async () => {
await store.goToPreviousChapter();
};
const goToNextChapter = async () => {
await store.goToNextChapter();
};
</script>
<style lang="postcss" scoped>
.chapter-navigation {
@apply flex items-center justify-between w-full px-4 py-3;
@apply bg-gray-800/80 backdrop-blur-sm border border-gray-700/50;
@apply rounded-lg shadow-lg;
}
.nav-button {
@apply flex items-center px-4 py-2;
@apply bg-blue-600 hover:bg-blue-700 text-white;
@apply rounded-md transition-all duration-200;
@apply disabled:opacity-50 disabled:cursor-not-allowed;
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2;
@apply font-medium text-sm;
}
.nav-button:disabled {
@apply hover:bg-blue-600;
}
.nav-button-previous {
@apply mr-auto;
}
.nav-button-next {
@apply ml-auto;
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div class="chapter-reader" :class="{ rtl: store.readingDirection === 'rtl' }">
<div v-if="store.isLoading" class="loading">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
<div v-else-if="store.error" class="error">
@@ -15,11 +15,13 @@
:total-pages="store.totalPages"
:is-first-page="store.isFirstPage"
:is-last-page="store.isLastPage"
:available-chapters="availableChapters"
@previous="store.previousPage"
@next="store.nextPage" />
@next="store.nextPage"
@chapter-selected="handleChapterSelected" />
<template v-if="store.readingMode === 'single'">
<ReaderPage
<SingleModeReader
:page-data="store.currentPageData"
:page-number="store.currentPage + 1"
:zoom="store.zoom" />
@@ -42,16 +44,20 @@
<script setup>
import { onMounted, onUnmounted, watch } from 'vue';
import { useReaderStore } from '../../application/store/readerStore';
import ReaderControls from './ReaderControls.vue';
import ReaderPage from './ReaderPage.vue';
import ReaderSettings from './ReaderSettings.vue';
import InfiniteReader from './InfiniteReader.vue';
import { useReaderStore } from '../../application/store/readerStore';
import InfiniteReader from './InfiniteReader.vue';
import ReaderControls from './ReaderControls.vue';
import ReaderSettings from './ReaderSettings.vue';
import SingleModeReader from './SingleModeReader.vue';
const props = defineProps({
chapterId: {
type: String,
required: true
},
availableChapters: {
type: Array,
default: () => []
}
});
@@ -83,6 +89,12 @@
}
};
const handleChapterSelected = (chapterId) => {
// La navigation est déjà gérée par le ChapterSelector via le store
// Cette fonction est là pour d'éventuelles actions supplémentaires
console.log('Chapitre sélectionné:', chapterId);
};
watch(
() => props.chapterId,
newId => {

View File

@@ -0,0 +1,83 @@
<template>
<div class="chapter-selector">
<label for="chapter-select" class="sr-only">Sélectionner un chapitre</label>
<select
id="chapter-select"
v-model="selectedChapterId"
@change="handleChapterChange"
class="chapter-select"
:disabled="isLoading"
>
<option
v-for="chapter in availableChapters"
:key="chapter.id"
:value="chapter.id"
:selected="chapter.id === currentChapterId"
>
Chapitre {{ chapter.number }} - {{ chapter.title }}
</option>
</select>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue';
import { useReaderStore } from '../../application/store/readerStore';
const props = defineProps({
availableChapters: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['chapter-selected']);
const store = useReaderStore();
const selectedChapterId = ref(null);
const currentChapterId = computed(() => store.currentChapter?.id);
const isLoading = computed(() => store.isLoading);
// Synchroniser la sélection avec le chapitre actuel
watch(currentChapterId, (newId) => {
selectedChapterId.value = newId;
}, { immediate: true });
// Gérer le changement de chapitre
const handleChapterChange = async () => {
if (selectedChapterId.value && selectedChapterId.value !== currentChapterId.value) {
await store.loadChapter(selectedChapterId.value);
emit('chapter-selected', selectedChapterId.value);
}
};
</script>
<style lang="postcss" scoped>
.chapter-selector {
@apply relative;
}
.chapter-select {
@apply w-full px-3 py-2 text-sm;
@apply bg-gray-800 text-white border border-gray-600;
@apply rounded-md shadow-sm;
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500;
@apply disabled:opacity-50 disabled:cursor-not-allowed;
@apply transition-all duration-200;
}
.chapter-select:hover:not(:disabled) {
@apply border-gray-500;
}
/* Style personnalisé pour la flèche du select */
.chapter-select {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
padding-right: 2.5rem;
}
</style>

View File

@@ -1,21 +1,53 @@
<template>
<div class="infinite-reader" ref="containerRef">
<!-- Navigation en haut -->
<div class="navigation-wrapper top">
<ChapterNavigation position="top" />
</div>
<div v-for="(page, index) in pages" :key="index" class="page-wrapper">
<div v-if="page?.loading" class="loading">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
<div v-else-if="page?.error" class="error">
{{ page.error }}
</div>
<ReaderPage v-else-if="page?.base64Content" :page-data="page" :page-number="index + 1" :zoom="zoom" />
</div>
<!-- Navigation en bas -->
<div class="navigation-wrapper bottom">
<ChapterNavigation position="bottom" />
</div>
<!-- Bouton flottant pour revenir en haut -->
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-in"
enter-from-class="opacity-0 translate-y-5 scale-75"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-5 scale-75"
>
<button
v-show="showScrollToTop"
@click="scrollToTop"
class="fixed bottom-6 right-6 z-[9999] bg-blue-600 hover:bg-blue-700 text-white w-12 h-12 rounded-full shadow-lg hover:shadow-xl flex items-center justify-center transition-all duration-200 hover:scale-110 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
title="Revenir en haut"
type="button"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
</button>
</Transition>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { nextTick } from 'vue';
import ReaderPage from './ReaderPage.vue';
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import ChapterNavigation from './ChapterNavigation.vue';
import ReaderPage from './ReaderPage.vue';
const props = defineProps({
pages: {
@@ -33,6 +65,13 @@
const containerRef = ref(null);
const observer = ref(null);
// État pour le bouton scroll to top
const showScrollToTop = ref(false);
// Variables pour détecter la direction du scroll
let lastScrollTop = 0;
let scrollDirection = 'down';
const observeIntersection = entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
@@ -63,6 +102,91 @@
});
};
// Gestion du scroll pour le bouton "revenir en haut"
const handleScroll = () => {
let scrollTop = 0;
// Vérifier le scroll sur le conteneur direct
if (containerRef.value && containerRef.value.scrollTop > 0) {
scrollTop = containerRef.value.scrollTop;
} else {
// Vérifier le scroll sur les conteneurs parents
let currentElement = containerRef.value?.parentElement;
while (currentElement && scrollTop === 0) {
if (currentElement.scrollTop > 0) {
scrollTop = currentElement.scrollTop;
break;
}
currentElement = currentElement.parentElement;
}
// Vérifier le scroll sur la fenêtre
if (scrollTop === 0) {
scrollTop = window.scrollY;
}
}
// Détecter la direction du scroll
if (scrollTop > lastScrollTop) {
scrollDirection = 'down';
} else if (scrollTop < lastScrollTop) {
scrollDirection = 'up';
}
// Mise à jour de la visibilité du bouton
// Afficher si on scroll vers le bas et qu'on est à plus de 300px
// Masquer si on scroll vers le haut ou qu'on est en haut de page
if (scrollDirection === 'down' && scrollTop > 300) {
showScrollToTop.value = true;
} else if (scrollDirection === 'up' || scrollTop <= 100) {
showScrollToTop.value = false;
}
// Sauvegarder la position actuelle pour la prochaine comparaison
lastScrollTop = scrollTop;
};
// Fonction pour revenir en haut de la page
const scrollToTop = () => {
console.log('scrollToTop appelée'); // Debug
// Stratégie 1: Scroll sur le conteneur direct
if (containerRef.value) {
console.log('containerRef trouvé, scrollTop actuel:', containerRef.value.scrollTop); // Debug
if (containerRef.value.scrollTop > 0) {
containerRef.value.scrollTo({
top: 0,
behavior: 'smooth'
});
console.log('Scroll sur containerRef effectué'); // Debug
return;
}
}
// Stratégie 2: Chercher le conteneur parent avec scroll
let currentElement = containerRef.value?.parentElement;
while (currentElement) {
const styles = window.getComputedStyle(currentElement);
if (styles.overflowY === 'auto' || styles.overflowY === 'scroll' || currentElement.scrollTop > 0) {
console.log('Conteneur avec scroll trouvé:', currentElement.className, 'scrollTop:', currentElement.scrollTop); // Debug
currentElement.scrollTo({
top: 0,
behavior: 'smooth'
});
return;
}
currentElement = currentElement.parentElement;
}
// Stratégie 3: Scroll sur la fenêtre entière
console.log('Scroll sur window, scrollY actuel:', window.scrollY); // Debug
window.scrollTo({
top: 0,
behavior: 'smooth'
});
};
watch(
() => props.pages,
() => {
@@ -73,18 +197,34 @@
onMounted(() => {
setupIntersectionObserver();
// Ajouter l'écouteur de scroll sur le conteneur
if (containerRef.value) {
containerRef.value.addEventListener('scroll', handleScroll, { passive: true });
}
// Ajouter l'écouteur de scroll sur la fenêtre
window.addEventListener('scroll', handleScroll, { passive: true });
});
onUnmounted(() => {
if (observer.value) {
observer.value.disconnect();
}
// Nettoyer l'écouteur de scroll du conteneur
if (containerRef.value) {
containerRef.value.removeEventListener('scroll', handleScroll);
}
// Nettoyer l'écouteur de scroll de la fenêtre
window.removeEventListener('scroll', handleScroll);
});
</script>
<style lang="postcss" scoped>
.infinite-reader {
@apply flex-1 flex flex-col items-center overflow-y-auto py-8;
@apply flex-1 flex flex-col items-center overflow-y-auto py-8 relative;
height: calc(100vh - 8rem);
scroll-behavior: smooth;
}
@@ -95,12 +235,22 @@
.loading,
.error {
@apply flex items-center justify-center;
width: 70vw;
min-height: 400px;
@apply flex items-center justify-center w-[70vw] min-h-[400px];
}
.error {
@apply text-red-500 text-xl bg-red-500/10 rounded-lg;
}
.navigation-wrapper {
@apply w-full max-w-4xl mx-auto px-4 mb-6;
}
.navigation-wrapper.top {
@apply mt-4;
}
.navigation-wrapper.bottom {
@apply mt-8 mb-4;
}
</style>

View File

@@ -3,7 +3,17 @@
<button @click="onPrevious" :disabled="isFirstPage">
<ChevronLeftIcon class="h-6 w-6" />
</button>
<div class="controls-center">
<div class="page-info"> {{ currentPage + 1 }} / {{ totalPages }} </div>
<div class="chapter-selector-wrapper" v-if="availableChapters.length > 0">
<ChapterSelector
:available-chapters="availableChapters"
@chapter-selected="onChapterSelected"
/>
</div>
</div>
<button @click="onNext" :disabled="isLastPage">
<ChevronRightIcon class="h-6 w-6" />
</button>
@@ -12,6 +22,7 @@
<script setup>
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/vue/24/outline';
import ChapterSelector from './ChapterSelector.vue';
defineProps({
currentPage: {
@@ -29,13 +40,18 @@
isLastPage: {
type: Boolean,
required: true
},
availableChapters: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['previous', 'next']);
const emit = defineEmits(['previous', 'next', 'chapter-selected']);
const onPrevious = () => emit('previous');
const onNext = () => emit('next');
const onChapterSelected = (chapterId) => emit('chapter-selected', chapterId);
</script>
<style lang="postcss" scoped>
@@ -43,10 +59,18 @@
@apply flex items-center justify-between p-4 bg-gray-800;
}
.controls-center {
@apply flex flex-col items-center space-y-2;
}
.page-info {
@apply text-lg font-medium;
}
.chapter-selector-wrapper {
@apply min-w-[200px];
}
button {
@apply px-4 py-2 bg-gray-700 rounded hover:bg-gray-600 transition-colors;
}

View File

@@ -0,0 +1,216 @@
<template>
<div class="single-mode-reader">
<!-- Zone cliquable pour navigation -->
<div class="page-navigation-wrapper" @click="handlePageClick">
<!-- Zone de navigation gauche (invisible) -->
<div
class="navigation-zone left-zone"
@click.stop="goToPrevious"
@mouseenter="showLeftHint"
@mouseleave="hideLeftHint"
title="Page précédente"
></div>
<!-- Page centrale -->
<div class="page-content">
<ReaderPage
:page-data="pageData"
:page-number="pageNumber"
:zoom="zoom"
/>
</div>
<!-- Zone de navigation droite (invisible) -->
<div
class="navigation-zone right-zone"
@click.stop="goToNext"
@mouseenter="showRightHint"
@mouseleave="hideRightHint"
title="Page suivante"
></div>
</div>
<!-- Indicateurs visuels de navigation -->
<div class="navigation-hints">
<div class="hint left-hint" v-if="canGoToPrevious && (showNavigationHints || showLeftHintHover)">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</div>
<div class="hint right-hint" v-if="canGoToNext && (showNavigationHints || showRightHintHover)">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue';
import { useReaderStore } from '../../application/store/readerStore';
import ReaderPage from './ReaderPage.vue';
const props = defineProps({
pageData: {
type: Object,
required: true
},
pageNumber: {
type: Number,
required: true
},
zoom: {
type: Number,
required: true
}
});
const store = useReaderStore();
// État pour afficher les indicateurs de navigation
const showNavigationHints = ref(false);
const showLeftHintHover = ref(false);
const showRightHintHover = ref(false);
let hintTimeout = null;
// Computed pour vérifier les possibilités de navigation
const canGoToPrevious = computed(() => {
return !store.isFirstPage || store.hasPreviousChapter;
});
const canGoToNext = computed(() => {
return !store.isLastPage || store.hasNextChapter;
});
// Navigation vers la page/chapitre précédent
const goToPrevious = async () => {
if (!store.isFirstPage) {
// Page précédente dans le même chapitre
await store.previousPage();
} else if (store.hasPreviousChapter) {
// Chapitre précédent (le store gère automatiquement la navigation vers la dernière page)
await store.goToPreviousChapter();
}
showNavigationHints.value = true;
clearTimeout(hintTimeout);
hintTimeout = setTimeout(() => {
showNavigationHints.value = false;
}, 1000);
};
// Navigation vers la page/chapitre suivant
const goToNext = async () => {
if (!store.isLastPage) {
// Page suivante dans le même chapitre
await store.nextPage();
} else if (store.hasNextChapter) {
// Première page du chapitre suivant
await store.goToNextChapter();
// Le store va charger le chapitre suivant et se positionner automatiquement à la première page
}
showNavigationHints.value = true;
clearTimeout(hintTimeout);
hintTimeout = setTimeout(() => {
showNavigationHints.value = false;
}, 1000);
};
// Gestion du clic général sur la page (fallback)
const handlePageClick = (event) => {
// Si le clic n'a pas été intercepté par les zones, on navigue vers la page suivante
goToNext();
};
// Gestion des hints au hover
const showLeftHint = () => {
showLeftHintHover.value = true;
};
const hideLeftHint = () => {
showLeftHintHover.value = false;
};
const showRightHint = () => {
showRightHintHover.value = true;
};
const hideRightHint = () => {
showRightHintHover.value = false;
};
</script>
<style lang="postcss" scoped>
.single-mode-reader {
@apply relative w-full h-full flex items-center justify-center;
}
.page-navigation-wrapper {
@apply relative w-full h-full flex items-center justify-center cursor-pointer;
}
.page-content {
@apply flex-1 h-full flex items-center justify-center;
pointer-events: none; /* Empêche les clics sur l'image elle-même */
}
.navigation-zone {
@apply absolute top-0 bottom-0 z-10;
width: 33%; /* 1/3 de la largeur pour chaque zone */
}
.left-zone {
@apply left-0;
cursor: pointer;
}
.right-zone {
@apply right-0;
cursor: pointer;
}
/* Indicateurs visuels de navigation */
.navigation-hints {
@apply absolute inset-0 pointer-events-none z-20;
}
.hint {
@apply absolute top-1/2 transform -translate-y-1/2;
@apply bg-black/50 text-white p-2 rounded-full;
@apply transition-all duration-300;
}
.left-hint {
@apply left-4;
animation: slideInLeft 0.3s ease-out;
}
.right-hint {
@apply right-4;
animation: slideInRight 0.3s ease-out;
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateY(-50%) translateX(-20px);
}
to {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateY(-50%) translateX(20px);
}
to {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
}
/* Pas d'effet hover background - les flèches apparaissent à la place */
</style>

View File

@@ -128,6 +128,8 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
->from(ChapterEntity::class, 'c')
->where('c.manga = :manga')
->andWhere('c.number < :number')
->andWhere('c.visible = true')
->andWhere('c.cbzPath IS NOT NULL')
->orderBy('c.number', 'DESC')
->setMaxResults(1)
->setParameters([
@@ -151,6 +153,8 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
->from(ChapterEntity::class, 'c')
->where('c.manga = :manga')
->andWhere('c.number > :number')
->andWhere('c.visible = true')
->andWhere('c.cbzPath IS NOT NULL')
->orderBy('c.number', 'ASC')
->setMaxResults(1)
->setParameters([