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

@@ -1,10 +1,38 @@
--- ---
description: description:
globs: globs: *.vue,*.js
alwaysApply: false alwaysApply: false
--- ---
# Architecture Frontend Vue.js # Architecture Frontend Vue.js
## Introduction
En tant que développeur front-end expérimenté spécialisé en Vue.js, vous devez suivre les meilleures pratiques et standards de développement établis pour ce projet. Votre expertise en Vue.js, TypeScript, et votre maîtrise des patterns de conception modernes sont essentiels pour maintenir une base de code cohérente et maintenable.
## Stack Technique
- **Framework Principal**: Vue.js 3.x avec Composition API
- **Store Management**: Pinia 3.x
- **Routage**: Vue Router 4.x
- **Styling**:
- TailwindCSS 4.x pour les utilitaires CSS
- HeadlessUI pour les composants accessibles
- Heroicons pour l'iconographie
- **Build Tool**: Vite
- **Testing**: Vitest avec Vue Test Utils
- **Linting & Formatting**:
- ESLint avec la configuration Vue.js
- Prettier pour le formatage
## Conventions de Nommage
- **Composants**: PascalCase (ex: `MangaCard.vue`, `SearchBar.vue`)
- **Fichiers**:
- Composants: PascalCase avec extension .vue
- Utilitaires: camelCase avec extension .js/.ts
- Tests: PascalCase.spec.ts
- **Props**: camelCase dans le template, PascalCase dans le script
- **Events**: kebab-case dans le template, camelCase dans le script
- **Stores**: camelCase avec suffixe "Store" (ex: `mangaStore.js`)
- **Composables**: camelCase avec préfixe "use" (ex: `useSearch.js`)
## Structure Générale ## Structure Générale
L'application Vue.js suit une architecture hexagonale (ports & adapters) avec une séparation claire des responsabilités. Le code est organisé en domaines distincts dans le dossier `assets/vue/`. L'application Vue.js suit une architecture hexagonale (ports & adapters) avec une séparation claire des responsabilités. Le code est organisé en domaines distincts dans le dossier `assets/vue/`.
Pour ce qui est du style, on utilise TailwindCss, Headlessui et Heroicons. Pour ce qui est du style, on utilise TailwindCss, Headlessui et Heroicons.

2
.gitignore vendored
View File

@@ -36,3 +36,5 @@ yarn-error.log
/public/manga-images/ /public/manga-images/
/public/cbz/ /public/cbz/
/public/images/ /public/images/
src/Controller/TestController.php
.phpunit.cache/test-results

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

2937
public/api-docs.json Normal file

File diff suppressed because it is too large Load Diff