feat: amélioration de la navigation du Reader + correction affichage des chapitres non visibles
This commit is contained in:
parent
72d7c233f7
commit
05dd7262eb
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -3,7 +3,17 @@
|
||||
<button @click="onPrevious" :disabled="isFirstPage">
|
||||
<ChevronLeftIcon class="h-6 w-6" />
|
||||
</button>
|
||||
<div class="page-info"> {{ currentPage + 1 }} / {{ totalPages }} </div>
|
||||
|
||||
<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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user