style(reader): améliorer la toolbar et l'UI du mode scroll

- Corriger la troncature de la toolbar (max-height 4rem → 5rem)
- Animer la toolbar en translateY pour un effet "bloc uni" avec le header
- Corriger le bug d'auto-hide du header après switch simple → scroll
- Augmenter la taille du titre de chapitre dans la toolbar (text-sm font-medium)
- Harmoniser le bouton scroll-to-top avec le style des ToolbarButtons
- Ajouter support de prop `class` sur les labels de ToolbarSection
This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2026-03-15 16:50:02 +01:00
parent cc702cff19
commit 9c47c717d0
18 changed files with 396 additions and 562 deletions

View File

@@ -9,19 +9,6 @@
</div> </div>
<div v-else class="reader-content"> <div v-else class="reader-content">
<ReaderControls
v-if="store.readingMode === 'single'"
:current-page="store.currentPage"
:total-pages="store.totalPages"
:is-first-page="store.isFirstPage"
:is-last-page="store.isLastPage"
:available-chapters="availableChapters"
:settings-open="settingsOpen"
@previous="store.previousPage"
@next="store.nextPage"
@chapter-selected="handleChapterSelected"
@toggle-settings="toggleSettings" />
<template v-if="store.readingMode === 'single'"> <template v-if="store.readingMode === 'single'">
<SingleModeReader <SingleModeReader
:page-data="store.currentPageData" :page-data="store.currentPageData"
@@ -36,28 +23,8 @@
:zoom="store.zoom" :zoom="store.zoom"
:double-page-mode="store.effectiveDoublePageMode" :double-page-mode="store.effectiveDoublePageMode"
@page-visible="store.handlePageVisible" @page-visible="store.handlePageVisible"
@buttons-visibility-change="handleButtonsVisibilityChange"
ref="infiniteReaderRef" /> ref="infiniteReaderRef" />
</template> </template>
<ReaderSettings
:reading-mode="store.readingMode"
:reading-direction="store.readingDirection"
:zoom="store.zoom"
:double-page-mode="store.effectiveDoublePageMode"
:double-page-settings="store.doublePageSettings"
:visible="showFloatingButtons"
:force-open="store.readingMode === 'single' ? settingsOpen : null"
@toggle-reading-mode="toggleReadingMode"
@toggle-reading-direction="toggleReadingDirection"
@zoom-in="zoomIn"
@zoom-out="zoomOut"
@zoom-change="handleZoomChange"
@double-page-mode-change="handleDoublePageModeChange"
@double-page-auto-detect-change="handleDoublePageAutoDetectChange"
@detection-threshold-change="handleDetectionThresholdChange"
@reset-preferences="handleResetPreferences"
@button-click="resetButtonsTimer" />
</div> </div>
</div> </div>
</template> </template>
@@ -68,8 +35,6 @@ import { useHeaderStore } from '../../../../shared/stores/headerStore';
import { useUserPreferencesStore } from '../../../../domain/setting/application/store/userPreferencesStore'; import { useUserPreferencesStore } from '../../../../domain/setting/application/store/userPreferencesStore';
import { useReaderStore } from '../../application/store/readerStore'; import { useReaderStore } from '../../application/store/readerStore';
import InfiniteReader from './InfiniteReader.vue'; import InfiniteReader from './InfiniteReader.vue';
import ReaderControls from './ReaderControls.vue';
import ReaderSettings from './ReaderSettings.vue';
import SingleModeReader from './SingleModeReader.vue'; import SingleModeReader from './SingleModeReader.vue';
const props = defineProps({ const props = defineProps({
@@ -87,28 +52,20 @@ import SingleModeReader from './SingleModeReader.vue';
const headerStore = useHeaderStore(); const headerStore = useHeaderStore();
const prefs = useUserPreferencesStore(); const prefs = useUserPreferencesStore();
// Référence vers InfiniteReader pour accéder à ses méthodes
const infiniteReaderRef = ref(null); const infiniteReaderRef = ref(null);
// État pour la visibilité des boutons (géré par InfiniteReader en mode infini, localement en mode simple)
const showFloatingButtons = ref(false);
const settingsOpen = ref(false); // Nouvel état pour gérer l'ouverture des paramètres
let localButtonsTimer = null;
// Actions de l'interface lecteur // Actions de l'interface lecteur
const toggleReadingMode = () => { const toggleReadingMode = () => {
const newMode = store.readingMode === 'single' ? 'infinite' : 'single'; const newMode = store.readingMode === 'single' ? 'infinite' : 'single';
store.setReadingMode(newMode); store.setReadingMode(newMode);
prefs.setReadingMode(newMode === 'infinite' ? 'scroll' : 'single'); prefs.setReadingMode(newMode === 'infinite' ? 'scroll' : 'single');
// Gérer la visibilité selon le mode
if (newMode === 'single') { if (newMode === 'single') {
headerStore.disableAutoHide(); headerStore.disableAutoHide();
// En mode simple : toujours visible headerStore.disableReaderToolbarAutoHide();
showFloatingButtons.value = true;
clearTimeout(localButtonsTimer); // Annuler tout timer local
} else { } else {
// En mode infini : utiliser la logique d'InfiniteReader headerStore.enableReaderToolbarAutoHide();
headerStore.enableAutoHide();
showButtonsWithTimer(); showButtonsWithTimer();
} }
}; };
@@ -117,100 +74,40 @@ import SingleModeReader from './SingleModeReader.vue';
const newDir = store.readingDirection === 'ltr' ? 'rtl' : 'ltr'; const newDir = store.readingDirection === 'ltr' ? 'rtl' : 'ltr';
store.setReadingDirection(newDir); store.setReadingDirection(newDir);
prefs.setReadingDirection(newDir); prefs.setReadingDirection(newDir);
resetButtonsTimer();
}; };
const zoomIn = () => { const zoomIn = () => store.setZoom(Math.min(store.zoom + 0.1, 2));
store.setZoom(Math.min(store.zoom + 0.1, 2)); const zoomOut = () => store.setZoom(Math.max(store.zoom - 0.1, 0.5));
resetButtonsTimer();
};
const zoomOut = () => { const handleZoomChange = (zoom) => store.setZoom(zoom);
store.setZoom(Math.max(store.zoom - 0.1, 0.5));
resetButtonsTimer();
};
const handleZoomChange = (zoom) => { const handleDoublePageModeChange = (mode) => store.setDoublePageMode(mode);
store.setZoom(zoom); const handleDoublePageAutoDetectChange = (enabled) => store.setDoublePageAutoDetect(enabled);
resetButtonsTimer(); const handleDetectionThresholdChange = (threshold) => store.setDoublePageDetectionThreshold(threshold);
}; const handleResetPreferences = () => store.resetPreferences();
// Fonctions pour les doubles pages
const handleDoublePageModeChange = (mode) => {
store.setDoublePageMode(mode);
resetButtonsTimer();
};
const handleDoublePageAutoDetectChange = (enabled) => {
store.setDoublePageAutoDetect(enabled);
resetButtonsTimer();
};
const handleDetectionThresholdChange = (threshold) => {
store.setDoublePageDetectionThreshold(threshold);
resetButtonsTimer();
};
const handleResetPreferences = () => {
store.resetPreferences();
resetButtonsTimer();
};
// Fonction pour afficher les boutons avec timer (avec fallback pour mode simple)
const showButtonsWithTimer = () => { const showButtonsWithTimer = () => {
if (store.readingMode === 'infinite' && infiniteReaderRef.value) { if (store.readingMode === 'infinite' && infiniteReaderRef.value) {
// Mode infini : utiliser la logique d'InfiniteReader
infiniteReaderRef.value.showButtonsWithTimer(); infiniteReaderRef.value.showButtonsWithTimer();
} else {
// Mode simple : toujours visible, pas de timer
showFloatingButtons.value = true;
} }
}; };
// Fonction centralisée pour réinitialiser le timer
const resetButtonsTimer = () => { const resetButtonsTimer = () => {
if (store.readingMode === 'infinite' && infiniteReaderRef.value) { if (store.readingMode === 'infinite' && infiniteReaderRef.value) {
// Mode infini : utiliser la logique d'InfiniteReader
infiniteReaderRef.value.resetButtonsTimer(); infiniteReaderRef.value.resetButtonsTimer();
} else {
// Mode simple : toujours visible, pas de timer
showFloatingButtons.value = true;
} }
}; };
// Gestionnaire pour les changements de visibilité des boutons
const handleButtonsVisibilityChange = (visible) => {
if (store.readingMode === 'infinite') {
showFloatingButtons.value = visible;
}
// En mode simple, on ignore les changements et on reste toujours visible
};
const handleKeyPress = event => { const handleKeyPress = event => {
if (store.readingMode === 'single') { if (store.readingMode === 'single') {
if (event.key === 'ArrowRight') { if (event.key === 'ArrowRight') {
store.nextPage(); store.nextPage();
showButtonsWithTimer(); // Afficher les boutons lors de la navigation clavier
} else if (event.key === 'ArrowLeft') { } else if (event.key === 'ArrowLeft') {
store.previousPage(); store.previousPage();
showButtonsWithTimer(); // Afficher les boutons lors de la navigation clavier
} }
} }
}; };
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);
resetButtonsTimer();
};
// Gestion des paramètres via le bouton intégré
const toggleSettings = () => {
settingsOpen.value = !settingsOpen.value;
resetButtonsTimer(); // Réinitialiser le timer lors de l'interaction
};
watch( watch(
() => props.chapterId, () => props.chapterId,
newId => { newId => {
@@ -222,38 +119,46 @@ import SingleModeReader from './SingleModeReader.vue';
); );
onMounted(() => { onMounted(() => {
// Charger les préférences sauvegardées
store.loadPreferences(); store.loadPreferences();
window.addEventListener('keydown', handleKeyPress); window.addEventListener('keydown', handleKeyPress);
// Auto-hide header si activé dans les préférences
if (prefs.autoHideHeaderReader) { if (prefs.autoHideHeaderReader) {
headerStore.enableAutoHide(); headerStore.enableAutoHide();
} }
// Auto-fullscreen si activé dans les préférences if (store.readingMode === 'infinite') {
headerStore.enableReaderToolbarAutoHide();
}
if (prefs.autoFullscreen && document.documentElement.requestFullscreen) { if (prefs.autoFullscreen && document.documentElement.requestFullscreen) {
document.documentElement.requestFullscreen().catch(() => {}); document.documentElement.requestFullscreen().catch(() => {});
} }
// Afficher les boutons au démarrage
showButtonsWithTimer();
}); });
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', handleKeyPress); window.removeEventListener('keydown', handleKeyPress);
// S'assurer que l'auto-hide est désactivé en quittant le lecteur
headerStore.disableAutoHide(); headerStore.disableAutoHide();
// Nettoyer le timer local headerStore.disableReaderToolbarAutoHide();
clearTimeout(localButtonsTimer); });
defineExpose({
toggleReadingMode,
toggleReadingDirection,
zoomIn,
zoomOut,
handleZoomChange,
handleDoublePageModeChange,
handleDoublePageAutoDetectChange,
handleDetectionThresholdChange,
handleResetPreferences,
resetButtonsTimer,
showButtonsWithTimer,
}); });
</script> </script>
<style lang="postcss" scoped> <style lang="postcss" scoped>
.chapter-reader { .chapter-reader {
@apply w-full h-full flex flex-col items-center justify-center bg-gray-900 text-white; @apply w-full h-full flex flex-col bg-gray-900 text-white;
@apply p-0 sm:p-2;
} }
.loading { .loading {
@@ -265,8 +170,7 @@ import SingleModeReader from './SingleModeReader.vue';
} }
.reader-content { .reader-content {
@apply w-full h-full flex flex-col; @apply w-full flex-1 flex flex-col min-h-0;
@apply p-0 sm:p-2;
} }
.rtl { .rtl {

View File

@@ -1,10 +1,5 @@
<template> <template>
<div class="infinite-reader" ref="containerRef"> <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-for="(page, index) in pages" :key="index" class="page-wrapper">
<div v-if="!page?.url" class="loading"> <div v-if="!page?.url" class="loading">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div> <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
@@ -12,11 +7,6 @@
<ReaderPage v-else :page-data="page" :page-number="index + 1" :zoom="zoom" :double-page-mode="doublePageMode" loading="lazy" /> <ReaderPage v-else :page-data="page" :page-number="index + 1" :zoom="zoom" :double-page-mode="doublePageMode" loading="lazy" />
</div> </div>
<!-- Navigation en bas -->
<div class="navigation-wrapper bottom">
<ChapterNavigation position="bottom" />
</div>
<!-- Bouton flottant pour revenir en haut --> <!-- Bouton flottant pour revenir en haut -->
<Transition <Transition
enter-active-class="transition-all duration-300 ease-out" enter-active-class="transition-all duration-300 ease-out"
@@ -29,13 +19,14 @@
<button <button
v-show="showFloatingButtons" v-show="showFloatingButtons"
@click="scrollToTop" @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" class="fixed bottom-6 right-6 z-[9999] bg-gray-800 hover:bg-gray-700 text-white hover:text-green-500 flex flex-col items-center justify-center w-12 h-12 rounded shadow-lg transition-colors duration-200"
title="Revenir en haut" title="Revenir en haut"
type="button" type="button"
> >
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 sm:w-6 sm: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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg> </svg>
<span class="text-xs hidden sm:inline">Haut</span>
</button> </button>
</Transition> </Transition>
</div> </div>
@@ -44,7 +35,6 @@
<script setup> <script setup>
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'; import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useHeaderStore } from '../../../../shared/stores/headerStore'; import { useHeaderStore } from '../../../../shared/stores/headerStore';
import ChapterNavigation from './ChapterNavigation.vue';
import ReaderPage from './ReaderPage.vue'; import ReaderPage from './ReaderPage.vue';
const props = defineProps({ const props = defineProps({
@@ -169,10 +159,8 @@ import ReaderPage from './ReaderPage.vue';
scrollDirection = 'up'; scrollDirection = 'up';
} }
// Gestion du header auto-hide (seulement si largeur < 1200px) // Gestion du header auto-hide (header : seulement si largeur < 1200px, toolbar : toujours)
if (windowWidth.value < 1200) { headerStore.updateScrollDirection(scrollTop);
headerStore.updateScrollDirection(scrollTop);
}
// Gestion de la visibilité des boutons flottants (même condition pour tous) // Gestion de la visibilité des boutons flottants (même condition pour tous)
// Afficher si on scroll et qu'on est à plus de 300px // Afficher si on scroll et qu'on est à plus de 300px
@@ -304,16 +292,14 @@ import ReaderPage from './ReaderPage.vue';
<style lang="postcss" scoped> <style lang="postcss" scoped>
.infinite-reader { .infinite-reader {
@apply flex-1 flex flex-col items-center overflow-y-auto relative; @apply flex-1 flex flex-col items-center overflow-y-auto relative min-h-0;
/* Réduction du padding sur mobile */ /* Réduction du padding sur mobile */
@apply py-2 sm:py-8; @apply py-2 sm:py-8;
height: calc(100vh - 8rem);
scroll-behavior: smooth; scroll-behavior: smooth;
} }
.page-wrapper { .page-wrapper {
@apply w-full flex justify-center min-h-[200px]; @apply w-full flex justify-center;
/* Réduction des marges sur mobile */
@apply mb-2 sm:mb-4 px-1 sm:px-4; @apply mb-2 sm:mb-4 px-1 sm:px-4;
} }
@@ -342,15 +328,4 @@ import ReaderPage from './ReaderPage.vue';
@apply text-red-500 text-xl bg-red-500/10 rounded-lg; @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> </style>

View File

@@ -1,5 +1,8 @@
<template> <template>
<div class="page-container" :style="{ transform: `scale(${zoom})` }"> <div
class="page-container"
:style="containerStyle"
>
<div v-if="!pageData" class="error">Aucune donnée d'image disponible</div> <div v-if="!pageData" class="error">Aucune donnée d'image disponible</div>
<div v-else-if="!pageData.url" class="error">URL de l'image manquante</div> <div v-else-if="!pageData.url" class="error">URL de l'image manquante</div>
@@ -79,6 +82,16 @@ import { useReaderStore } from '../../application/store/readerStore';
}); });
const store = useReaderStore(); const store = useReaderStore();
// En mode single : zoom via la propriété CSS `zoom` (affecte le layout → scrollbars naturelles)
// En mode infinite : zoom via transform: scale (pas d'impact layout souhaité)
const containerStyle = computed(() => {
if (store.readingMode === 'single') {
return { zoom: props.zoom };
}
return { transform: `scale(${props.zoom})` };
});
const imageRef = ref(null); const imageRef = ref(null);
const scrollContainerRef = ref(null); const scrollContainerRef = ref(null);
const naturalWidth = ref(0); const naturalWidth = ref(0);
@@ -187,13 +200,27 @@ import { useReaderStore } from '../../application/store/readerStore';
}); });
const imageStyle = computed(() => { const imageStyle = computed(() => {
if (!maxWidth.value) return {}; // Mode simple : laisser CSS contraindre les deux dimensions proportionnellement
if (store.readingMode === 'single') {
return {
maxWidth: '100%',
maxHeight: '100%',
width: 'auto',
height: 'auto',
};
}
return { // Mode scroll : fixer la largeur, hauteur libre
width: `${maxWidth.value}px`, const style = {
height: 'auto', height: 'auto',
maxWidth: '100%' maxWidth: '100%',
}; };
if (maxWidth.value) {
style.width = `${maxWidth.value}px`;
}
return style;
}); });
// Styles spéciaux pour les doubles pages // Styles spéciaux pour les doubles pages
@@ -279,17 +306,15 @@ import { useReaderStore } from '../../application/store/readerStore';
<style lang="postcss" scoped> <style lang="postcss" scoped>
.page-container { .page-container {
@apply flex-1 flex items-center justify-center overflow-hidden; @apply flex items-center justify-center;
transform-origin: center; transform-origin: center;
/* Réduction des marges sur mobile */
@apply p-0 sm:p-2; @apply p-0 sm:p-2;
} }
.page-image { .page-image {
@apply object-contain; @apply object-contain;
/* La largeur est gérée par le JavaScript, on garde juste les contraintes max */ /* La largeur et max-height sont gérées par imageStyle selon le mode */
max-width: 100%; max-width: 100%;
max-height: 100%;
} }
/* Styles pour les doubles pages sur mobile */ /* Styles pour les doubles pages sur mobile */

