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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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 { 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 }) {
|
||||
|
||||
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 { 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() {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user