refactor(reader): serve pages as static files instead of base64

Replace the per-page API call (base64 payload) with static image URLs
served directly by Caddy from public/images/pages/{chapterId}/.

- LocalImageStorage now stores to public/images/ (was MANGA_DATA_PATH)
- LegacyChapterRepository returns /images/pages/{id}/{file} URLs,
  uses getimagesize() instead of loading file content into memory
- Delete GetChapterPage query/handler/response, ChapterPageResource,
  ChapterPageProvider, PageContent model
- Remove getPageContent() from ChapterRepositoryInterface
- Frontend: loadChapter() fetches chapter + all pages in parallel,
  ReaderPage uses URL instead of base64 data URI, InfiniteReader drops
  lazy-load observer side effect, readerStore drops loadedPages/preload
- GetChapterPagesTest: extract fixture images from CBZ at runtime,
  ignore tests/Fixtures/pages/ in .gitignore

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2026-03-09 22:05:45 +01:00
parent 6875ad4222
commit 322c396165
19 changed files with 300 additions and 755 deletions

View File

@@ -13,7 +13,6 @@ export const useReaderStore = defineStore('reader', {
error: null,
pages: [],
totalPages: 0,
loadedPages: new Set(), // Garder une trace des pages déjà chargées
// Paramètres pour les doubles pages
doublePageSettings: {
@@ -32,7 +31,6 @@ export const useReaderStore = defineStore('reader', {
// Getters pour les doubles pages
effectiveDoublePageMode: (state) => {
// Si la détection automatique est désactivée, retourner 'normal'
if (!state.doublePageSettings.autoDetect) {
return 'normal';
}
@@ -55,28 +53,20 @@ export const useReaderStore = defineStore('reader', {
try {
const repository = new ApiChapterRepository();
// Charger les informations du chapitre
const chapterData = await repository.getChapter(chapterId);
const [chapterData, pagesData] = await Promise.all([
repository.getChapter(chapterId),
repository.getChapterPages(chapterId, 1, 9999),
]);
this.currentChapter = Chapter.create(chapterData);
// Charger la liste des pages
const pagesData = await repository.getChapterPages(chapterId);
// Initialiser le tableau avec des placeholders
this.pages = new Array(pagesData.totalItems).fill(null);
this.pages = pagesData.pages.map(p => ({
id: p.id,
pageNumber: p.pageNumber,
url: p.url,
dimensions: p.dimensions,
}));
this.totalPages = pagesData.totalItems;
this.loadedPages.clear();
// Charger la première page
if (this.totalPages > 0) {
this.currentPage = 0;
await this.loadPageData(0);
// En mode infini, précharger les premières pages
if (this.readingMode === 'infinite') {
await this.preloadNextPages(0);
}
}
this.currentPage = 0;
} catch (error) {
this.error = error.message;
} finally {
@@ -84,100 +74,28 @@ export const useReaderStore = defineStore('reader', {
}
},
async loadPageData(pageIndex) {
if (!this.currentChapter || pageIndex < 0 || pageIndex >= this.totalPages) {
return;
}
// Si la page est déjà chargée, ne rien faire
if (this.loadedPages.has(pageIndex)) {
return;
}
const pageNumber = pageIndex + 1; // Convertir en 1-based pour l'API
// Marquer la page comme en cours de chargement
const newPages = [...this.pages];
newPages[pageIndex] = { loading: true };
this.pages = newPages;
try {
const repository = new ApiChapterRepository();
const pageData = await repository.getChapterPage(this.currentChapter.id, 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");
}
// Mettre à jour la page
const updatedPages = [...this.pages];
updatedPages[pageIndex] = {
id: pageData.id,
pageNumber: pageData.pageNumber,
base64Content: pageData.base64Content,
mimeType: pageData.mimeType,
dimensions: pageData.dimensions
};
this.pages = updatedPages;
this.loadedPages.add(pageIndex);
} catch (error) {
console.error(`Erreur lors du chargement de la page ${pageNumber}:`, error);
// Marquer la page comme en erreur
const errorPages = [...this.pages];
errorPages[pageIndex] = { error: error.message };
this.pages = errorPages;
}
},
async preloadNextPages(startIndex, count = 3) {
const promises = [];
for (let i = 1; i <= count; i++) {
const pageIndex = startIndex + i;
if (pageIndex < this.totalPages) {
promises.push(this.loadPageData(pageIndex));
}
}
await Promise.all(promises);
},
async handlePageVisible(pageIndex) {
handlePageVisible(pageIndex) {
if (pageIndex !== this.currentPage) {
this.currentPage = pageIndex;
// Précharger les pages suivantes
if (this.readingMode === 'infinite') {
await this.preloadNextPages(pageIndex);
}
}
},
async nextPage() {
nextPage() {
if (!this.isLastPage) {
this.currentPage++;
await this.loadPageData(this.currentPage);
}
},
async previousPage() {
previousPage() {
if (!this.isFirstPage) {
this.currentPage--;
await this.loadPageData(this.currentPage);
}
},
async setReadingMode(mode) {
if (mode === this.readingMode) return;
this.readingMode = mode;
this.savePreferences();
// S'assurer que la page courante est chargée
await this.loadPageData(this.currentPage);
// Si on passe en mode infini, précharger les pages suivantes
if (mode === 'infinite') {
await this.preloadNextPages(this.currentPage);
}
},
setReadingDirection(direction) {
@@ -190,7 +108,6 @@ export const useReaderStore = defineStore('reader', {
this.savePreferences();
},
// Nouvelles actions pour les doubles pages
setDoublePageMode(mode) {
if (['rotate', 'scroll', 'normal'].includes(mode)) {
this.doublePageSettings.mobileMode = mode;
@@ -225,16 +142,10 @@ export const useReaderStore = defineStore('reader', {
async goToPreviousChapter() {
if (this.currentChapter?.navigation?.previousChapter) {
await this.loadChapter(this.currentChapter.navigation.previousChapter);
// Aller à la dernière page du chapitre précédent
this.currentPage = Math.max(0, this.totalPages - 1);
// S'assurer que la page est chargée
if (this.totalPages > 0) {
await this.loadPageData(this.currentPage);
}
}
},
// Gestion de la persistance des préférences
savePreferences() {
try {
const preferences = {
@@ -255,7 +166,6 @@ export const useReaderStore = defineStore('reader', {
if (stored) {
const preferences = JSON.parse(stored);
// Appliquer les préférences sauvegardées
if (preferences.readingMode) this.readingMode = preferences.readingMode;
if (preferences.readingDirection) this.readingDirection = preferences.readingDirection;
if (typeof preferences.zoom === 'number') this.zoom = preferences.zoom;
@@ -277,7 +187,6 @@ export const useReaderStore = defineStore('reader', {
}
},
// Réinitialiser les préférences
resetPreferences() {
this.readingMode = 'single';
this.readingDirection = 'ltr';

View File

@@ -9,7 +9,7 @@ export class ApiChapterRepository extends ChapterRepositoryInterface {
return response.json();
}
async getChapterPages(chapterId, page = 1, itemsPerPage = 20) {
async getChapterPages(chapterId, page = 1, itemsPerPage = 9999) {
const response = await fetch(
`/api/reader/chapter/${chapterId}/pages?page=${page}&itemsPerPage=${itemsPerPage}`
);
@@ -18,12 +18,4 @@ export class ApiChapterRepository extends ChapterRepositoryInterface {
}
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

@@ -6,13 +6,10 @@
</div>
<div v-for="(page, index) in pages" :key="index" class="page-wrapper">
<div v-if="page?.loading" 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>
<div v-else-if="page?.error" class="error">
{{ page.error }}
</div>
<ReaderPage v-else-if="page?.base64Content" :page-data="page" :page-number="index + 1" :zoom="zoom" :double-page-mode="doublePageMode" />
<ReaderPage v-else :page-data="page" :page-number="index + 1" :zoom="zoom" :double-page-mode="doublePageMode" loading="lazy" />
</div>
<!-- Navigation en bas -->

View File

@@ -1,7 +1,7 @@
<template>
<div class="page-container" :style="{ transform: `scale(${zoom})` }">
<div v-if="!pageData" class="error">Aucune donnée d'image disponible</div>
<div v-else-if="!pageData.base64Content" class="error">Contenu de l'image manquant</div>
<div v-else-if="!pageData.url" class="error">URL de l'image manquante</div>
<!-- Affichage spécial pour les doubles pages sur mobile -->
<div v-else-if="isDoublePage && isMobile && doublePageMode !== 'normal'" class="double-page-mobile">
@@ -88,10 +88,7 @@ import { useReaderStore } from '../../application/store/readerStore';
const imageLoaded = ref(false);
const imageSource = computed(() => {
if (!props.pageData?.base64Content || !props.pageData?.mimeType) {
return '';
}
return `data:${props.pageData.mimeType};base64,${props.pageData.base64Content}`;
return props.pageData?.url ?? '';
});
// Détection des doubles pages basée sur le ratio largeur/hauteur et les dimensions API