View File

@@ -1,29 +1,5 @@
<template> <template>
<div class="reader-settings"> <div class="reader-settings">
<!-- Bouton pour ouvrir/fermer les paramètres -->
<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="visible"
@click="toggleSettings"
class="settings-toggle"
:class="{ 'active': effectiveIsOpen }"
:data-external-control="forceOpen !== null"
title="Paramètres du lecteur"
>
<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="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
</svg>
</button>
</Transition>
<!-- Panel des paramètres -->
<Transition <Transition
enter-active-class="transition-all duration-300 ease-out" enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-in" leave-active-class="transition-all duration-300 ease-in"
@@ -32,63 +8,9 @@
leave-from-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-4 scale-95" leave-to-class="opacity-0 translate-y-4 scale-95"
> >
<div v-show="effectiveIsOpen" class="settings-panel" :data-external-control="forceOpen !== null" ref="panelRef"> <div v-show="open" class="settings-panel" ref="panelRef">
<!-- Paramètres de base -->
<div class="settings-section">
<h3 class="section-title">Mode de lecture</h3>
<div class="setting-group">
<button
@click="onToggleReadingMode"
class="setting-button"
:class="{ 'active': readingMode === 'infinite' }"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7" />
</svg>
{{ readingMode === 'single' ? 'Mode Infini' : 'Mode Simple' }}
</button>
<button <!-- Paramètres des doubles pages (mobile uniquement) -->
@click="onToggleReadingDirection"
class="setting-button"
:class="{ 'active': readingDirection === 'rtl' }"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16l-4-4m0 0l4-4m-4 4h18" />
</svg>
{{ readingDirection === 'ltr' ? 'RTL' : 'LTR' }}
</button>
</div>
</div>
<!-- Contrôles du zoom -->
<div class="settings-section">
<h3 class="section-title">Zoom</h3>
<div class="zoom-controls">
<button @click="onZoomOut" class="zoom-button" :disabled="zoom <= 0.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4" />
</svg>
</button>
<span class="zoom-display">{{ Math.round(zoom * 100) }}%</span>
<button @click="onZoomIn" class="zoom-button" :disabled="zoom >= 2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
<input
type="range"
:value="zoom"
@input="onZoomChange($event.target.value)"
min="0.5"
max="2"
step="0.1"
class="zoom-slider"
/>
</div>
<!-- Paramètres des doubles pages -->
<div class="settings-section" v-if="isMobile"> <div class="settings-section" v-if="isMobile">
<h3 class="section-title"> <h3 class="section-title">
<svg class="w-5 h-5 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -97,7 +19,6 @@
Doubles pages (Mobile) Doubles pages (Mobile)
</h3> </h3>
<!-- Activation/désactivation -->
<div class="setting-item"> <div class="setting-item">
<label class="setting-label"> <label class="setting-label">
<input <input
@@ -113,7 +34,6 @@
</p> </p>
</div> </div>
<!-- Mode d'affichage (si la détection automatique est activée) -->
<div v-if="doublePageSettings.autoDetect" class="setting-item"> <div v-if="doublePageSettings.autoDetect" class="setting-item">
<label class="setting-label">Mode d'affichage</label> <label class="setting-label">Mode d'affichage</label>
<select <select
@@ -125,22 +45,13 @@
<option value="scroll">Défilement horizontal</option> <option value="scroll">Défilement horizontal</option>
<option value="normal">Affichage normal</option> <option value="normal">Affichage normal</option>
</select> </select>
<!-- Descriptions des modes -->
<p class="setting-description"> <p class="setting-description">
<span v-if="doublePageMode === 'rotate'"> <span v-if="doublePageMode === 'rotate'">Suggère de tourner l'appareil pour une meilleure lecture</span>
Suggère de tourner l'appareil pour une meilleure lecture <span v-else-if="doublePageMode === 'scroll'">Permet le défilement horizontal pour naviguer dans la page (commence à droite)</span>
</span> <span v-else>Affichage standard sans optimisation spéciale</span>
<span v-else-if="doublePageMode === 'scroll'">
Permet le défilement horizontal pour naviguer dans la page (commence à droite)
</span>
<span v-else>
Affichage standard sans optimisation spéciale
</span>
</p> </p>
</div> </div>
<!-- Seuil de détection -->
<div v-if="doublePageSettings.autoDetect" class="setting-item"> <div v-if="doublePageSettings.autoDetect" class="setting-item">
<label class="setting-label"> <label class="setting-label">
Sensibilité de détection: {{ doublePageSettings.detectionThreshold.toFixed(1) }} Sensibilité de détection: {{ doublePageSettings.detectionThreshold.toFixed(1) }}
@@ -160,14 +71,14 @@
</div> </div>
</div> </div>
<!-- Actions --> <!-- Réinitialiser -->
<div class="settings-section"> <div class="settings-section">
<div class="setting-actions"> <div class="setting-actions">
<button @click="onResetPreferences" class="action-button reset"> <button @click="onResetPreferences" class="action-button reset">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg> </svg>
Réinitialiser Réinitialiser les préférences
</button> </button>
</div> </div>
</div> </div>
@@ -177,21 +88,9 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; import { computed, onMounted, onUnmounted, ref } from 'vue';
const props = defineProps({ const props = defineProps({
readingMode: {
type: String,
required: true
},
readingDirection: {
type: String,
required: true
},
zoom: {
type: Number,
required: true
},
doublePageMode: { doublePageMode: {
type: String, type: String,
default: 'rotate' default: 'rotate'
@@ -204,138 +103,38 @@
detectionThreshold: 1.4 detectionThreshold: 1.4
}) })
}, },
// Visibilité contrôlée par le parent open: {
visible: {
type: Boolean, type: Boolean,
default: true default: false
},
// Contrôle externe de l'ouverture (pour le bouton intégré)
forceOpen: {
type: Boolean,
default: null // null = pas de contrôle externe, true/false = contrôle externe
} }
}); });
const emit = defineEmits([ const emit = defineEmits([
'toggleReadingMode', 'toggleSettings',
'toggleReadingDirection',
'zoomIn',
'zoomOut',
'zoomChange',
'doublePageModeChange', 'doublePageModeChange',
'doublePageAutoDetectChange', 'doublePageAutoDetectChange',
'detectionThresholdChange', 'detectionThresholdChange',
'resetPreferences', 'resetPreferences',
'buttonClick' // Signaler l'interaction au parent
]); ]);
const isOpen = ref(false);
const isMobile = computed(() => window.innerWidth < 768); const isMobile = computed(() => window.innerWidth < 768);
const panelRef = ref(null); const panelRef = ref(null);
// Computed pour gérer l'état d'ouverture (interne ou externe)
const effectiveIsOpen = computed(() => {
// Si forceOpen est défini (true/false), on l'utilise
if (props.forceOpen !== null) {
return props.forceOpen;
}
// Sinon, on utilise l'état interne
return isOpen.value;
});
const toggleSettings = () => {
// Si on est en contrôle externe, ne pas permettre le toggle via le bouton flottant
if (props.forceOpen !== null) {
return;
}
isOpen.value = !isOpen.value;
// Signaler l'interaction au parent
emit('buttonClick');
};
// Fonction pour fermer le panel (utilisée par les clics externes et internes)
const closePanel = () => {
if (props.forceOpen !== null) {
// Mode externe : émettre l'événement pour que le parent gère la fermeture
emit('buttonClick');
} else {
// Mode interne : fermer directement
isOpen.value = false;
emit('buttonClick');
}
};
// Gestion des clics en dehors du panel
const handleClickOutside = (event) => { const handleClickOutside = (event) => {
if (effectiveIsOpen.value && panelRef.value && !panelRef.value.contains(event.target)) { if (props.open && panelRef.value && !panelRef.value.contains(event.target)) {
// Vérifier que le clic n'est pas sur le bouton de toggle emit('toggleSettings');
const settingsButton = document.querySelector('.settings-toggle, .settings-button');
if (settingsButton && settingsButton.contains(event.target)) {
return; // Laisser le bouton gérer le toggle
}
closePanel();
} }
}; };
// Watcher pour empêcher la fermeture du bouton quand le panel est ouvert onMounted(() => document.addEventListener('click', handleClickOutside, true));
watch( onUnmounted(() => document.removeEventListener('click', handleClickOutside, true));
() => effectiveIsOpen.value,
(newIsOpen) => {
if (newIsOpen || !newIsOpen) {
// Signaler l'interaction à chaque changement
emit('buttonClick');
}
}
);
// Cycle de vie des event listeners const onDoublePageModeChange = (mode) => emit('doublePageModeChange', mode);
onMounted(() => { const onDoublePageAutoDetectChange = (enabled) => emit('doublePageAutoDetectChange', enabled);
document.addEventListener('click', handleClickOutside, true); const onDetectionThresholdChange = (threshold) => emit('detectionThresholdChange', parseFloat(threshold));
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside, true);
});
// Méthodes des événements (toutes signalent l'interaction)
const onToggleReadingMode = () => {
emit('toggleReadingMode');
emit('buttonClick');
};
const onToggleReadingDirection = () => {
emit('toggleReadingDirection');
emit('buttonClick');
};
const onZoomIn = () => {
emit('zoomIn');
emit('buttonClick');
};
const onZoomOut = () => {
emit('zoomOut');
emit('buttonClick');
};
const onZoomChange = (value) => {
emit('zoomChange', parseFloat(value));
emit('buttonClick');
};
const onDoublePageModeChange = (mode) => {
emit('doublePageModeChange', mode);
emit('buttonClick');
};
const onDoublePageAutoDetectChange = (enabled) => {
emit('doublePageAutoDetectChange', enabled);
emit('buttonClick');
};
const onDetectionThresholdChange = (threshold) => {
emit('detectionThresholdChange', parseFloat(threshold));
emit('buttonClick');
};
const onResetPreferences = () => { const onResetPreferences = () => {
emit('resetPreferences'); emit('resetPreferences');
emit('buttonClick'); emit('toggleSettings');
isOpen.value = false;
}; };
</script> </script>
@@ -344,25 +143,10 @@
@apply relative; @apply relative;
} }
.settings-toggle {
@apply fixed top-20 right-4 z-50 w-12 h-12 bg-gray-800 hover:bg-gray-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200;
@apply focus:outline-none focus:ring-2 focus:ring-blue-500;
}
/* Masquer le bouton flottant si on est en contrôle externe */
.settings-toggle[data-external-control="true"] {
display: none;
}
.settings-toggle.active {
@apply bg-blue-600 hover:bg-blue-700;
}
.settings-panel { .settings-panel {
@apply fixed top-36 right-4 z-40 w-80 max-w-[calc(100vw-2rem)] bg-gray-800 rounded-lg shadow-xl border border-gray-700 max-h-[80vh] overflow-y-auto; @apply fixed top-20 right-4 z-40 w-80 max-w-[calc(100vw-2rem)] bg-gray-800 rounded-lg shadow-xl border border-gray-700 max-h-[80vh] overflow-y-auto;
} }
/* Responsive pour settings-panel */
@media (max-width: 480px) { @media (max-width: 480px) {
.settings-panel { .settings-panel {
width: 90vw; width: 90vw;
@@ -371,14 +155,6 @@
} }
} }
/* Position adaptative pour le contrôle externe (bouton intégré) */
.settings-panel[data-external-control="true"] {
@apply top-32 left-1/2 right-auto;
transform: translateX(-50%);
/* S'assurer qu'il ne couvre pas les contrôles */
margin-top: 1rem;
}
.settings-section { .settings-section {
@apply p-4 border-b border-gray-700 last:border-b-0; @apply p-4 border-b border-gray-700 last:border-b-0;
} }
@@ -387,44 +163,6 @@
@apply text-white font-semibold text-lg mb-3 flex items-center; @apply text-white font-semibold text-lg mb-3 flex items-center;
} }
.setting-group {
@apply flex flex-col gap-2;
}
.setting-button {
@apply flex items-center gap-2 px-3 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors duration-200 text-sm;
}
.setting-button.active {
@apply bg-blue-600 hover:bg-blue-700;
}
/* Contrôles du zoom */
.zoom-controls {
@apply flex items-center gap-3 mb-2;
}
.zoom-button {
@apply w-8 h-8 bg-gray-700 hover:bg-gray-600 disabled:bg-gray-800 disabled:cursor-not-allowed text-white rounded flex items-center justify-center transition-colors;
}
.zoom-display {
@apply text-white font-mono text-sm min-w-[3rem] text-center;
}
.zoom-slider {
@apply w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer;
}
.zoom-slider::-webkit-slider-thumb {
@apply appearance-none w-4 h-4 bg-blue-600 rounded-full cursor-pointer;
}
.zoom-slider::-moz-range-thumb {
@apply w-4 h-4 bg-blue-600 rounded-full cursor-pointer border-none;
}
/* Paramètres des doubles pages */
.setting-item { .setting-item {
@apply mb-4 last:mb-0; @apply mb-4 last:mb-0;
} }
@@ -457,7 +195,6 @@
@apply text-gray-400 text-xs leading-relaxed; @apply text-gray-400 text-xs leading-relaxed;
} }
/* Actions */
.setting-actions { .setting-actions {
@apply flex gap-2; @apply flex gap-2;
} }
@@ -470,23 +207,9 @@
@apply bg-red-600 hover:bg-red-700 text-white; @apply bg-red-600 hover:bg-red-700 text-white;
} }
/* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.settings-panel { .settings-panel {
@apply right-2 w-72; @apply right-2 w-72;
} }
.settings-toggle {
@apply right-2;
}
}
/* Pour les très petits écrans */
@media (max-width: 480px) {
.settings-toggle {
right: 0.25rem;
width: 2.5rem;
height: 2.5rem;
}
} }
</style> </style>

