feat: ajout du lecteur de chapitres avec gestion des pages, des modes de lecture et des paramètres de zoom

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-03-26 22:52:48 +01:00
parent bf8ca79290
commit 85abca7906
10 changed files with 3500 additions and 2 deletions

View File

@@ -0,0 +1,187 @@
import { defineStore } from 'pinia';
import { Chapter } from '../../domain/entities/Chapter';
import { ApiChapterRepository } from '../../infrastructure/repository/ApiChapterRepository';
export const useReaderStore = defineStore('reader', {
state: () => ({
currentChapter: null,
currentPage: 0,
readingMode: 'single', // 'single' ou 'infinite'
readingDirection: 'ltr', // 'ltr' ou 'rtl'
zoom: 1,
isLoading: false,
error: null,
pages: [],
totalPages: 0,
_debug: {
lastUpdate: Date.now(),
lastAction: null
}
}),
getters: {
isFirstPage: state => state.currentPage === 0,
isLastPage: state => state.currentPage === state.totalPages - 1,
currentPageData: state => {
const data = state.pages[state.currentPage];
console.log('Getting currentPageData:', {
currentPage: state.currentPage,
hasData: !!data,
totalPages: state.totalPages,
pagesLength: state.pages.length,
lastUpdate: state._debug.lastUpdate,
lastAction: state._debug.lastAction
});
return data;
}
},
actions: {
async loadChapter(chapterId) {
console.log('Loading chapter:', chapterId);
this.isLoading = true;
this.error = null;
this._debug.lastAction = 'loadChapter';
try {
const repository = new ApiChapterRepository();
// Charger les informations du chapitre
console.log('Fetching chapter info...');
const chapterData = await repository.getChapter(chapterId);
console.log('Chapter data received:', chapterData);
this.currentChapter = Chapter.create(chapterData);
// Charger la liste des pages
console.log('Fetching pages info...');
const pagesData = await repository.getChapterPages(chapterId);
console.log('Pages data received:', pagesData);
// Initialiser le tableau avec des placeholders
this.pages = new Array(pagesData.totalItems).fill(null);
this.totalPages = pagesData.totalItems;
this._debug.lastUpdate = Date.now();
console.log('Pages array initialized:', {
length: this.pages.length,
totalPages: this.totalPages
});
// Charger la première page
if (this.totalPages > 0) {
console.log('Loading first page...');
this.currentPage = 0;
await this.loadCurrentPageData();
} else {
console.warn('No pages available for this chapter');
}
} catch (error) {
console.error('Error loading chapter:', error);
this.error = error.message;
} finally {
this.isLoading = false;
}
},
async loadCurrentPageData() {
if (!this.currentChapter) {
console.error('No current chapter loaded');
return;
}
if (this.currentPage < 0 || this.currentPage >= this.totalPages) {
console.error('Invalid page index:', this.currentPage);
return;
}
const pageNumber = this.currentPage + 1; // Convertir en 1-based pour l'API
console.log('Loading page data:', {
pageNumber,
chapterId: this.currentChapter.id
});
if (this.pages[this.currentPage]?.base64Content) {
console.log('Page already loaded, skipping fetch');
return;
}
this.isLoading = true;
this.error = null;
this._debug.lastAction = 'loadCurrentPageData';
try {
const repository = new ApiChapterRepository();
console.log('Fetching page from API...');
const pageData = await repository.getChapterPage(this.currentChapter.id, pageNumber);
console.log('Page data received:', {
hasContent: !!pageData?.base64Content,
mimeType: pageData?.mimeType,
pageNumber: pageData?.pageNumber
});
// Vérifier que les données sont valides
if (!pageData || !pageData.base64Content) {
throw new Error("Données de page invalides reçues de l'API");
}
// Créer une nouvelle référence du tableau pour déclencher la réactivité
const newPages = [...this.pages];
newPages[this.currentPage] = {
id: pageData.id,
pageNumber: pageData.pageNumber,
base64Content: pageData.base64Content,
mimeType: pageData.mimeType,
dimensions: pageData.dimensions
};
this.pages = newPages;
this._debug.lastUpdate = Date.now();
console.log('Page data updated in store:', {
pageIndex: this.currentPage,
hasContent: !!this.pages[this.currentPage]?.base64Content
});
} catch (error) {
console.error('Error loading page:', error);
this.error = error.message;
} finally {
this.isLoading = false;
}
},
async nextPage() {
if (!this.isLastPage) {
console.log('Moving to next page');
this.currentPage++;
this._debug.lastAction = 'nextPage';
this._debug.lastUpdate = Date.now();
await this.loadCurrentPageData();
}
},
async previousPage() {
if (!this.isFirstPage) {
console.log('Moving to previous page');
this.currentPage--;
this._debug.lastAction = 'previousPage';
this._debug.lastUpdate = Date.now();
await this.loadCurrentPageData();
}
},
setReadingMode(mode) {
this.readingMode = mode;
this._debug.lastAction = 'setReadingMode';
this._debug.lastUpdate = Date.now();
},
setReadingDirection(direction) {
this.readingDirection = direction;
this._debug.lastAction = 'setReadingDirection';
this._debug.lastUpdate = Date.now();
},
setZoom(level) {
this.zoom = level;
this._debug.lastAction = 'setZoom';
this._debug.lastUpdate = Date.now();
}
}
});

