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:
parent
bf8ca79290
commit
85abca7906
187
assets/vue/app/domain/reader/application/store/readerStore.js
Normal file
187
assets/vue/app/domain/reader/application/store/readerStore.js
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
30
assets/vue/app/domain/reader/domain/entities/Chapter.js
Normal file
30
assets/vue/app/domain/reader/domain/entities/Chapter.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user