View File

@@ -0,0 +1,178 @@
<template>
<Toolbar :config="toolbarConfig">
<template #center>
<!-- Mode simple : navigation entre pages -->
<div v-if="store.readingMode === 'single'" class="flex items-center gap-1">
<button
@click="store.previousPage()"
:disabled="store.isFirstPage"
class="nav-btn"
title="Page précédente"
>
<ChevronLeftIcon class="h-4 w-4" />
</button>
<span class="text-white text-sm w-16 text-center">
{{ store.currentPage + 1 }} / {{ store.totalPages }}
</span>
<button
@click="store.nextPage()"
:disabled="store.isLastPage"
class="nav-btn"
title="Page suivante"
>
<ChevronRightIcon class="h-4 w-4" />
</button>
</div>
<!-- Mode scroll : navigation entre chapitres (ordre inversé en RTL) -->
<div v-else class="flex items-center gap-1">
<button
@click="leftChapterAction"
:disabled="!canGoLeftChapter || store.isLoading"
class="chapter-nav-btn"
:title="store.readingDirection === 'rtl' ? 'Chapitre suivant' : 'Chapitre précédent'"
>
<ChevronDoubleLeftIcon class="h-4 w-4 flex-shrink-0" />
<span class="text-xs">{{ store.readingDirection === 'rtl' ? 'Suivant' : 'Précédent' }}</span>
</button>
<button
@click="rightChapterAction"
:disabled="!canGoRightChapter || store.isLoading"
class="chapter-nav-btn"
:title="store.readingDirection === 'rtl' ? 'Chapitre précédent' : 'Chapitre suivant'"
>
<span class="text-xs">{{ store.readingDirection === 'rtl' ? 'Précédent' : 'Suivant' }}</span>
<ChevronDoubleRightIcon class="h-4 w-4 flex-shrink-0" />
</button>
</div>
</template>
</Toolbar>
</template>
<script setup>
import {
ArrowLeftIcon,
ChevronDoubleLeftIcon,
ChevronDoubleRightIcon,
ChevronLeftIcon,
ChevronRightIcon,
DocumentIcon,
EyeIcon,
EyeSlashIcon,
ListBulletIcon,
MinusIcon,
PlusIcon
} from '@heroicons/vue/24/outline';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useHeaderStore } from '../../../../shared/stores/headerStore';
import { useReaderStore } from '../../application/store/readerStore';
const props = defineProps({
chapterReaderRef: {
type: Object,
default: null
}
});
const store = useReaderStore();
const headerStore = useHeaderStore();
const router = useRouter();
// Vue auto-unwrap les refs dans le template : chapterReaderRef est déjà l'instance
const reader = computed(() => props.chapterReaderRef);
const goBack = () => {
const mangaId = store.currentChapter?.mangaId;
if (mangaId) {
router.push({ name: 'manga-details', params: { id: mangaId } });
} else {
router.back();
}
};
const toggleReadingMode = () => reader.value?.toggleReadingMode();
const toggleReadingDirection = () => reader.value?.toggleReadingDirection();
const zoomIn = () => store.setZoom(Math.min(store.zoom + 0.1, 2));
const zoomOut = () => store.setZoom(Math.max(store.zoom - 0.1, 0.5));
// En RTL, le bouton gauche (◄◄) avance dans l'histoire (chapitre suivant)
const isRtl = computed(() => store.readingDirection === 'rtl');
const leftChapterAction = () => isRtl.value ? store.goToNextChapter() : store.goToPreviousChapter();
const rightChapterAction = () => isRtl.value ? store.goToPreviousChapter() : store.goToNextChapter();
const canGoLeftChapter = computed(() => isRtl.value ? store.hasNextChapter : store.hasPreviousChapter);
const canGoRightChapter = computed(() => isRtl.value ? store.hasPreviousChapter : store.hasNextChapter);
const toolbarConfig = computed(() => ({
leftSection: [
{
type: 'button',
icon: ArrowLeftIcon,
label: 'Retour',
onClick: goBack,
},
{
type: 'label',
text: store.currentChapter?.title || '',
class: 'text-sm font-medium',
},
...(store.currentChapter?.number != null ? [{
type: 'label',
text: `Ch.${store.currentChapter.number}`,
}] : []),
],
rightSection: [
{
type: 'button',
icon: store.readingMode === 'single' ? ListBulletIcon : DocumentIcon,
label: store.readingMode === 'single' ? 'Scroll' : 'Simple',
active: store.readingMode === 'infinite',
onClick: toggleReadingMode,
},
{
type: 'button',
label: store.readingDirection.toUpperCase(),
active: store.readingDirection === 'rtl',
onClick: toggleReadingDirection,
},
{ type: 'divider' },
{
type: 'button',
icon: MinusIcon,
disabled: store.zoom <= 0.5,
onClick: zoomOut,
},
{
type: 'label',
text: `${Math.round(store.zoom * 100)}%`,
},
{
type: 'button',
icon: PlusIcon,
disabled: store.zoom >= 2,
onClick: zoomIn,
},
...(store.readingMode === 'infinite' ? [
{ type: 'divider' },
{
type: 'button',
icon: headerStore.isReaderToolbarAutoHideEnabled ? EyeSlashIcon : EyeIcon,
active: headerStore.isReaderToolbarAutoHideEnabled,
title: headerStore.isReaderToolbarAutoHideEnabled ? 'Toolbar auto-masquée' : 'Toolbar toujours visible',
onClick: () => headerStore.toggleReaderToolbarAutoHide(),
},
] : []),
],
}));
</script>
<style lang="postcss" scoped>
.nav-btn {
@apply flex items-center justify-center w-7 h-7 rounded bg-gray-700 hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors text-white;
}
.chapter-nav-btn {
@apply flex items-center justify-between gap-1 h-7 w-28 px-2 rounded bg-gray-700 hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors text-white;
}
</style>

