From 668702b1fba9f432f43581e4aaca40b9fc356cde Mon Sep 17 00:00:00 2001 From: "ext.jeremy.guillot@maxicoffee.domains" Date: Mon, 17 Feb 2025 12:02:56 +0100 Subject: [PATCH] feat: Reader working, some work still need to be done --- assets/react/app/App.jsx | 24 +- .../application/useCases/getChapterContext.js | 9 + .../react/app/application/useCases/getPage.js | 9 + .../app/application/useCases/getPages.js | 9 + .../app/domain/ports/readerRepository.js | 30 +++ assets/react/app/domain/reader.js | 19 ++ .../infrastructure/api/apiMangaRepository.js | 61 ++++- .../infrastructure/api/apiReaderRepository.js | 74 ++++++ .../api/mockReaderRepository.js | 61 +++++ .../components/SearchBar/SearchBar.jsx | 4 +- .../app/presentation/context/MangaContext.jsx | 99 +++++++ .../presentation/context/ReaderContext.jsx | 129 +++++++++ .../app/presentation/pages/AddMangaPage.jsx | 4 +- .../react/app/presentation/pages/HomePage.jsx | 33 +-- .../presentation/pages/MangaDetailPage.jsx | 180 ++++++------- .../app/presentation/pages/ReaderPage.jsx | 246 ++++++++++++++++++ .../Exception/PageNotFoundException.php | 17 ++ .../Resource/ChapterContextResource.php | 48 ++++ .../Resource/ChapterPageResource.php | 28 ++ .../Resource/ChapterPagesResource.php | 37 +++ 20 files changed, 994 insertions(+), 127 deletions(-) create mode 100644 assets/react/app/application/useCases/getChapterContext.js create mode 100644 assets/react/app/application/useCases/getPage.js create mode 100644 assets/react/app/application/useCases/getPages.js create mode 100644 assets/react/app/domain/ports/readerRepository.js create mode 100644 assets/react/app/domain/reader.js create mode 100644 assets/react/app/infrastructure/api/apiReaderRepository.js create mode 100644 assets/react/app/infrastructure/api/mockReaderRepository.js create mode 100644 assets/react/app/presentation/context/MangaContext.jsx create mode 100644 assets/react/app/presentation/context/ReaderContext.jsx create mode 100644 assets/react/app/presentation/pages/ReaderPage.jsx create mode 100644 src/Domain/Reader/Domain/Exception/PageNotFoundException.php diff --git a/assets/react/app/App.jsx b/assets/react/app/App.jsx index aeda3ce..2fa8c6a 100644 --- a/assets/react/app/App.jsx +++ b/assets/react/app/App.jsx @@ -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 ( - - - } /> - } /> - } /> - } /> - - + + + + + } /> + } /> + } /> + } /> + } /> + + + + ); } diff --git a/assets/react/app/application/useCases/getChapterContext.js b/assets/react/app/application/useCases/getChapterContext.js new file mode 100644 index 0000000..b692c79 --- /dev/null +++ b/assets/react/app/application/useCases/getChapterContext.js @@ -0,0 +1,9 @@ +export class GetChapterContext { + constructor(readerRepository) { + this.readerRepository = readerRepository; + } + + async execute(chapterId) { + return await this.readerRepository.getChapterContext(chapterId); + } +} \ No newline at end of file diff --git a/assets/react/app/application/useCases/getPage.js b/assets/react/app/application/useCases/getPage.js new file mode 100644 index 0000000..46cd33e --- /dev/null +++ b/assets/react/app/application/useCases/getPage.js @@ -0,0 +1,9 @@ +export class GetPage { + constructor(readerRepository) { + this.readerRepository = readerRepository; + } + + async execute(chapterId, pageNumber) { + return await this.readerRepository.getPage(chapterId, pageNumber); + } +} \ No newline at end of file diff --git a/assets/react/app/application/useCases/getPages.js b/assets/react/app/application/useCases/getPages.js new file mode 100644 index 0000000..e05def8 --- /dev/null +++ b/assets/react/app/application/useCases/getPages.js @@ -0,0 +1,9 @@ +export class GetPages { + constructor(readerRepository) { + this.readerRepository = readerRepository; + } + + async execute(chapterId) { + return await this.readerRepository.getPages(chapterId); + } +} \ No newline at end of file diff --git a/assets/react/app/domain/ports/readerRepository.js b/assets/react/app/domain/ports/readerRepository.js new file mode 100644 index 0000000..f7e0d47 --- /dev/null +++ b/assets/react/app/domain/ports/readerRepository.js @@ -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} + */ + 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} + */ + 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'); + } +} \ No newline at end of file diff --git a/assets/react/app/domain/reader.js b/assets/react/app/domain/reader.js new file mode 100644 index 0000000..f857c60 --- /dev/null +++ b/assets/react/app/domain/reader.js @@ -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; + } +} \ No newline at end of file diff --git a/assets/react/app/infrastructure/api/apiMangaRepository.js b/assets/react/app/infrastructure/api/apiMangaRepository.js index 3e6ca64..9ac0973 100644 --- a/assets/react/app/infrastructure/api/apiMangaRepository.js +++ b/assets/react/app/infrastructure/api/apiMangaRepository.js @@ -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; + } + } } \ No newline at end of file diff --git a/assets/react/app/infrastructure/api/apiReaderRepository.js b/assets/react/app/infrastructure/api/apiReaderRepository.js new file mode 100644 index 0000000..2fb9177 --- /dev/null +++ b/assets/react/app/infrastructure/api/apiReaderRepository.js @@ -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; + } + } +} \ No newline at end of file diff --git a/assets/react/app/infrastructure/api/mockReaderRepository.js b/assets/react/app/infrastructure/api/mockReaderRepository.js new file mode 100644 index 0000000..d6e0ec6 --- /dev/null +++ b/assets/react/app/infrastructure/api/mockReaderRepository.js @@ -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 + }; + } +} \ No newline at end of file diff --git a/assets/react/app/presentation/components/SearchBar/SearchBar.jsx b/assets/react/app/presentation/components/SearchBar/SearchBar.jsx index 44e60bb..d72a1f8 100644 --- a/assets/react/app/presentation/components/SearchBar/SearchBar.jsx +++ b/assets/react/app/presentation/components/SearchBar/SearchBar.jsx @@ -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 }) { diff --git a/assets/react/app/presentation/context/MangaContext.jsx b/assets/react/app/presentation/context/MangaContext.jsx new file mode 100644 index 0000000..2cbf4a7 --- /dev/null +++ b/assets/react/app/presentation/context/MangaContext.jsx @@ -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 ( + + {children} + + ); +} + +export function useManga() { + const context = useContext(MangaContext); + if (!context) { + throw new Error('useManga must be used within a MangaProvider'); + } + return context; +} \ No newline at end of file diff --git a/assets/react/app/presentation/context/ReaderContext.jsx b/assets/react/app/presentation/context/ReaderContext.jsx new file mode 100644 index 0000000..155e043 --- /dev/null +++ b/assets/react/app/presentation/context/ReaderContext.jsx @@ -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 ( + + {children} + + ); +} + +export function useReader() { + const context = useContext(ReaderContext); + if (!context) { + throw new Error('useReader must be used within a ReaderProvider'); + } + return context; +} \ No newline at end of file diff --git a/assets/react/app/presentation/pages/AddMangaPage.jsx b/assets/react/app/presentation/pages/AddMangaPage.jsx index efe65c0..8cd827c 100644 --- a/assets/react/app/presentation/pages/AddMangaPage.jsx +++ b/assets/react/app/presentation/pages/AddMangaPage.jsx @@ -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() { diff --git a/assets/react/app/presentation/pages/HomePage.jsx b/assets/react/app/presentation/pages/HomePage.jsx index e88444f..e795977 100644 --- a/assets/react/app/presentation/pages/HomePage.jsx +++ b/assets/react/app/presentation/pages/HomePage.jsx @@ -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() {
- +
); diff --git a/assets/react/app/presentation/pages/MangaDetailPage.jsx b/assets/react/app/presentation/pages/MangaDetailPage.jsx index 5b39ff4..341ebed 100644 --- a/assets/react/app/presentation/pages/MangaDetailPage.jsx +++ b/assets/react/app/presentation/pages/MangaDetailPage.jsx @@ -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
{error}
; } + // Utiliser les données de base de la collection pendant le chargement des détails + const displayManga = manga || collectionManga; + if (!displayManga) { + return
Manga not found
; + } + return ( @@ -78,8 +77,8 @@ export function MangaDetailPage() {
{manga.title}
@@ -88,23 +87,23 @@ export function MangaDetailPage() {
-

{manga.title}

+

{displayManga.title}

- {manga.status} + {displayManga.status}
- {manga.rating} + {displayManga.rating}
- {manga.publicationYear} - {manga.author} + {displayManga.publicationYear} + {displayManga.author}
- {manga.genres.map((genre, index) => ( + {displayManga.genres.map((genre, index) => (

- {manga.description} + {displayManga.description}

- {/* Chapters section */} -
- {Array.from(manga.chapters.entries()).map(([volume, chapters]) => ( -
-

- Volume {volume || 'Unknown'} - - ({chapters.length} chapters) - -

- -
- - - - - - - - - - - {chapters.map((chapter) => ( - - - - - + {/* Chapters section - only shown when full details are loaded */} + {manga && manga.chapters && ( +
+ {Array.from(manga.chapters.entries()).map(([volume, chapters]) => ( +
+

+ Volume {volume || 'Unknown'} + + ({chapters.length} chapters) + +

+ +
+
- # - - Title - - Added - - Actions -
- {chapter.number} - - {chapter.title} - - {new Date(chapter.createdAt).toLocaleDateString()} - - - -
+ + + + + + - ))} - -
+ # + + Title + + Added + + Actions +
+ + + {chapters.map((chapter) => ( + + + {chapter.number} + + + {chapter.title} + + + {new Date(chapter.createdAt).toLocaleDateString()} + + + + + + + ))} + + +
-
- ))} -
+ ))} +
+ )} ); } \ No newline at end of file diff --git a/assets/react/app/presentation/pages/ReaderPage.jsx b/assets/react/app/presentation/pages/ReaderPage.jsx new file mode 100644 index 0000000..41fe33b --- /dev/null +++ b/assets/react/app/presentation/pages/ReaderPage.jsx @@ -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 ( +
+
+
+
Chargement du chapitre...
+
+
+ ); + } + + if (error) { + return ( +
+
{error}
+ +
+ ); + } + + return ( +
+ {/* Toolbar */} +
+
+
+
+ + {context && ( +
+ Manga title + - + Chapter {context.number} +
+ )} +
+
+ + + +
+
+
+
+ + {/* Reader content */} +
+ {mode === 'classic' ? ( + // Mode classique +
+ {currentPageData && ( + {`Page + )} +
+ Page {currentPage} of {pages.length} +
+
+ ) : ( + // Mode scrolling +
+ {pages.map((page, index) => ( +
+ {page.base64Content ? ( + {`Page + ) : ( +
+ Chargement... +
+ )} +
+ ))} +
+ )} +
+ + {/* Navigation buttons for classic mode */} + {mode === 'classic' && ( + <> + + + + )} +
+ ); +} diff --git a/src/Domain/Reader/Domain/Exception/PageNotFoundException.php b/src/Domain/Reader/Domain/Exception/PageNotFoundException.php new file mode 100644 index 0000000..18e81da --- /dev/null +++ b/src/Domain/Reader/Domain/Exception/PageNotFoundException.php @@ -0,0 +1,17 @@ +getValue(), $chapterId->getValue())); + } +} \ No newline at end of file diff --git a/src/Domain/Reader/Infrastructure/ApiPlatform/Resource/ChapterContextResource.php b/src/Domain/Reader/Infrastructure/ApiPlatform/Resource/ChapterContextResource.php index 44e51ba..b5c8d1e 100644 --- a/src/Domain/Reader/Infrastructure/ApiPlatform/Resource/ChapterContextResource.php +++ b/src/Domain/Reader/Infrastructure/ApiPlatform/Resource/ChapterContextResource.php @@ -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 ), diff --git a/src/Domain/Reader/Infrastructure/ApiPlatform/Resource/ChapterPageResource.php b/src/Domain/Reader/Infrastructure/ApiPlatform/Resource/ChapterPageResource.php index f5b6c07..34ccad8 100644 --- a/src/Domain/Reader/Infrastructure/ApiPlatform/Resource/ChapterPageResource.php +++ b/src/Domain/Reader/Infrastructure/ApiPlatform/Resource/ChapterPageResource.php @@ -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 ), diff --git a/src/Domain/Reader/Infrastructure/ApiPlatform/Resource/ChapterPagesResource.php b/src/Domain/Reader/Infrastructure/ApiPlatform/Resource/ChapterPagesResource.php index 43a60d2..b048675 100644 --- a/src/Domain/Reader/Infrastructure/ApiPlatform/Resource/ChapterPagesResource.php +++ b/src/Domain/Reader/Infrastructure/ApiPlatform/Resource/ChapterPagesResource.php @@ -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 ),