feat: Reader working, some work still need to be done
This commit is contained in:
parent
33f5a5568a
commit
668702b1fb
@@ -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