View File

@@ -5,10 +5,10 @@
<!-- Zone de navigation gauche (invisible) --> <!-- Zone de navigation gauche (invisible) -->
<div <div
class="navigation-zone left-zone" class="navigation-zone left-zone"
@click.stop="goToPrevious" @click.stop="onLeftZoneClick"
@mouseenter="showLeftHint" @mouseenter="showLeftHint"
@mouseleave="hideLeftHint" @mouseleave="hideLeftHint"
title="Page précédente" :title="isRtl ? 'Page suivante' : 'Page précédente'"
></div> ></div>
<!-- Page centrale --> <!-- Page centrale -->
@@ -24,21 +24,21 @@
<!-- Zone de navigation droite (invisible) --> <!-- Zone de navigation droite (invisible) -->
<div <div
class="navigation-zone right-zone" class="navigation-zone right-zone"
@click.stop="goToNext" @click.stop="onRightZoneClick"
@mouseenter="showRightHint" @mouseenter="showRightHint"
@mouseleave="hideRightHint" @mouseleave="hideRightHint"
title="Page suivante" :title="isRtl ? 'Page précédente' : 'Page suivante'"
></div> ></div>
</div> </div>
<!-- Indicateurs visuels de navigation --> <!-- Indicateurs visuels de navigation -->
<div class="navigation-hints"> <div class="navigation-hints">
<div class="hint left-hint" v-if="canGoToPrevious && (showNavigationHints || showLeftHintHover)"> <div class="hint left-hint" v-if="canGoLeft && (showNavigationHints || showLeftHintHover)">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg> </svg>
</div> </div>
<div class="hint right-hint" v-if="canGoToNext && (showNavigationHints || showRightHintHover)"> <div class="hint right-hint" v-if="canGoRight && (showNavigationHints || showRightHintHover)">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
@@ -81,14 +81,18 @@ const showLeftHintHover = ref(false);
const showRightHintHover = ref(false); const showRightHintHover = ref(false);
let hintTimeout = null; let hintTimeout = null;
// Computed pour vérifier les possibilités de navigation const isRtl = computed(() => store.readingDirection === 'rtl');
const canGoToPrevious = computed(() => {
return !store.isFirstPage || store.hasPreviousChapter;
});
const canGoToNext = computed(() => { // Computed pour vérifier les possibilités de navigation
return !store.isLastPage || store.hasNextChapter; const canGoToPrevious = computed(() => !store.isFirstPage || store.hasPreviousChapter);
}); const canGoToNext = computed(() => !store.isLastPage || store.hasNextChapter);
// En RTL, le côté gauche avance dans l'histoire (page suivante) et le droit recule
const canGoLeft = computed(() => isRtl.value ? canGoToNext.value : canGoToPrevious.value);
const canGoRight = computed(() => isRtl.value ? canGoToPrevious.value : canGoToNext.value);
const onLeftZoneClick = () => isRtl.value ? goToNext() : goToPrevious();
const onRightZoneClick = () => isRtl.value ? goToPrevious() : goToNext();
// Navigation vers la page/chapitre précédent // Navigation vers la page/chapitre précédent
const goToPrevious = async () => { const goToPrevious = async () => {
@@ -151,22 +155,20 @@ const hideRightHint = () => {
<style lang="postcss" scoped> <style lang="postcss" scoped>
.single-mode-reader { .single-mode-reader {
@apply relative w-full h-full flex items-center justify-center; @apply relative w-full flex-1 flex flex-col min-h-0 overflow-hidden;
/* Suppression des marges sur mobile */ @apply py-2;
@apply p-0 sm:p-2;
/* Ajouter des marges en haut et en bas pour l'espace des contrôles et paramètres */
@apply py-8 sm:py-12;
} }
.page-navigation-wrapper { .page-navigation-wrapper {
@apply relative w-full h-full flex items-center justify-center cursor-pointer; /* overflow-auto : scrollbars quand l'image zoomée déborde */
@apply relative w-full flex-1 min-h-0 overflow-auto cursor-pointer;
} }
.page-content { .page-content {
@apply flex-1 h-full flex items-center justify-center; /* min-h-full : centre l'image quand elle est plus petite que le conteneur */
pointer-events: none; /* Empêche les clics sur l'image elle-même */ min-height: 100%;
/* Optimisation pour mobile */ @apply flex items-center justify-center;
@apply p-0; pointer-events: none;
} }
.navigation-zone { .navigation-zone {

View File

@@ -1,56 +1,31 @@
<template> <template>
<div class="chapter-page"> <div class="chapter-page">
<div class="chapter-header"> <div
<!-- Bouton de retour --> class="toolbar-wrapper"
<div class="flex items-center gap-4 mb-4"> :class="{ 'toolbar-hidden': !headerStore.shouldShowReaderToolbar }"
<button >
@click="goBackToManga" <div class="toolbar-slide">
class="flex items-center gap-2 px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white transition-colors duration-200" <ReaderToolbar :chapter-reader-ref="chapterReaderRef" />
:disabled="!currentChapter?.mangaId"
>
<ArrowLeftIcon class="h-5 w-5" />
<span class="text-sm font-medium">Retour au manga</span>
</button>
</div>
<!-- Titre du chapitre amélioré -->
<div class="chapter-title-section">
<h1 class="text-3xl md:text-4xl font-bold text-white leading-tight">
{{ currentChapter?.title || 'Chargement...' }}
</h1>
<div class="chapter-meta mt-3">
<span class="inline-flex items-center px-3 py-1 bg-blue-600 text-white text-sm font-semibold rounded-full">
Chapitre {{ currentChapter?.number }}
</span>
</div>
</div> </div>
</div> </div>
<div class="reader-container"> <div class="reader-container">
<ChapterReader :chapter-id="chapterId" /> <ChapterReader ref="chapterReaderRef" :chapter-id="chapterId" />
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ArrowLeftIcon } from '@heroicons/vue/24/outline'; import { computed, ref } from 'vue';
import { computed } from 'vue'; import { useRoute } from 'vue-router';
import { useRoute, useRouter } from 'vue-router'; import { useHeaderStore } from '../../../../shared/stores/headerStore';
import { useReaderStore } from '../../application/store/readerStore';
import ChapterReader from '../components/ChapterReader.vue'; import ChapterReader from '../components/ChapterReader.vue';
import ReaderToolbar from '../components/ReaderToolbar.vue';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const headerStore = useHeaderStore();
const store = useReaderStore();
const chapterId = computed(() => route.params.chapterId); const chapterId = computed(() => route.params.chapterId);
const currentChapter = computed(() => store.currentChapter); const chapterReaderRef = ref(null);
const goBackToManga = () => {
if (currentChapter.value?.mangaId) {
router.push({ name: 'manga-details', params: { id: currentChapter.value.mangaId } });
}
};
</script> </script>
<style lang="postcss" scoped> <style lang="postcss" scoped>
@@ -58,19 +33,26 @@ import ChapterReader from '../components/ChapterReader.vue';
@apply w-full h-full flex flex-col; @apply w-full h-full flex flex-col;
} }
.chapter-header { .toolbar-wrapper {
@apply p-6 bg-gradient-to-b from-gray-800 to-gray-900 border-b border-gray-700 shadow-lg; @apply overflow-hidden;
max-height: 5rem;
transition: max-height 300ms ease-in-out;
} }
.chapter-title-section { .toolbar-wrapper.toolbar-hidden {
@apply space-y-2; max-height: 0;
} }
.chapter-meta { .toolbar-slide {
@apply flex flex-wrap items-center gap-3; transform: translateY(0);
transition: transform 300ms ease-in-out;
}
.toolbar-hidden .toolbar-slide {
transform: translateY(-100%);
} }
.reader-container { .reader-container {
@apply flex-1 overflow-hidden; @apply flex-1 overflow-hidden min-h-0;
} }
</style> </style>

View File

@@ -12,9 +12,10 @@
@add-manga-click="$emit('add-manga-click', $event)" /> @add-manga-click="$emit('add-manga-click', $event)" />
<main :class="[ <main :class="[
'flex-1 mt-16 flex flex-col overflow-hidden', 'flex-1 flex flex-col overflow-hidden',
headerStore.shouldShowHeader ? 'mt-16' : 'mt-0',
isReaderMode ? '' : 'md:ml-60' isReaderMode ? '' : 'md:ml-60'
]"> ]" style="transition: margin-top 300ms ease-in-out;">
<RouterView></RouterView> <RouterView></RouterView>
</main> </main>
</div> </div>
@@ -23,10 +24,12 @@
<script setup> <script setup>
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useHeaderStore } from '../../stores/headerStore';
import Header from './Header.vue'; import Header from './Header.vue';
import Sidebar from './Sidebar.vue'; import Sidebar from './Sidebar.vue';
const route = useRoute(); const route = useRoute();
const headerStore = useHeaderStore();
const isSidebarOpen = ref(false); const isSidebarOpen = ref(false);
// Détecte si on est en mode Reader // Détecte si on est en mode Reader

View File

@@ -4,6 +4,9 @@
<!-- Left section --> <!-- Left section -->
<ToolbarSection :items="config.leftSection" /> <ToolbarSection :items="config.leftSection" />
<!-- Center section (optional slot) -->
<slot name="center" />
<!-- Right section --> <!-- Right section -->
<ToolbarSection :items="config.rightSection" /> <ToolbarSection :items="config.rightSection" />
</div> </div>

View File

@@ -1,11 +1,13 @@
<template> <template>
<button <button
@click="$emit('click')" @click="$emit('click')"
:disabled="disabled"
:class="[ :class="[
'flex flex-col items-center justify-center min-h-12 sm:min-h-14 w-min px-2 sm:ml-4 ml-1 rounded group text-white', 'flex flex-col items-center justify-center min-h-12 sm:min-h-14 w-min px-2 sm:ml-4 ml-1 rounded group text-white',
active active
? 'text-green-500' // Style actif ? 'text-green-500' // Style actif
: 'hover:text-green-500' // Effet de survol : 'hover:text-green-500', // Effet de survol
disabled ? 'opacity-40 cursor-not-allowed' : ''
]" ]"
:aria-label="label || 'Toolbar button'"> :aria-label="label || 'Toolbar button'">
<component v-if="icon" :is="icon" class="h-5 w-5 sm:h-6 sm:w-6" /> <component v-if="icon" :is="icon" class="h-5 w-5 sm:h-6 sm:w-6" />
@@ -30,6 +32,10 @@
active: { active: {
type: Boolean, type: Boolean,
default: false default: false
},
disabled: {
type: Boolean,
default: false
} }
}); });

