feat: Reader working, some work still need to be done

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-02-17 12:02:56 +01:00
parent 33f5a5568a
commit 668702b1fb
20 changed files with 994 additions and 127 deletions

View File

@@ -3,17 +3,25 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { HomePage } from './presentation/pages/HomePage.jsx';
import { MangaDetailPage } from './presentation/pages/MangaDetailPage.jsx';
import { AddMangaPage } from './presentation/pages/AddMangaPage.jsx';
import { ReaderPage } from './presentation/pages/ReaderPage.jsx';
import { MangaProvider } from './presentation/context/MangaContext.jsx';
import { ReaderProvider } from './presentation/context/ReaderContext.jsx';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/manga/:slug" element={<MangaDetailPage />} />
<Route path="/add" element={<AddMangaPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
<MangaProvider>
<ReaderProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/manga/:slug" element={<MangaDetailPage />} />
<Route path="/add" element={<AddMangaPage />} />
<Route path="/reader/:chapterId" element={<ReaderPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</ReaderProvider>
</MangaProvider>
);
}

View File

@@ -0,0 +1,9 @@
export class GetChapterContext {
constructor(readerRepository) {
this.readerRepository = readerRepository;
}
async execute(chapterId) {
return await this.readerRepository.getChapterContext(chapterId);
}
}

View File

@@ -0,0 +1,9 @@
export class GetPage {
constructor(readerRepository) {
this.readerRepository = readerRepository;
}
async execute(chapterId, pageNumber) {
return await this.readerRepository.getPage(chapterId, pageNumber);
}
}

View File

@@ -0,0 +1,9 @@
export class GetPages {
constructor(readerRepository) {
this.readerRepository = readerRepository;
}
async execute(chapterId) {
return await this.readerRepository.getPages(chapterId);
}
}

View File