View File

@@ -0,0 +1,30 @@
export class Chapter {
constructor({ id, mangaId, number, title, pages = [], read = false, lastReadPage = 0 }) {
this.id = id;
this.mangaId = mangaId;
this.number = number;
this.title = title;
this.pages = pages;
this.read = read;
this.lastReadPage = lastReadPage;
}
static create(data) {
return new Chapter(data);
}
markAsRead() {
this.read = true;
this.lastReadPage = this.pages.length;
}
markAsUnread() {
this.read = false;
this.lastReadPage = 0;
}
updateLastReadPage(pageNumber) {
this.lastReadPage = pageNumber;
this.read = pageNumber === this.pages.length;
}
}

View File

@@ -0,0 +1,31 @@
export class ChapterRepositoryInterface {
/**
* Récupère les informations d'un chapitre
* @param {string} chapterId - L'identifiant du chapitre
* @returns {Promise<Object>} Les informations du chapitre
*/
async getChapter(chapterId) {
throw new Error('Method not implemented');
}
/**
* Récupère la liste des pages d'un chapitre
* @param {string} chapterId - L'identifiant du chapitre
* @param {number} page - Le numéro de page
* @param {number} itemsPerPage - Le nombre d'éléments par page
* @returns {Promise<Object>} La liste des pages avec leurs métadonnées
*/
async getChapterPages(chapterId, page = 1, itemsPerPage = 20) {
throw new Error('Method not implemented');
}
/**
* Récupère une page spécifique d'un chapitre
* @param {string} chapterId - L'identifiant du chapitre
* @param {number} pageNumber - Le numéro de la page
* @returns {Promise<Object>} Les données de la page
*/
async getChapterPage(chapterId, pageNumber) {
throw new Error('Method not implemented');
}
}

View File

@@ -0,0 +1,29 @@
import { ChapterRepositoryInterface } from '../../domain/repository/ChapterRepositoryInterface';
export class ApiChapterRepository extends ChapterRepositoryInterface {
async getChapter(chapterId) {
const response = await fetch(`/api/reader/chapter/${chapterId}`);
if (!response.ok) {
throw new Error('Failed to fetch chapter');
}
return response.json();
}
async getChapterPages(chapterId, page = 1, itemsPerPage = 20) {
const response = await fetch(
`/api/reader/chapter/${chapterId}/pages?page=${page}&itemsPerPage=${itemsPerPage}`
);
if (!response.ok) {
throw new Error('Failed to fetch chapter pages');
}
return response.json();
}
async getChapterPage(chapterId, pageNumber) {
const response = await fetch(`/api/reader/chapter/${chapterId}/page/${pageNumber}`);
if (!response.ok) {
throw new Error('Failed to fetch chapter page');
}
return response.json();
}
}

View File