View File

@@ -6,6 +6,7 @@
:icon="item.icon" :icon="item.icon"
:label="item.label" :label="item.label"
:active="item.active" :active="item.active"
:disabled="item.disabled"
@click="item.onClick" /> @click="item.onClick" />
<ToolbarDropdown <ToolbarDropdown
v-else-if="item.type === 'dropdown'" v-else-if="item.type === 'dropdown'"
@@ -14,7 +15,9 @@
:active="item.active" :active="item.active"
:items="item.items" /> :items="item.items" />
<Divider v-else-if="item.type === 'divider'" /> <Divider v-else-if="item.type === 'divider'" />
<!-- Ajoutez d'autres types d'éléments ici si nécessaire --> <span
v-else-if="item.type === 'label'"
:class="['text-white px-1 select-none', item.class || 'text-xs']">{{ item.text }}</span>
</template> </template>
</div> </div>
</template> </template>
@@ -36,6 +39,7 @@
item.type && item.type &&
(item.type === 'button' || (item.type === 'button' ||
item.type === 'divider' || item.type === 'divider' ||
item.type === 'label' ||
(item.type === 'dropdown' && Array.isArray(item.items))) (item.type === 'dropdown' && Array.isArray(item.items)))
); );
} }

View File

@@ -4,19 +4,20 @@ export const useHeaderStore = defineStore('header', {
state: () => ({ state: () => ({
isHeaderVisible: true, isHeaderVisible: true,
isAutoHideEnabled: false, isAutoHideEnabled: false,
isReaderToolbarVisible: true,
isReaderToolbarAutoHideEnabled: false,
lastScrollY: 0, lastScrollY: 0,
scrollDirection: 'up' scrollDirection: 'up'
}), }),
getters: { getters: {
shouldShowHeader: (state) => { shouldShowHeader: (state) => {
// Si l'auto-hide n'est pas activé, toujours afficher le header if (!state.isAutoHideEnabled) return true;
if (!state.isAutoHideEnabled) {
return true;
}
// Si l'auto-hide est activé, suivre la visibilité
return state.isHeaderVisible; return state.isHeaderVisible;
},
shouldShowReaderToolbar: (state) => {
if (!state.isReaderToolbarAutoHideEnabled) return true;
return state.isReaderToolbarVisible;
} }
}, },
@@ -27,35 +28,47 @@ export const useHeaderStore = defineStore('header', {
disableAutoHide() { disableAutoHide() {
this.isAutoHideEnabled = false; this.isAutoHideEnabled = false;
this.isHeaderVisible = true; // Toujours visible quand désactivé this.isHeaderVisible = true;
}, },
updateScrollDirection(scrollY) { enableReaderToolbarAutoHide() {
// Éviter les calculs inutiles si pas d'auto-hide this.isReaderToolbarAutoHideEnabled = true;
if (!this.isAutoHideEnabled) { this.isReaderToolbarVisible = true;
this.lastScrollY = scrollY; },
return;
}
// Détecter la direction du scroll avec un seuil pour éviter les micro-mouvements disableReaderToolbarAutoHide() {
this.isReaderToolbarAutoHideEnabled = false;
this.isReaderToolbarVisible = true;
},
toggleReaderToolbarAutoHide() {
if (this.isReaderToolbarAutoHideEnabled) {
this.disableReaderToolbarAutoHide();
this.disableAutoHide();
} else {
this.enableReaderToolbarAutoHide();
this.enableAutoHide();
}
},
updateScrollDirection(scrollY) {
const scrollDifference = Math.abs(scrollY - this.lastScrollY); const scrollDifference = Math.abs(scrollY - this.lastScrollY);
if (scrollDifference < 5) { if (scrollDifference < 5) {
// Mouvement trop petit, on ignore
return; return;
} }
if (scrollY > this.lastScrollY && scrollY > 100) { if (scrollY > this.lastScrollY && scrollY > 100) {
// Scroll vers le bas et suffisamment de scroll
if (this.scrollDirection !== 'down') { if (this.scrollDirection !== 'down') {
this.scrollDirection = 'down'; this.scrollDirection = 'down';
this.isHeaderVisible = false; if (this.isAutoHideEnabled) this.isHeaderVisible = false;
if (this.isReaderToolbarAutoHideEnabled) this.isReaderToolbarVisible = false;
} }
} else if (scrollY < this.lastScrollY) { } else if (scrollY < this.lastScrollY) {
// Scroll vers le haut
if (this.scrollDirection !== 'up') { if (this.scrollDirection !== 'up') {
this.scrollDirection = 'up'; this.scrollDirection = 'up';
this.isHeaderVisible = true; if (this.isAutoHideEnabled) this.isHeaderVisible = true;
if (this.isReaderToolbarAutoHideEnabled) this.isReaderToolbarVisible = true;
} }
} }

View File

@@ -23,12 +23,13 @@ final readonly class GetChapterContextHandler
$context = $this->chapterRepository->getChapterContext($chapterId); $context = $this->chapterRepository->getChapterContext($chapterId);
return new ChapterContextResponse( return new ChapterContextResponse(
$query->getChapterId(), id: $query->getChapterId(),
$context->getChapterTitle(), mangaId: $context->getMangaId(),
$context->getNumber(), title: $context->getChapterTitle(),
$context->getTotalPages(), number: $context->getNumber(),
$context->getPreviousChapterId()?->getValue(), totalPages: $context->getTotalPages(),
$context->getNextChapterId()?->getValue(), previousChapterId: $context->getPreviousChapterId()?->getValue(),
nextChapterId: $context->getNextChapterId()?->getValue(),
); );
} }
} }

View File

@@ -8,6 +8,7 @@ final readonly class ChapterContextResponse
{ {
public function __construct( public function __construct(
private string $id, private string $id,
private string $mangaId,
private string $title, private string $title,
private float $number, private float $number,
private int $totalPages, private int $totalPages,
@@ -21,6 +22,11 @@ final readonly class ChapterContextResponse
return $this->id; return $this->id;
} }
public function getMangaId(): string
{
return $this->mangaId;
}
public function getTitle(): string public function getTitle(): string
{ {
return $this->title; return $this->title;

View File

@@ -12,6 +12,7 @@ readonly class ChapterContext
private ChapterId $id, private ChapterId $id,
private ?ChapterId $previousChapterId, private ?ChapterId $previousChapterId,
private ?ChapterId $nextChapterId, private ?ChapterId $nextChapterId,
private string $mangaId,
private string $mangaTitle, private string $mangaTitle,
private float $number, private float $number,
private ?string $chapterTitle, private ?string $chapterTitle,
@@ -39,6 +40,11 @@ readonly class ChapterContext
return $this->nextChapterId; return $this->nextChapterId;
} }
public function getMangaId(): string
{
return $this->mangaId;
}
public function getMangaTitle(): string public function getMangaTitle(): string
{ {
return $this->mangaTitle; return $this->mangaTitle;

View File

@@ -27,6 +27,7 @@ final readonly class ChapterContextProvider implements ProviderInterface
return new ChapterContextResponse( return new ChapterContextResponse(
id: $response->getId(), id: $response->getId(),
mangaId: $response->getMangaId(),
title: $response->getTitle(), title: $response->getTitle(),
number: $response->getNumber(), number: $response->getNumber(),
totalPages: $response->getTotalPages(), totalPages: $response->getTotalPages(),

View File

@@ -49,6 +49,7 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
id: $chapterId, id: $chapterId,
previousChapterId: $this->getPreviousChapterId($chapterId), previousChapterId: $this->getPreviousChapterId($chapterId),
nextChapterId: $this->getNextChapterId($chapterId), nextChapterId: $this->getNextChapterId($chapterId),
mangaId: (string) $chapter->getManga()->getId(),
mangaTitle: $chapter->getManga()->getTitle(), mangaTitle: $chapter->getManga()->getTitle(),
number: $chapter->getNumber(), number: $chapter->getNumber(),
chapterTitle: $chapter->getTitle(), chapterTitle: $chapter->getTitle(),

View File

@@ -53,6 +53,7 @@ final class GetChapterContextTest extends AbstractApiTestCase
$this->assertJsonContains([ $this->assertJsonContains([
'id' => (string)$chapter1->getId(), 'id' => (string)$chapter1->getId(),
'mangaId' => (string)$manga->getId(),
'title' => 'Chapter 1', 'title' => 'Chapter 1',
'number' => 1, 'number' => 1,
'totalPages' => 0, 'totalPages' => 0,