@@ -0,0 +1,30 @@
// Port (interface) for reader data access
export class ReaderRepository {
/**
* Récupère le contexte d'un chapitre
* @param {string} chapterId - L'identifiant du chapitre
* @returns {Promise<ReaderContext>}
*/
async getChapterContext(chapterId) {
throw new Error('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<Page>}
*/
async getPage(chapterId, pageNumber) {
throw new Error('Not implemented');
}
/**
* Récupère toutes les pages d'un chapitre
* @param {string} chapterId - L'identifiant du chapitre
* @returns {Promise<{pages: Page[], totalItems: number, currentPage: number, itemsPerPage: number, totalPages: number}>}
*/
async getPages(chapterId) {
throw new Error('Not implemented');
}
}

View File

@@ -0,0 +1,19 @@
export class ReaderContext {
constructor(id, title, number, manga, navigation) {
this.id = id;
this.title = title;
this.number = number;
this.manga = manga;
this.navigation = navigation;
}
}
export class Page {
constructor(id, pageNumber, base64Content, mimeType, dimensions) {
this.id = id;
this.pageNumber = pageNumber;
this.base64Content = base64Content;
this.mimeType = mimeType;
this.dimensions = dimensions;
}
}

View File

@@ -1,5 +1,6 @@
import axios from 'axios';
import { Manga, MangaCollection } from '../../domain/manga';
import { Manga, MangaCollection, MangaDetail } from '../../domain/manga';
import { Chapter } from '../../domain/chapter';
export class ApiMangaRepository {
constructor() {
@@ -38,4 +39,62 @@ export class ApiMangaRepository {
throw error;
}
}
async searchMangas(query) {
try {
const response = await this.api.get(`/mangas-search?title=${encodeURIComponent(query)}`);
const data = response.data;
return data.items.map(item => new Manga(
item.externalId,
item.title,
item.slug,
item.imageUrl,
item.author,
item.publicationYear,
item.genres,
item.status,
item.rating,
item.description
));
} catch (error) {
console.error('Error searching mangas:', error);
throw error;
}
}
async getMangaBySlug(slug) {
try {
const mangaResponse = await this.api.get(`/mangas/by-slug/${slug}`);
const mangaData = mangaResponse.data;
const chaptersResponse = await this.api.get(`/mangas/${mangaData.id}/chapters?page=1&limit=1000&sortOrder=desc`);
const chaptersData = chaptersResponse.data;
const chapters = chaptersData.items.map(item => new Chapter(
item.id,
parseFloat(item.number),
item.title,
item.volume,
item.isVisible,
item.createdAt
));
return new MangaDetail({
id: mangaData.id,
title: mangaData.title,
slug: mangaData.slug,
imageUrl: mangaData.imageUrl,
author: mangaData.author,
publicationYear: mangaData.publicationYear,
genres: mangaData.genres,
status: mangaData.status,
rating: mangaData.rating,
description: mangaData.description
}, chapters);
} catch (error) {
console.error('Error fetching manga details:', error);
throw error;
}
}
}

View File

@@ -0,0 +1,74 @@
import axios from 'axios';
import { ReaderContext, Page } from '../../domain/reader';
import { ReaderRepository } from '../../domain/ports/readerRepository';
export class ApiReaderRepository extends ReaderRepository {
constructor() {
super();
this.api = axios.create({
baseURL: '/api'
});
}
async getChapterContext(chapterId) {
try {
const response = await this.api.get(`/reader/chapter/${chapterId}`);
const data = response.data;
return new ReaderContext(
data.id,
data.title,
data.number,
data.manga,
data.navigation
);
} catch (error) {
console.error('Error fetching chapter context:', error);
throw error;
}
}
async getPage(chapterId, pageNumber) {
try {
const response = await this.api.get(`/reader/chapter/${chapterId}/page/${pageNumber}`);
const data = response.data;
return new Page(
data.id,
data.pageNumber,
data.base64Content,
data.mimeType,
data.dimensions
);
} catch (error) {
console.error('Error fetching page:', error);
throw error;
}
}
async getPages(chapterId) {
try {
const response = await this.api.get(`/reader/chapter/${chapterId}/pages`);
const data = response.data;
// Charger chaque page individuellement pour obtenir le contenu base64
const pagesPromises = data.pages.map(async (page) => {
const pageResponse = await this.getPage(chapterId, page.pageNumber);
return pageResponse;
});
const loadedPages = await Promise.all(pagesPromises);
return {
pages: loadedPages,
totalItems: data.totalItems,
currentPage: data.currentPage,
itemsPerPage: data.itemsPerPage,
totalPages: data.totalPages
};
} catch (error) {
console.error('Error fetching pages:', error);
throw error;
}
}
}

View File

@@ -0,0 +1,61 @@
export class MockReaderRepository {
async getChapterContext(chapterId) {
// Simuler un délai réseau
await new Promise(resolve => setTimeout(resolve, 500));
return {
id: chapterId,
title: "Un assassin invité",
number: "378",
manga: {
id: "1",
title: "One Piece"
},
navigation: {
previous: {
id: "prev-chapter",
number: "377"
},
next: {
id: "next-chapter",
number: "379"
}
}
};
}
async getPage(chapterId, pageNumber) {
// Simuler un délai réseau
await new Promise(resolve => setTimeout(resolve, 500));
return {
id: `page-${pageNumber}`,
pageNumber: pageNumber,
base64Content: "data:image/jpeg;base64,/9j/4AAQSkZJRg...", // Simulé
mimeType: "image/jpeg",
dimensions: {
width: 800,
height: 1200
}
};
}
async getPages(chapterId) {
// Simuler un délai réseau
await new Promise(resolve => setTimeout(resolve, 500));
return {
pages: Array.from({ length: 20 }, (_, i) => ({
number: i + 1,
dimensions: {
width: 800,
height: 1200
}
})),
totalItems: 20,
currentPage: 1,
itemsPerPage: 20,
totalPages: 1
};
}
}

View File

@@ -1,10 +1,10 @@
import React, { useState, useRef, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSearch, faPlus } from '@fortawesome/free-solid-svg-icons';
import { MockMangaRepository } from '../../../infrastructure/api/mockMangaRepository.js';
import { ApiMangaRepository } from '../../../infrastructure/api/apiMangaRepository.js';
import { SearchMangas } from '../../../application/useCases/searchMangas.js';
const mangaRepository = new MockMangaRepository();
const mangaRepository = new ApiMangaRepository();
const searchMangas = new SearchMangas(mangaRepository);
export function SearchBar({ onMangaClick, onAddMangaClick }) {

View File

@@ -0,0 +1,99 @@
import React, { createContext, useContext, useReducer, useCallback } from 'react';
import { ApiMangaRepository } from '../../infrastructure/api/apiMangaRepository';
import { GetMangaCollection } from '../../application/useCases/getMangaCollection';
import { GetMangaDetail } from '../../application/useCases/getMangaDetail';
const mangaRepository = new ApiMangaRepository();
const getMangaCollection = new GetMangaCollection(mangaRepository);
const getMangaDetail = new GetMangaDetail(mangaRepository);
const MangaContext = createContext(null);
const initialState = {
collection: null,
detailedMangas: {},
loading: false,
error: null
};
function mangaReducer(state, action) {
switch (action.type) {
case 'SET_LOADING':
return { ...state, loading: action.payload };
case 'SET_ERROR':
return { ...state, error: action.payload, loading: false };
case 'SET_COLLECTION':
return { ...state, collection: action.payload, loading: false, error: null };
case 'SET_MANGA_DETAIL':
return {
...state,
detailedMangas: {
...state.detailedMangas,
[action.payload.slug]: action.payload
},
loading: false,
error: null
};
default:
return state;
}
}
export function MangaProvider({ children }) {
const [state, dispatch] = useReducer(mangaReducer, initialState);
const loadCollection = useCallback(async () => {
if (state.collection) return; // Return if already loaded
dispatch({ type: 'SET_LOADING', payload: true });
try {
const collection = await getMangaCollection.execute(1);
dispatch({ type: 'SET_COLLECTION', payload: collection });
} catch (error) {
dispatch({ type: 'SET_ERROR', payload: 'Failed to load manga collection' });
console.error(error);
}
}, []);
const loadMangaDetail = useCallback(async (slug) => {
// Return cached data if available
if (state.detailedMangas[slug]) return state.detailedMangas[slug];
dispatch({ type: 'SET_LOADING', payload: true });
try {
const manga = await getMangaDetail.execute(slug);
dispatch({ type: 'SET_MANGA_DETAIL', payload: manga });
return manga;
} catch (error) {
dispatch({ type: 'SET_ERROR', payload: 'Failed to load manga details' });
console.error(error);
return null;
}
}, []);
const getMangaFromCollection = useCallback((slug) => {
if (!state.collection) return null;
return state.collection.items.find(manga => manga.slug === slug);
}, [state.collection]);
const value = {
...state,
loadCollection,
loadMangaDetail,
getMangaFromCollection
};
return (
<MangaContext.Provider value={value}>
{children}
</MangaContext.Provider>
);
}
export function useManga() {
const context = useContext(MangaContext);
if (!context) {
throw new Error('useManga must be used within a MangaProvider');
}
return context;
}

View File

@@ -0,0 +1,129 @@
import React, { createContext, useContext, useReducer, useCallback } from 'react';
import { ApiReaderRepository } from '../../infrastructure/api/apiReaderRepository';
import { GetChapterContext } from '../../application/useCases/getChapterContext';
import { GetPage } from '../../application/useCases/getPage';
import { GetPages } from '../../application/useCases/getPages';
const readerRepository = new ApiReaderRepository();
const getChapterContext = new GetChapterContext(readerRepository);
const getPage = new GetPage(readerRepository);
const getPages = new GetPages(readerRepository);
const ReaderContext = createContext(null);
const initialState = {
context: null,
currentPage: 1,
pages: [],
loading: false,
error: null,
mode: 'classic', // 'classic' ou 'scrolling'
};
function readerReducer(state, action) {
switch (action.type) {
case 'SET_LOADING':
return { ...state, loading: action.payload };
case 'SET_ERROR':
return { ...state, error: action.payload, loading: false };
case 'SET_CONTEXT':
return { ...state, context: action.payload, loading: false };
case 'SET_PAGES':
return { ...state, pages: action.payload, loading: false };
case 'SET_CURRENT_PAGE':
return { ...state, currentPage: action.payload };
case 'SET_MODE':
return { ...state, mode: action.payload };
case 'RESET_STATE':
return initialState;
default:
return state;
}
}
export function ReaderProvider({ children }) {
const [state, dispatch] = useReducer(readerReducer, initialState);
const loadChapterContext = useCallback(async (chapterId) => {
dispatch({ type: 'RESET_STATE' });
dispatch({ type: 'SET_LOADING', payload: true });
try {
const context = await getChapterContext.execute(chapterId);
dispatch({ type: 'SET_CONTEXT', payload: context });
return true;
} catch (error) {
dispatch({ type: 'SET_ERROR', payload: error.response?.status === 404 ? 'Chapitre introuvable' : 'Erreur lors du chargement du chapitre' });
console.error(error);
return false;
}
}, []);
const loadPage = useCallback(async (chapterId, pageNumber) => {
dispatch({ type: 'SET_LOADING', payload: true });
try {
const page = await getPage.execute(chapterId, pageNumber);
dispatch({ type: 'SET_LOADING', payload: false });
return page;
} catch (error) {
const errorMessage = error.response?.status === 404
? 'Page introuvable'
: 'Erreur lors du chargement de la page';
dispatch({ type: 'SET_ERROR', payload: errorMessage });
dispatch({ type: 'SET_LOADING', payload: false });
return null;
}
}, []);
const loadPages = useCallback(async (chapterId) => {
dispatch({ type: 'SET_LOADING', payload: true });
try {
const { pages } = await getPages.execute(chapterId);
if (!pages || pages.length === 0) {
dispatch({ type: 'SET_ERROR', payload: 'Aucune page trouvée dans ce chapitre' });
return false;
}
dispatch({ type: 'SET_PAGES', payload: pages });
dispatch({ type: 'SET_CURRENT_PAGE', payload: 1 });
dispatch({ type: 'SET_LOADING', payload: false });
return true;
} catch (error) {
const errorMessage = error.response?.status === 404
? 'Chapitre introuvable'
: 'Erreur lors du chargement des pages';
dispatch({ type: 'SET_ERROR', payload: errorMessage });
dispatch({ type: 'SET_LOADING', payload: false });
return false;
}
}, []);
const setCurrentPage = useCallback((pageNumber) => {
dispatch({ type: 'SET_CURRENT_PAGE', payload: pageNumber });
}, []);
const setMode = useCallback((mode) => {
dispatch({ type: 'SET_MODE', payload: mode });
}, []);
const value = {
...state,
loadChapterContext,
loadPage,
loadPages,
setCurrentPage,
setMode,
};
return (
<ReaderContext.Provider value={value}>
{children}
</ReaderContext.Provider>
);
}
export function useReader() {
const context = useContext(ReaderContext);
if (!context) {
throw new Error('useReader must be used within a ReaderProvider');
}
return context;
}

View File

@@ -4,10 +4,10 @@ import { Layout } from '../components/Layout/Layout';
import { Toolbar } from '../components/Toolbar/Toolbar';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowLeft, faSearch, faStar } from '@fortawesome/free-solid-svg-icons';
import { MockMangaRepository } from '../../infrastructure/api/mockMangaRepository';
import { ApiMangaRepository } from '../../infrastructure/api/apiMangaRepository';
import { SearchMangas } from '../../application/useCases/searchMangas';
const mangaRepository = new MockMangaRepository();
const mangaRepository = new ApiMangaRepository();
const searchMangas = new SearchMangas(mangaRepository);
export function AddMangaPage() {

View File

@@ -1,10 +1,9 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { MangaGrid } from '../components/MangaGrid.jsx';
import { Layout } from '../components/Layout/Layout.jsx';
import { Toolbar } from '../components/Toolbar/Toolbar.jsx';
import { MockMangaRepository } from '../../infrastructure/api/mockMangaRepository.js';
import { GetMangaCollection } from '../../application/useCases/getMangaCollection.js';
import { useManga } from '../context/MangaContext.jsx';
import {
faRefresh,
faSearch,
@@ -14,31 +13,13 @@ import {
faFilter
} from '@fortawesome/free-solid-svg-icons';
const mangaRepository = new MockMangaRepository();
const getMangaCollection = new GetMangaCollection(mangaRepository);
export function HomePage() {
const navigate = useNavigate();
const [mangaCollection, setMangaCollection] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const handleRefresh = async () => {
setLoading(true);
try {
const collection = await getMangaCollection.execute(1);
setMangaCollection(collection);
} catch (err) {
setError('Failed to load mangas');
console.error(err);
} finally {
setLoading(false);
}
};
const { collection, loading, error, loadCollection } = useManga();
useEffect(() => {
handleRefresh();
}, []);
loadCollection();
}, [loadCollection]);
const handleMangaClick = (slug) => {
navigate(`/manga/${slug}`);
@@ -50,7 +31,7 @@ export function HomePage() {
const toolbarConfig = {
leftSection: [
{ icon: faRefresh, label: 'Refresh', onClick: handleRefresh },
{ icon: faRefresh, label: 'Refresh', onClick: loadCollection },
{ icon: faSearch, label: 'Search', onClick: () => {} }
],
rightSection: [
@@ -73,7 +54,7 @@ export function HomePage() {
<Layout onMangaClick={handleMangaClick} onAddMangaClick={handleAddMangaClick}>
<Toolbar {...toolbarConfig} className="sticky top-16 z-10" />
<div className="container mx-auto px-4">
<MangaGrid mangas={mangaCollection.items} onMangaClick={handleMangaClick} />
<MangaGrid mangas={collection?.items || []} onMangaClick={handleMangaClick} />
</div>
</Layout>
);

View File

@@ -1,9 +1,8 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { MockMangaRepository } from '../../infrastructure/api/mockMangaRepository.js';
import { GetMangaDetail } from '../../application/useCases/getMangaDetail.js';
import { Layout } from '../components/Layout/Layout.jsx';
import { Toolbar } from '../components/Toolbar/Toolbar.jsx';
import { useManga } from '../context/MangaContext.jsx';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faStar,
@@ -15,32 +14,26 @@ import {
faShare
} from '@fortawesome/free-solid-svg-icons';
const mangaRepository = new MockMangaRepository();
const getMangaDetail = new GetMangaDetail(mangaRepository);
export function MangaDetailPage() {
const { slug } = useParams();
const navigate = useNavigate();
const [manga, setManga] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const {
detailedMangas,
loading,
error,
loadMangaDetail,
getMangaFromCollection
} = useManga();
const handleRefresh = async () => {
setLoading(true);
try {
const mangaDetail = await getMangaDetail.execute(slug);
setManga(mangaDetail);
} catch (err) {
setError('Failed to load manga details');
console.error(err);
} finally {
setLoading(false);
}
};
const manga = detailedMangas[slug];
const collectionManga = getMangaFromCollection(slug);
useEffect(() => {
handleRefresh();
}, [slug]);
// Si on n'a pas les détails du manga, on les charge
if (!manga) {
loadMangaDetail(slug);
}
}, [slug, manga, loadMangaDetail]);
const handleMangaClick = (mangaSlug) => {
navigate(`/manga/${mangaSlug}`);
@@ -53,7 +46,7 @@ export function MangaDetailPage() {
const toolbarConfig = {
leftSection: [
{ icon: faArrowLeft, onClick: () => navigate(-1) },
{ icon: faRefresh, onClick: handleRefresh }
{ icon: faRefresh, onClick: () => loadMangaDetail(slug) }
],
rightSection: [
{ icon: faBookmark, onClick: () => {} },
@@ -70,6 +63,12 @@ export function MangaDetailPage() {
return <div className="text-red-500 text-center p-4">{error}</div>;
}
// Utiliser les données de base de la collection pendant le chargement des détails
const displayManga = manga || collectionManga;
if (!displayManga) {
return <div className="text-center p-4">Manga not found</div>;
}
return (
<Layout onMangaClick={handleMangaClick} onAddMangaClick={handleAddMangaClick}>
<Toolbar {...toolbarConfig} className="sticky top-16 z-10" />
@@ -78,8 +77,8 @@ export function MangaDetailPage() {
<div className="relative h-[400px]">
<div className="absolute inset-0">
<img
src={manga.imageUrl}
alt={manga.title}
src={displayManga.imageUrl}
alt={displayManga.title}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent" />
@@ -88,23 +87,23 @@ export function MangaDetailPage() {
<div className="relative h-full container mx-auto px-4 flex items-end pb-8">
<div className="text-white">
<div className="flex items-center gap-4 mb-2">
<h1 className="text-4xl font-bold">{manga.title}</h1>
<h1 className="text-4xl font-bold">{displayManga.title}</h1>
<span className="px-2 py-1 bg-green-500 rounded-full text-sm">
{manga.status}
{displayManga.status}
</span>
</div>
<div className="flex items-center gap-6 mb-4">
<div className="flex items-center">
<FontAwesomeIcon icon={faStar} className="text-yellow-400 mr-2" />
<span>{manga.rating}</span>
<span>{displayManga.rating}</span>
</div>
<span>{manga.publicationYear}</span>
<span>{manga.author}</span>
<span>{displayManga.publicationYear}</span>
<span>{displayManga.author}</span>
</div>
<div className="flex flex-wrap gap-2 mb-4">
{manga.genres.map((genre, index) => (
{displayManga.genres.map((genre, index) => (
<span
key={index}
className="px-3 py-1 bg-gray-700/50 rounded-full text-sm"
@@ -115,69 +114,74 @@ export function MangaDetailPage() {
</div>
<p className="max-w-3xl text-gray-200">
{manga.description}
{displayManga.description}
</p>
</div>
</div>
</div>
{/* Chapters section */}
<div className="container mx-auto px-4 py-8">
{Array.from(manga.chapters.entries()).map(([volume, chapters]) => (
<div key={volume} className="mb-8">
<h2 className="text-xl font-semibold mb-4">
Volume {volume || 'Unknown'}
<span className="text-sm text-gray-500 ml-2">
({chapters.length} chapters)
</span>
</h2>
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
#
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Title
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Added
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{chapters.map((chapter) => (
<tr key={chapter.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{chapter.number}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{chapter.title}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(chapter.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-right">
<button className="text-gray-400 hover:text-gray-600 mx-2">
<FontAwesomeIcon icon={faDownload} />
</button>
<button className="text-gray-400 hover:text-gray-600 mx-2">
<FontAwesomeIcon icon={faEye} />
</button>
</td>
{/* Chapters section - only shown when full details are loaded */}
{manga && manga.chapters && (
<div className="container mx-auto px-4 py-8">
{Array.from(manga.chapters.entries()).map(([volume, chapters]) => (
<div key={volume} className="mb-8">
<h2 className="text-xl font-semibold mb-4">
Volume {volume || 'Unknown'}
<span className="text-sm text-gray-500 ml-2">
({chapters.length} chapters)
</span>
</h2>
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
#
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Title
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Added
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="divide-y divide-gray-200">
{chapters.map((chapter) => (
<tr key={chapter.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{chapter.number}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{chapter.title}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(chapter.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-right">
<button
onClick={() => navigate(`/reader/${chapter.id}`)}
className="text-gray-400 hover:text-gray-600 mx-2"
>
<FontAwesomeIcon icon={faEye} />
</button>
<button className="text-gray-400 hover:text-gray-600 mx-2">
<FontAwesomeIcon icon={faDownload} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
))}
</div>
))}
</div>
)}
</Layout>
);
}

View File

@@ -0,0 +1,246 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useReader } from '../context/ReaderContext';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faArrowLeft,
faList,
faExpand,
faCompress,
faBookOpen,
faScroll
} from '@fortawesome/free-solid-svg-icons';
export function ReaderPage() {
const { chapterId } = useParams();
const navigate = useNavigate();
const {
context,
currentPage,
pages,
loading,
error,
mode,
loadChapterContext,
loadPage,
loadPages,
setCurrentPage,
setMode
} = useReader();
const [currentPageData, setCurrentPageData] = useState(null);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const initializeChapter = async () => {
const contextLoaded = await loadChapterContext(chapterId);
if (contextLoaded) {
await loadPages(chapterId);
}
};
initializeChapter();
}, [chapterId, loadChapterContext, loadPages]);
useEffect(() => {
if (mode === 'classic' && currentPage > 0 && pages.length > 0) {
loadPage(chapterId, currentPage).then(setCurrentPageData);
}
}, [chapterId, currentPage, loadPage, mode, pages.length]);
const handleKeyDown = useCallback((e) => {
if (mode === 'classic') {
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
} else if (context?.navigation.previous) {
navigate(`/reader/${context.navigation.previous.id}`);
}
} else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
if (currentPage < pages.length) {
setCurrentPage(currentPage + 1);
} else if (context?.navigation.next) {
navigate(`/reader/${context.navigation.next.id}`);
}
}
}
}, [currentPage, context, mode, navigate, pages.length, setCurrentPage]);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
setIsFullscreen(true);
} else {
document.exitFullscreen();
setIsFullscreen(false);
}
};
const handleImageClick = (e) => {
if (mode !== 'classic') return;
const rect = e.target.getBoundingClientRect();
const x = e.clientX - rect.left;
const width = rect.width;
if (x < width / 2) {
// Clic sur la partie gauche
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
} else if (context?.navigation.previous) {
navigate(`/reader/${context.navigation.previous.id}`);
}
} else {
// Clic sur la partie droite
if (currentPage < pages.length) {
setCurrentPage(currentPage + 1);
} else if (context?.navigation.next) {
navigate(`/reader/${context.navigation.next.id}`);
}
}
};
if (loading || (!currentPageData && mode === 'classic' && pages.length === 0)) {
return (
<div className="flex justify-center items-center h-screen bg-gray-900 text-white">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-white mx-auto mb-4"></div>
<div>Chargement du chapitre...</div>
</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col justify-center items-center h-screen bg-gray-900 text-white">
<div className="text-red-500 text-xl mb-4">{error}</div>
<button
onClick={() => navigate(-1)}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors"
>
Retour
</button>
</div>
);
}
return (
<div className="min-h-screen bg-gray-900 text-white">
{/* Toolbar */}
<div className="fixed top-0 left-0 right-0 bg-gray-800 z-50">
<div className="container mx-auto px-4">
<div className="h-16 flex items-center justify-between">
<div className="flex items-center space-x-4">
<button
onClick={() => navigate(-1)}
className="text-gray-300 hover:text-white"
>
<FontAwesomeIcon icon={faArrowLeft} />
</button>
{context && (
<div>
<span className="font-medium">Manga title</span>
<span className="mx-2">-</span>
<span>Chapter {context.number}</span>
</div>
)}
</div>
<div className="flex items-center space-x-4">
<button
onClick={() => setMode(mode === 'classic' ? 'scrolling' : 'classic')}
className="text-gray-300 hover:text-white"
title={`Switch to ${mode === 'classic' ? 'scrolling' : 'classic'} mode`}
>
<FontAwesomeIcon icon={mode === 'classic' ? faScroll : faBookOpen} />
</button>
<button
onClick={toggleFullscreen}
className="text-gray-300 hover:text-white"
>
<FontAwesomeIcon icon={isFullscreen ? faCompress : faExpand} />
</button>
<button className="text-gray-300 hover:text-white">
<FontAwesomeIcon icon={faList} />
</button>
</div>
</div>
</div>
</div>
{/* Reader content */}
<div className="pt-16">
{mode === 'classic' ? (
// Mode classique
<div className="relative max-w-5xl mx-auto">
{currentPageData && (
<img
src={`data:${currentPageData.mimeType};base64,${currentPageData.base64Content}`}
alt={`Page ${currentPageData.pageNumber}`}
className="w-full h-auto cursor-pointer"
onClick={handleImageClick}
/>
)}
<div className="fixed bottom-4 left-1/2 transform -translate-x-1/2 bg-gray-800 px-4 py-2 rounded-lg">
Page {currentPage} of {pages.length}
</div>
</div>
) : (
// Mode scrolling
<div className="max-w-5xl mx-auto space-y-4 p-4">
{pages.map((page, index) => (
<div key={index} className="relative">
{page.base64Content ? (
<img
src={`data:${page.mimeType};base64,${page.base64Content}`}
alt={`Page ${page.pageNumber}`}
className="w-full h-auto"
loading="lazy"
/>
) : (
<div className="w-full aspect-[2/3] bg-gray-800 animate-pulse flex items-center justify-center">
<span className="text-gray-400">Chargement...</span>
</div>
)}
</div>
))}
</div>
)}
</div>
{/* Navigation buttons for classic mode */}
{mode === 'classic' && (
<>
<button
onClick={() => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
} else if (context?.navigation.previous) {
navigate(`/reader/${context.navigation.previous.id}`);
}
}}
className="fixed left-4 top-1/2 transform -translate-y-1/2 bg-gray-800 p-4 rounded-full opacity-50 hover:opacity-100 transition-opacity"
>
</button>
<button
onClick={() => {
if (currentPage < pages.length) {
setCurrentPage(currentPage + 1);
} else if (context?.navigation.next) {
navigate(`/reader/${context.navigation.next.id}`);
}
}}
className="fixed right-4 top-1/2 transform -translate-y-1/2 bg-gray-800 p-4 rounded-full opacity-50 hover:opacity-100 transition-opacity"
>
</button>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Domain\Reader\Domain\Exception;
use App\Domain\Reader\Domain\ValueObject\ChapterId;
use App\Domain\Reader\Domain\ValueObject\PageNumber;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class PageNotFoundException extends NotFoundHttpException
{
public static function forPage(ChapterId $chapterId, PageNumber $pageNumber): self
{
return new self(sprintf('La page %d du chapitre %s n\'existe pas', $pageNumber->getValue(), $chapterId->getValue()));
}
}

View File

@@ -25,6 +25,54 @@ use Symfony\Component\Serializer\Annotation\Groups;
'schema' => ['type' => 'string'],
],
],
'responses' => [
'200' => [
'description' => 'Contexte du chapitre',
'content' => [
'application/json' => [
'schema' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'string'],
'title' => ['type' => 'string'],
'number' => ['type' => 'string'],
'manga' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'string'],
'title' => ['type' => 'string']
]
],
'navigation' => [
'type' => 'object',
'properties' => [
'previous' => [
'type' => 'object',
'nullable' => true,
'properties' => [
'id' => ['type' => 'string'],
'number' => ['type' => 'string']
]
],
'next' => [
'type' => 'object',
'nullable' => true,
'properties' => [
'id' => ['type' => 'string'],
'number' => ['type' => 'string']
]
]
]
]
]
]
]
]
],
'404' => [
'description' => 'Chapitre non trouvé'
]
]
],
provider: ChapterContextProvider::class
),

View File

@@ -32,6 +32,34 @@ use App\Domain\Reader\Infrastructure\ApiPlatform\State\Provider\ChapterPageProvi
'description' => 'Le numéro de la page à récupérer'
],
],
'responses' => [
'200' => [
'description' => 'Page du chapitre',
'content' => [
'application/json' => [
'schema' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'string'],
'pageNumber' => ['type' => 'integer'],
'base64Content' => ['type' => 'string', 'description' => 'Contenu de l\'image en base64'],
'mimeType' => ['type' => 'string', 'example' => 'image/jpeg'],
'dimensions' => [
'type' => 'object',
'properties' => [
'width' => ['type' => 'integer'],
'height' => ['type' => 'integer']
]
]
]
]
]
]
],
'404' => [
'description' => 'Chapitre ou page non trouvé'
]
]
],
provider: ChapterPageProvider::class
),

View File

@@ -37,6 +37,43 @@ use Symfony\Component\Serializer\Annotation\Groups;
'schema' => ['type' => 'integer', 'default' => 20],
],
],
'responses' => [
'200' => [
'description' => 'Collection paginée des pages du chapitre',
'content' => [
'application/json' => [
'schema' => [
'type' => 'object',
'properties' => [
'pages' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'number' => ['type' => 'integer'],
'dimensions' => [
'type' => 'object',
'properties' => [
'width' => ['type' => 'integer'],
'height' => ['type' => 'integer']
]
]
]
]
],
'totalItems' => ['type' => 'integer'],
'currentPage' => ['type' => 'integer'],
'itemsPerPage' => ['type' => 'integer'],
'totalPages' => ['type' => 'integer']
]
]
]
]
],
'404' => [
'description' => 'Chapitre non trouvé'
]
]
],
provider: ChapterPagesProvider::class
),