@@ -0,0 +1,206 @@
<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>
<div v-else-if="store.error" class="error">
{{ store.error }}
</div>
<div v-else class="reader-content">
<div class="reader-controls">
<button @click="store.previousPage" :disabled="store.isFirstPage">
<ChevronLeftIcon class="h-6 w-6" />
</button>
<div class="page-info"> {{ store.currentPage + 1 }} / {{ store.totalPages }} </div>
<button @click="store.nextPage" :disabled="store.isLastPage">
<ChevronRightIcon class="h-6 w-6" />
</button>
</div>
<div class="page-container" :style="{ transform: `scale(${store.zoom})` }">
<div v-if="!store.currentPageData" class="error"> Aucune donnée d'image disponible </div>
<div v-else-if="!store.currentPageData.base64Content" class="error"> Contenu de l'image manquant </div>
<img
v-else
:src="imageSource"
:alt="`Page ${store.currentPage + 1}`"
class="page-image"
@error="handleImageError"
@load="handleImageLoad" />
</div>
<div class="reader-settings">
<button @click="toggleReadingMode">
{{ store.readingMode === 'single' ? 'Mode Infini' : 'Mode Simple' }}
</button>
<button @click="toggleReadingDirection">
{{ store.readingDirection === 'ltr' ? 'RTL' : 'LTR' }}
</button>
<div class="zoom-controls">
<button @click="zoomOut">-</button>
<span>{{ Math.round(store.zoom * 100) }}%</span>
<button @click="zoomIn">+</button>
</div>
</div>
<div
class="debug-info"
style="
position: fixed;
bottom: 0;
left: 0;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 10px;
font-size: 12px;
">
currentPage: {{ store.currentPage }}<br />
totalPages: {{ store.totalPages }}<br />
hasCurrentPageData: {{ !!store.currentPageData }}<br />
hasBase64Content: {{ !!store.currentPageData?.base64Content }}<br />
mimeType: {{ store.currentPageData?.mimeType }}<br />
lastAction: {{ store._debug.lastAction }}<br />
lastUpdate: {{ new Date(store._debug.lastUpdate).toLocaleTimeString() }}
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, computed, watch } from 'vue';
import { useReaderStore } from '../../application/store/readerStore';
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/vue/24/outline';
const props = defineProps({
chapterId: {
type: String,
required: true
}
});
const store = useReaderStore();
const imageSource = computed(() => {
console.log('Computing imageSource:', {
hasCurrentPageData: !!store.currentPageData,
mimeType: store.currentPageData?.mimeType,
hasContent: !!store.currentPageData?.base64Content
});
if (!store.currentPageData?.base64Content || !store.currentPageData?.mimeType) {
console.error("Données d'image invalides:", store.currentPageData);
return '';
}
return `data:${store.currentPageData.mimeType};base64,${store.currentPageData.base64Content}`;
});
const handleImageError = e => {
console.error("Erreur de chargement de l'image:", e);
console.log("Source de l'image:", imageSource.value);
console.log('Données de la page:', store.currentPageData);
};
const handleImageLoad = () => {
console.log('Image chargée avec succès');
};
const toggleReadingMode = () => {
store.setReadingMode(store.readingMode === 'single' ? 'infinite' : 'single');
};
const toggleReadingDirection = () => {
store.setReadingDirection(store.readingDirection === 'ltr' ? 'rtl' : 'ltr');
};
const zoomIn = () => {
store.setZoom(Math.min(store.zoom + 0.1, 2));
};
const zoomOut = () => {
store.setZoom(Math.max(store.zoom - 0.1, 0.5));
};
const handleKeyPress = event => {
if (event.key === 'ArrowRight') {
store.nextPage();
} else if (event.key === 'ArrowLeft') {
store.previousPage();
}
};
watch(
() => props.chapterId,
newId => {
if (newId) {
console.log('ChapterId changed, loading new chapter:', newId);
store.loadChapter(newId);
}
},
{ immediate: true }
);
onMounted(() => {
window.addEventListener('keydown', handleKeyPress);
});
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyPress);
});
</script>
<style lang="postcss" scoped>
.chapter-reader {
@apply w-full h-full flex flex-col items-center justify-center bg-gray-900 text-white;
}
.loading {
@apply flex items-center justify-center h-full;
}
.error {
@apply text-red-500 text-xl;
}
.reader-content {
@apply w-full h-full flex flex-col;
}
.reader-controls {
@apply flex items-center justify-between p-4 bg-gray-800;
}
.page-info {
@apply text-lg font-medium;
}
.page-container {
@apply flex-1 flex items-center justify-center overflow-hidden;
transform-origin: center;
}
.page-image {
@apply max-w-full max-h-full object-contain;
}
.reader-settings {
@apply flex items-center justify-center gap-4 p-4 bg-gray-800;
}
.zoom-controls {
@apply flex items-center gap-2;
}
button {
@apply px-4 py-2 bg-gray-700 rounded hover:bg-gray-600 transition-colors;
}
button:disabled {
@apply opacity-50 cursor-not-allowed;
}
.rtl {
direction: rtl;
}
</style>

View File

@@ -0,0 +1,47 @@
<template>
<div class="chapter-page">
<div class="chapter-header">
<h1 class="text-2xl font-bold">
{{ currentChapter?.title || 'Chargement...' }}
</h1>
<div class="chapter-info">
<span class="text-gray-400"> Chapitre {{ currentChapter?.number }} </span>
</div>
</div>
<div class="reader-container">
<ChapterReader :chapter-id="chapterId" />
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useReaderStore } from '../../application/store/readerStore';
import ChapterReader from '../components/ChapterReader.vue';
const route = useRoute();
const store = useReaderStore();
const chapterId = computed(() => route.params.chapterId);
const currentChapter = computed(() => store.currentChapter);
</script>
<style lang="postcss" scoped>
.chapter-page {
@apply w-full h-full flex flex-col;
}
.chapter-header {
@apply p-4 bg-gray-800 border-b border-gray-700;
}
.chapter-info {
@apply mt-2;
}
.reader-container {
@apply flex-1 overflow-hidden;
}
</style>

View File

@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router';
import Layout from '../shared/components/layout/Layout.vue';
import HomePage from '../domain/manga/presentation/pages/HomePage.vue';
import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue';
import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue';
// Placeholder component for new routes
const PlaceholderComponent = {
@@ -48,7 +49,7 @@ const routes = [
{
path: '/reader/:chapterId',
name: 'reader',
component: PlaceholderComponent,
component: ChapterPage,
props: { title: 'Lecteur' }
},
// Pages placeholder avec chargement différé