feat: Reader working, some work still need to be done
This commit is contained in:
parent
33f5a5568a
commit
668702b1fb
@@ -3,17 +3,25 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
|||||||
import { HomePage } from './presentation/pages/HomePage.jsx';
|
import { HomePage } from './presentation/pages/HomePage.jsx';
|
||||||
import { MangaDetailPage } from './presentation/pages/MangaDetailPage.jsx';
|
import { MangaDetailPage } from './presentation/pages/MangaDetailPage.jsx';
|
||||||
import { AddMangaPage } from './presentation/pages/AddMangaPage.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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<MangaProvider>
|
||||||
<Routes>
|
<ReaderProvider>
|
||||||
<Route path="/" element={<HomePage />} />
|
<BrowserRouter>
|
||||||
<Route path="/manga/:slug" element={<MangaDetailPage />} />
|
<Routes>
|
||||||
<Route path="/add" element={<AddMangaPage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="/manga/:slug" element={<MangaDetailPage />} />
|
||||||
</Routes>
|
<Route path="/add" element={<AddMangaPage />} />
|
||||||
</BrowserRouter>
|
<Route path="/reader/:chapterId" element={<ReaderPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</ReaderProvider>
|
||||||
|
</MangaProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export class GetChapterContext {
|
||||||
|
constructor(readerRepository) {
|
||||||
|
this.readerRepository = readerRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(chapterId) {
|
||||||
|
return await this.readerRepository.getChapterContext(chapterId);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
assets/react/app/application/useCases/getPage.js
Normal file
9
assets/react/app/application/useCases/getPage.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export class GetPage {
|
||||||
|
constructor(readerRepository) {
|
||||||
|
this.readerRepository = readerRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(chapterId, pageNumber) {
|
||||||
|
return await this.readerRepository.getPage(chapterId, pageNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
assets/react/app/application/useCases/getPages.js
Normal file
9
assets/react/app/application/useCases/getPages.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export class GetPages {
|
||||||
|
constructor(readerRepository) {
|
||||||
|
this.readerRepository = readerRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(chapterId) {
|
||||||
|
return await this.readerRepository.getPages(chapterId);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
assets/react/app/domain/ports/readerRepository.js
Normal file
30
assets/react/app/domain/ports/readerRepository.js
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
19
assets/react/app/domain/reader.js
Normal file
19
assets/react/app/domain/reader.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import axios from 'axios';
|
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 {
|
export class ApiMangaRepository {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -38,4 +39,62 @@ export class ApiMangaRepository {
|
|||||||
throw error;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
74
assets/react/app/infrastructure/api/apiReaderRepository.js
Normal file
74
assets/react/app/infrastructure/api/apiReaderRepository.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
assets/react/app/infrastructure/api/mockReaderRepository.js
Normal file
61
assets/react/app/infrastructure/api/mockReaderRepository.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faSearch, faPlus } from '@fortawesome/free-solid-svg-icons';
|
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';
|
import { SearchMangas } from '../../../application/useCases/searchMangas.js';
|
||||||
|
|
||||||
const mangaRepository = new MockMangaRepository();
|
const mangaRepository = new ApiMangaRepository();
|
||||||
const searchMangas = new SearchMangas(mangaRepository);
|
const searchMangas = new SearchMangas(mangaRepository);
|
||||||
|
|
||||||
export function SearchBar({ onMangaClick, onAddMangaClick }) {
|
export function SearchBar({ onMangaClick, onAddMangaClick }) {
|
||||||
|
|||||||
99
assets/react/app/presentation/context/MangaContext.jsx
Normal file
99
assets/react/app/presentation/context/MangaContext.jsx
Normal 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;
|
||||||
|
}
|
||||||
129
assets/react/app/presentation/context/ReaderContext.jsx
Normal file
129
assets/react/app/presentation/context/ReaderContext.jsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -4,10 +4,10 @@ import { Layout } from '../components/Layout/Layout';
|
|||||||
import { Toolbar } from '../components/Toolbar/Toolbar';
|
import { Toolbar } from '../components/Toolbar/Toolbar';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faArrowLeft, faSearch, faStar } from '@fortawesome/free-solid-svg-icons';
|
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';
|
import { SearchMangas } from '../../application/useCases/searchMangas';
|
||||||
|
|
||||||
const mangaRepository = new MockMangaRepository();
|
const mangaRepository = new ApiMangaRepository();
|
||||||
const searchMangas = new SearchMangas(mangaRepository);
|
const searchMangas = new SearchMangas(mangaRepository);
|
||||||
|
|
||||||
export function AddMangaPage() {
|
export function AddMangaPage() {
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { MangaGrid } from '../components/MangaGrid.jsx';
|
import { MangaGrid } from '../components/MangaGrid.jsx';
|
||||||
import { Layout } from '../components/Layout/Layout.jsx';
|
import { Layout } from '../components/Layout/Layout.jsx';
|
||||||
import { Toolbar } from '../components/Toolbar/Toolbar.jsx';
|
import { Toolbar } from '../components/Toolbar/Toolbar.jsx';
|
||||||
import { MockMangaRepository } from '../../infrastructure/api/mockMangaRepository.js';
|
import { useManga } from '../context/MangaContext.jsx';
|
||||||
import { GetMangaCollection } from '../../application/useCases/getMangaCollection.js';
|
|
||||||
import {
|
import {
|
||||||
faRefresh,
|
faRefresh,
|
||||||
faSearch,
|
faSearch,
|
||||||
@@ -14,31 +13,13 @@ import {
|
|||||||
faFilter
|
faFilter
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
const mangaRepository = new MockMangaRepository();
|
|
||||||
const getMangaCollection = new GetMangaCollection(mangaRepository);
|
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [mangaCollection, setMangaCollection] = useState(null);
|
const { collection, loading, error, loadCollection } = useManga();
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleRefresh();
|
loadCollection();
|
||||||
}, []);
|
}, [loadCollection]);
|
||||||
|
|
||||||
const handleMangaClick = (slug) => {
|
const handleMangaClick = (slug) => {
|
||||||
navigate(`/manga/${slug}`);
|
navigate(`/manga/${slug}`);
|
||||||
@@ -50,7 +31,7 @@ export function HomePage() {
|
|||||||
|
|
||||||
const toolbarConfig = {
|
const toolbarConfig = {
|
||||||
leftSection: [
|
leftSection: [
|
||||||
{ icon: faRefresh, label: 'Refresh', onClick: handleRefresh },
|
{ icon: faRefresh, label: 'Refresh', onClick: loadCollection },
|
||||||
{ icon: faSearch, label: 'Search', onClick: () => {} }
|
{ icon: faSearch, label: 'Search', onClick: () => {} }
|
||||||
],
|
],
|
||||||
rightSection: [
|
rightSection: [
|
||||||
@@ -73,7 +54,7 @@ export function HomePage() {
|
|||||||
<Layout onMangaClick={handleMangaClick} onAddMangaClick={handleAddMangaClick}>
|
<Layout onMangaClick={handleMangaClick} onAddMangaClick={handleAddMangaClick}>
|
||||||
<Toolbar {...toolbarConfig} className="sticky top-16 z-10" />
|
<Toolbar {...toolbarConfig} className="sticky top-16 z-10" />
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<MangaGrid mangas={mangaCollection.items} onMangaClick={handleMangaClick} />
|
<MangaGrid mangas={collection?.items || []} onMangaClick={handleMangaClick} />
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
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 { Layout } from '../components/Layout/Layout.jsx';
|
||||||
import { Toolbar } from '../components/Toolbar/Toolbar.jsx';
|
import { Toolbar } from '../components/Toolbar/Toolbar.jsx';
|
||||||
|
import { useManga } from '../context/MangaContext.jsx';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import {
|
import {
|
||||||
faStar,
|
faStar,
|
||||||
@@ -15,32 +14,26 @@ import {
|
|||||||
faShare
|
faShare
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
const mangaRepository = new MockMangaRepository();
|
|
||||||
const getMangaDetail = new GetMangaDetail(mangaRepository);
|
|
||||||
|
|
||||||
export function MangaDetailPage() {
|
export function MangaDetailPage() {
|
||||||
const { slug } = useParams();
|
const { slug } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [manga, setManga] = useState(null);
|
const {
|
||||||
const [loading, setLoading] = useState(true);
|
detailedMangas,
|
||||||
const [error, setError] = useState(null);
|
loading,
|
||||||
|
error,
|
||||||
|
loadMangaDetail,
|
||||||
|
getMangaFromCollection
|
||||||
|
} = useManga();
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const manga = detailedMangas[slug];
|
||||||
setLoading(true);
|
const collectionManga = getMangaFromCollection(slug);
|
||||||
try {
|
|
||||||
const mangaDetail = await getMangaDetail.execute(slug);
|
|
||||||
setManga(mangaDetail);
|
|
||||||
} catch (err) {
|
|
||||||
setError('Failed to load manga details');
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleRefresh();
|
// Si on n'a pas les détails du manga, on les charge
|
||||||
}, [slug]);
|
if (!manga) {
|
||||||
|
loadMangaDetail(slug);
|
||||||
|
}
|
||||||
|
}, [slug, manga, loadMangaDetail]);
|
||||||
|
|
||||||
const handleMangaClick = (mangaSlug) => {
|
const handleMangaClick = (mangaSlug) => {
|
||||||
navigate(`/manga/${mangaSlug}`);
|
navigate(`/manga/${mangaSlug}`);
|
||||||
@@ -53,7 +46,7 @@ export function MangaDetailPage() {
|
|||||||
const toolbarConfig = {
|
const toolbarConfig = {
|
||||||
leftSection: [
|
leftSection: [
|
||||||
{ icon: faArrowLeft, onClick: () => navigate(-1) },
|
{ icon: faArrowLeft, onClick: () => navigate(-1) },
|
||||||
{ icon: faRefresh, onClick: handleRefresh }
|
{ icon: faRefresh, onClick: () => loadMangaDetail(slug) }
|
||||||
],
|
],
|
||||||
rightSection: [
|
rightSection: [
|
||||||
{ icon: faBookmark, onClick: () => {} },
|
{ icon: faBookmark, onClick: () => {} },
|
||||||
@@ -70,6 +63,12 @@ export function MangaDetailPage() {
|
|||||||
return <div className="text-red-500 text-center p-4">{error}</div>;
|
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 (
|
return (
|
||||||
<Layout onMangaClick={handleMangaClick} onAddMangaClick={handleAddMangaClick}>
|
<Layout onMangaClick={handleMangaClick} onAddMangaClick={handleAddMangaClick}>
|
||||||
<Toolbar {...toolbarConfig} className="sticky top-16 z-10" />
|
<Toolbar {...toolbarConfig} className="sticky top-16 z-10" />
|
||||||
@@ -78,8 +77,8 @@ export function MangaDetailPage() {
|
|||||||
<div className="relative h-[400px]">
|
<div className="relative h-[400px]">
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<img
|
<img
|
||||||
src={manga.imageUrl}
|
src={displayManga.imageUrl}
|
||||||
alt={manga.title}
|
alt={displayManga.title}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent" />
|
<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="relative h-full container mx-auto px-4 flex items-end pb-8">
|
||||||
<div className="text-white">
|
<div className="text-white">
|
||||||
<div className="flex items-center gap-4 mb-2">
|
<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">
|
<span className="px-2 py-1 bg-green-500 rounded-full text-sm">
|
||||||
{manga.status}
|
{displayManga.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-6 mb-4">
|
<div className="flex items-center gap-6 mb-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<FontAwesomeIcon icon={faStar} className="text-yellow-400 mr-2" />
|
<FontAwesomeIcon icon={faStar} className="text-yellow-400 mr-2" />
|
||||||
<span>{manga.rating}</span>
|
<span>{displayManga.rating}</span>
|
||||||
</div>
|
</div>
|
||||||
<span>{manga.publicationYear}</span>
|
<span>{displayManga.publicationYear}</span>
|
||||||
<span>{manga.author}</span>
|
<span>{displayManga.author}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
{manga.genres.map((genre, index) => (
|
{displayManga.genres.map((genre, index) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
className="px-3 py-1 bg-gray-700/50 rounded-full text-sm"
|
className="px-3 py-1 bg-gray-700/50 rounded-full text-sm"
|
||||||
@@ -115,69 +114,74 @@ export function MangaDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="max-w-3xl text-gray-200">
|
<p className="max-w-3xl text-gray-200">
|
||||||
{manga.description}
|
{displayManga.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chapters section */}
|
{/* Chapters section - only shown when full details are loaded */}
|
||||||
<div className="container mx-auto px-4 py-8">
|
{manga && manga.chapters && (
|
||||||
{Array.from(manga.chapters.entries()).map(([volume, chapters]) => (
|
<div className="container mx-auto px-4 py-8">
|
||||||
<div key={volume} className="mb-8">
|
{Array.from(manga.chapters.entries()).map(([volume, chapters]) => (
|
||||||
<h2 className="text-xl font-semibold mb-4">
|
<div key={volume} className="mb-8">
|
||||||
Volume {volume || 'Unknown'}
|
<h2 className="text-xl font-semibold mb-4">
|
||||||
<span className="text-sm text-gray-500 ml-2">
|
Volume {volume || 'Unknown'}
|
||||||
({chapters.length} chapters)
|
<span className="text-sm text-gray-500 ml-2">
|
||||||
</span>
|
({chapters.length} chapters)
|
||||||
</h2>
|
</span>
|
||||||
|
</h2>
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
|
||||||
<table className="w-full">
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
<thead className="bg-gray-50">
|
<table className="w-full">
|
||||||
<tr>
|
<thead className="bg-gray-50">
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<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">
|
</th>
|
||||||
Title
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
</th>
|
Title
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
</th>
|
||||||
Added
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
</th>
|
Added
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
</th>
|
||||||
Actions
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
</th>
|
Actions
|
||||||
</tr>
|
</th>
|
||||||
</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>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody className="divide-y divide-gray-200">
|
||||||
</table>
|
{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>
|
||||||
</div>
|
)}
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
246
assets/react/app/presentation/pages/ReaderPage.jsx
Normal file
246
assets/react/app/presentation/pages/ReaderPage.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/Domain/Reader/Domain/Exception/PageNotFoundException.php
Normal file
17
src/Domain/Reader/Domain/Exception/PageNotFoundException.php
Normal 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,54 @@ use Symfony\Component\Serializer\Annotation\Groups;
|
|||||||
'schema' => ['type' => 'string'],
|
'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
|
provider: ChapterContextProvider::class
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -32,6 +32,34 @@ use App\Domain\Reader\Infrastructure\ApiPlatform\State\Provider\ChapterPageProvi
|
|||||||
'description' => 'Le numéro de la page à récupérer'
|
'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
|
provider: ChapterPageProvider::class
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -37,6 +37,43 @@ use Symfony\Component\Serializer\Annotation\Groups;
|
|||||||
'schema' => ['type' => 'integer', 'default' => 20],
|
'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
|
provider: ChapterPagesProvider::class
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user