239
src/App.css
239
src/App.css
@@ -1,38 +1,229 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Accesibilidad: clase para ocultar visualmente pero mantener accesible para lectores de pantalla */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Accesibilidad: Skip link para navegación por teclado */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
border-radius: 0 0 4px 0;
|
||||
z-index: 10000;
|
||||
transition: top 0.3s;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 0;
|
||||
outline: 3px solid #ffd700;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
}
|
||||
|
||||
/* Mejorar contraste para accesibilidad */
|
||||
.App-header {
|
||||
background: linear-gradient(135deg, #5568d3 0%, #6941a3 100%);
|
||||
color: white;
|
||||
padding: 2rem 1.5rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
.logo-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
font-size: 3rem;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.App-header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.95;
|
||||
margin: 0;
|
||||
padding-left: 4rem;
|
||||
}
|
||||
|
||||
.App-main {
|
||||
flex: 1;
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.controls-section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.catalog-section {
|
||||
flex: 1;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.App-footer {
|
||||
background: #2c3e50;
|
||||
color: white;
|
||||
padding: 1rem 1.5rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.App-footer p {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.App-footer a {
|
||||
color: #8fa3ff;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.App-footer a:hover,
|
||||
.App-footer a:focus {
|
||||
color: #b5c7ff;
|
||||
text-decoration: underline;
|
||||
outline: 2px solid #8fa3ff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.share-toast {
|
||||
position: fixed;
|
||||
top: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
font-weight: 600;
|
||||
z-index: 1002;
|
||||
animation: slideDown 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-20px);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.App-header {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.App-header h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.95rem;
|
||||
padding-left: 2.75rem;
|
||||
}
|
||||
|
||||
.App-main {
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.catalog-section {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.App-footer p {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.logo-section {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
235
src/App.js
235
src/App.js
@@ -1,23 +1,228 @@
|
||||
import logo from './logo.svg';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { FaBook, FaShare } from 'react-icons/fa';
|
||||
import BookList from './components/BookList';
|
||||
import EpubViewer from './components/EpubViewer';
|
||||
import GatewaySelector from './components/GatewaySelector';
|
||||
import { parseIPFSCatalog, IPFS_GATEWAYS, buildEpubUrl } from './utils/ipfsParser';
|
||||
import './App.css';
|
||||
|
||||
/**
|
||||
* Componente principal de La Biblioteca
|
||||
* Aplicación para visualizar documentos EPUB desde IPFS
|
||||
*/
|
||||
function App() {
|
||||
const [books, setBooks] = useState([]);
|
||||
const [selectedBook, setSelectedBook] = useState(null);
|
||||
const [selectedGateway, setSelectedGateway] = useState(IPFS_GATEWAYS[0].url);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showShareDialog, setShowShareDialog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadCatalog();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedGateway]);
|
||||
|
||||
useEffect(() => {
|
||||
// Verificar si hay un hash IPFS en la URL al cargar la página
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const ipfsHash = params.get('epub');
|
||||
const gateway = params.get('gateway');
|
||||
|
||||
if (ipfsHash && books.length > 0) {
|
||||
// Si hay un gateway en la URL, usarlo
|
||||
if (gateway && gateway !== selectedGateway) {
|
||||
setSelectedGateway(gateway);
|
||||
}
|
||||
// Cargar el EPUB directamente
|
||||
const book = books.find(b => b.ipfsHash === ipfsHash);
|
||||
if (book) {
|
||||
setSelectedBook(book);
|
||||
} else {
|
||||
// Si no está en el catálogo, crear un objeto básico
|
||||
setSelectedBook({
|
||||
id: ipfsHash,
|
||||
ipfsHash: ipfsHash,
|
||||
title: 'Libro compartido',
|
||||
filename: `${ipfsHash}.epub`,
|
||||
href: ''
|
||||
});
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [books]);
|
||||
|
||||
/**
|
||||
* Carga el catálogo de libros desde IPFS
|
||||
*/
|
||||
const loadCatalog = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Cargar el catálogo desde IPFS usando el gateway seleccionado
|
||||
const catalogUrl = `${selectedGateway}/ipfs/QmYuyPJZs6J6LfKgVFsMPX5kApWYfSLcr1BXox8NMS7UpL/`;
|
||||
const response = await fetch(catalogUrl);
|
||||
const htmlContent = await response.text();
|
||||
|
||||
// Parsear el contenido y extraer los libros
|
||||
const parsedBooks = parseIPFSCatalog(htmlContent);
|
||||
setBooks(parsedBooks);
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Error cargando el catálogo:', error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Carga un libro directamente desde su hash IPFS
|
||||
*/
|
||||
const loadBookFromHash = (ipfsHash) => {
|
||||
// Buscar el libro en el catálogo si ya está cargado
|
||||
const book = books.find(b => b.ipfsHash === ipfsHash);
|
||||
if (book) {
|
||||
setSelectedBook(book);
|
||||
} else {
|
||||
// Si no está en el catálogo, crear un objeto básico
|
||||
setSelectedBook({
|
||||
id: ipfsHash,
|
||||
ipfsHash: ipfsHash,
|
||||
title: 'Libro compartido',
|
||||
filename: `${ipfsHash}.epub`,
|
||||
href: ''
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Maneja la selección de un libro
|
||||
*/
|
||||
const handleBookSelect = (book) => {
|
||||
// Actualizar la URL del navegador
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('epub', book.ipfsHash);
|
||||
url.searchParams.set('gateway', selectedGateway);
|
||||
window.history.pushState({}, '', url);
|
||||
|
||||
setSelectedBook(book);
|
||||
};
|
||||
|
||||
/**
|
||||
* Cierra el visor de EPUB
|
||||
*/
|
||||
const handleCloseViewer = () => {
|
||||
// Limpiar la URL
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.delete('epub');
|
||||
url.searchParams.delete('gateway');
|
||||
window.history.pushState({}, '', url);
|
||||
|
||||
setSelectedBook(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Genera el enlace para compartir
|
||||
*/
|
||||
const getShareUrl = () => {
|
||||
if (!selectedBook) return '';
|
||||
const url = new URL(window.location.origin + window.location.pathname);
|
||||
url.searchParams.set('epub', selectedBook.ipfsHash);
|
||||
url.searchParams.set('gateway', selectedGateway);
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
/**
|
||||
* Copia el enlace al portapapeles
|
||||
*/
|
||||
const handleShare = async () => {
|
||||
const shareUrl = getShareUrl();
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
setShowShareDialog(true);
|
||||
setTimeout(() => setShowShareDialog(false), 3000);
|
||||
} catch (err) {
|
||||
console.error('Error al copiar:', err);
|
||||
alert('No se pudo copiar el enlace. URL: ' + shareUrl);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<header className="App-header">
|
||||
<img src={logo} className="App-logo" alt="logo" />
|
||||
<p>
|
||||
Edit <code>src/App.js</code> and save to reload.
|
||||
</p>
|
||||
<a
|
||||
className="App-link"
|
||||
href="https://reactjs.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn React
|
||||
</a>
|
||||
</header>
|
||||
<a href="#main-content" className="skip-link">Saltar al contenido principal</a>
|
||||
{selectedBook ? (
|
||||
<>
|
||||
<EpubViewer
|
||||
epubUrl={buildEpubUrl(selectedBook.ipfsHash, selectedGateway)}
|
||||
bookTitle={selectedBook.title}
|
||||
bookFilename={selectedBook.filename}
|
||||
onClose={handleCloseViewer}
|
||||
onShare={handleShare}
|
||||
/>
|
||||
{showShareDialog && (
|
||||
<div
|
||||
className="share-toast"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
✓ Enlace copiado al portapapeles
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<header className="App-header" role="banner">
|
||||
<div className="header-content">
|
||||
<div className="logo-section">
|
||||
<FaBook className="app-logo" aria-hidden="true" />
|
||||
<h1>La Biblioteca</h1>
|
||||
</div>
|
||||
<p className="subtitle">Visor de documentos EPUB accesibles desde IPFS</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="App-main" id="main-content" role="main">
|
||||
<div className="controls-section">
|
||||
<GatewaySelector
|
||||
gateways={IPFS_GATEWAYS}
|
||||
selectedGateway={selectedGateway}
|
||||
onGatewayChange={setSelectedGateway}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="catalog-section">
|
||||
<BookList
|
||||
books={books}
|
||||
onBookSelect={handleBookSelect}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="App-footer" role="contentinfo">
|
||||
<p>
|
||||
<span aria-label="Libros disponibles">{books.length} libros disponibles en IPFS</span>
|
||||
{' | '}
|
||||
<a
|
||||
href="https://ipfs.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Información sobre IPFS (abre en nueva ventana)"
|
||||
>
|
||||
¿Qué es IPFS?
|
||||
</a>
|
||||
{' | '}
|
||||
<a
|
||||
href="https://git.manalejandro.com/ale/labiblioteca"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Repositorio git del proyecto (abre en nueva ventana)"
|
||||
>
|
||||
📦 Repositorio
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
178
src/components/BookList.js
Archivo normal
178
src/components/BookList.js
Archivo normal
@@ -0,0 +1,178 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FaBook, FaSearch, FaChevronLeft, FaChevronRight } from 'react-icons/fa';
|
||||
import '../styles/BookList.css';
|
||||
|
||||
/**
|
||||
* Componente para mostrar y buscar libros en el catálogo
|
||||
*/
|
||||
const BookList = ({ books, onBookSelect, isLoading }) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
// Filtrar libros según el término de búsqueda
|
||||
const filteredBooks = books.filter((book) =>
|
||||
book.title.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// Calcular paginación
|
||||
const totalPages = Math.ceil(filteredBooks.length / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const currentBooks = filteredBooks.slice(startIndex, endIndex);
|
||||
|
||||
// Resetear a la primera página cuando cambia la búsqueda o items por página
|
||||
const handleSearchChange = (value) => {
|
||||
setSearchTerm(value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleItemsPerPageChange = (value) => {
|
||||
setItemsPerPage(Number(value));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const goToPage = (page) => {
|
||||
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="book-list-container">
|
||||
<div className="loading">
|
||||
<div className="spinner"></div>
|
||||
<p>Cargando catálogo desde IPFS...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="book-list-container">
|
||||
<div className="search-box" role="search">
|
||||
<label htmlFor="search-input" className="visually-hidden">Buscar libros por título</label>
|
||||
<FaSearch className="search-icon" aria-hidden="true" />
|
||||
<input
|
||||
id="search-input"
|
||||
type="search"
|
||||
placeholder="Buscar libros por título..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className="search-input"
|
||||
aria-label="Buscar libros"
|
||||
aria-describedby="search-results-count"
|
||||
aria-controls="book-list"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
className="clear-search"
|
||||
onClick={() => handleSearchChange('')}
|
||||
aria-label="Limpiar búsqueda"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="controls-bar">
|
||||
<div className="book-count" id="search-results-count" role="status" aria-live="polite" aria-atomic="true">
|
||||
{filteredBooks.length} {filteredBooks.length === 1 ? 'libro encontrado' : 'libros encontrados'}
|
||||
</div>
|
||||
|
||||
<div className="items-per-page">
|
||||
<label htmlFor="items-per-page">Mostrar:</label>
|
||||
<select
|
||||
id="items-per-page"
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => handleItemsPerPageChange(e.target.value)}
|
||||
className="items-select"
|
||||
aria-label="Libros por página"
|
||||
aria-describedby="search-results-count"
|
||||
aria-controls="book-list"
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
<span>por página</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="book-list" id="book-list" role="list" aria-label="Lista de libros">
|
||||
{filteredBooks.length === 0 ? (
|
||||
<li className="no-results" role="status">
|
||||
<FaBook className="no-results-icon" aria-hidden="true" />
|
||||
<p>No se encontraron libros</p>
|
||||
<small>Intenta con otros términos de búsqueda</small>
|
||||
</li>
|
||||
) : (
|
||||
currentBooks.map((book, index) => (
|
||||
<li key={book.id} className="book-list-item" role="listitem">
|
||||
<a
|
||||
href={`?epub=${book.ipfsHash}&gateway=${encodeURIComponent(window.location.origin)}`}
|
||||
className="book-item"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onBookSelect(book);
|
||||
}}
|
||||
aria-label={`Abrir libro: ${book.title}`}
|
||||
>
|
||||
<FaBook className="book-icon" aria-hidden="true" />
|
||||
<div className="book-info">
|
||||
<h3 className="book-title">{book.title}</h3>
|
||||
<p className="book-filename" aria-label={`Archivo: ${book.filename}`}>
|
||||
<small>{book.filename}</small>
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<nav className="pagination" role="navigation" aria-label="Paginación de libros">
|
||||
<button
|
||||
className="pagination-button"
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
aria-label="Ir a página anterior"
|
||||
aria-disabled={currentPage === 1}
|
||||
>
|
||||
<FaChevronLeft aria-hidden="true" />
|
||||
<span className="visually-hidden">Anterior</span>
|
||||
</button>
|
||||
|
||||
<div className="pagination-info">
|
||||
<label htmlFor="page-input" className="visually-hidden">Ir a página número</label>
|
||||
<span aria-hidden="true">Página </span>
|
||||
<input
|
||||
id="page-input"
|
||||
type="number"
|
||||
min="1"
|
||||
max={totalPages}
|
||||
value={currentPage}
|
||||
onChange={(e) => goToPage(Number(e.target.value))}
|
||||
className="page-input"
|
||||
aria-label={`Página actual ${currentPage} de ${totalPages}`}
|
||||
/>
|
||||
<span aria-hidden="true"> de {totalPages}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="pagination-button"
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
aria-label="Ir a página siguiente"
|
||||
aria-disabled={currentPage === totalPages}
|
||||
>
|
||||
<FaChevronRight aria-hidden="true" />
|
||||
<span className="visually-hidden">Siguiente</span>
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookList;
|
||||
443
src/components/EpubViewer.js
Archivo normal
443
src/components/EpubViewer.js
Archivo normal
@@ -0,0 +1,443 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import ePub from 'epubjs';
|
||||
import {
|
||||
FaArrowLeft,
|
||||
FaArrowRight,
|
||||
FaSearchPlus,
|
||||
FaSearchMinus,
|
||||
FaTimes,
|
||||
FaList,
|
||||
FaCog,
|
||||
FaShare,
|
||||
FaDownload
|
||||
} from 'react-icons/fa';
|
||||
import { setCleaningUp } from '../setupErrorHandler';
|
||||
import '../styles/EpubViewer.css';
|
||||
|
||||
/**
|
||||
* Componente visor de EPUB con controles de navegación y accesibilidad
|
||||
*/
|
||||
const EpubViewer = ({ epubUrl, bookTitle, bookFilename, onClose, onShare }) => {
|
||||
const viewerRef = useRef(null);
|
||||
const renditionRef = useRef(null);
|
||||
const bookRef = useRef(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [currentLocation, setCurrentLocation] = useState(null);
|
||||
const [totalSections, setTotalSections] = useState(0);
|
||||
const [fontSize, setFontSize] = useState(100);
|
||||
const [showToc, setShowToc] = useState(false);
|
||||
const [toc, setToc] = useState([]);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [theme, setTheme] = useState('light');
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const response = await fetch(epubUrl);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = bookFilename || `${bookTitle}.epub`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (err) {
|
||||
console.error('Error descargando el archivo:', err);
|
||||
alert('Error al descargar el archivo. Intenta de nuevo.');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!epubUrl || !viewerRef.current) return;
|
||||
|
||||
// Deshabilitar ResizeObserver para prevenir errores infinitos
|
||||
const disableResizeObserver = () => {
|
||||
if (window.ResizeObserver) {
|
||||
window.ResizeObserver = class NoOp {
|
||||
constructor() {}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
}
|
||||
};
|
||||
disableResizeObserver();
|
||||
|
||||
const loadBook = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Descargar el EPUB desde IPFS como blob
|
||||
const response = await fetch(epubUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error al descargar el EPUB: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
// Crear instancia del libro desde el blob
|
||||
const book = ePub(blob);
|
||||
bookRef.current = book;
|
||||
|
||||
// Renderizar el libro en modo scroll continuo
|
||||
const rendition = book.renderTo(viewerRef.current, {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
flow: 'scrolled-doc',
|
||||
allowScriptedContent: true,
|
||||
});
|
||||
|
||||
renditionRef.current = rendition;
|
||||
|
||||
// Mostrar el libro
|
||||
await rendition.display();
|
||||
|
||||
// Cargar tabla de contenidos y obtener total de secciones
|
||||
const navigation = await book.loaded.navigation;
|
||||
setToc(navigation.toc);
|
||||
|
||||
// Obtener el total de secciones del spine
|
||||
const spine = book.spine;
|
||||
const total = spine.length;
|
||||
setTotalSections(total);
|
||||
|
||||
// Aplicar tema
|
||||
applyTheme(rendition, theme);
|
||||
|
||||
// Actualizar ubicación actual - mejor manejo del evento
|
||||
rendition.on('relocated', (location) => {
|
||||
if (!isMountedRef.current) return;
|
||||
setCurrentLocation(location);
|
||||
});
|
||||
|
||||
// También escuchar el evento de renderizado
|
||||
rendition.on('rendered', (section) => {
|
||||
if (!isMountedRef.current) return;
|
||||
const location = rendition.currentLocation();
|
||||
if (location) {
|
||||
setCurrentLocation(location);
|
||||
}
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
console.error('Error cargando EPUB:', err);
|
||||
setError('Error al cargar el libro. Intenta con otro gateway IPFS.');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadBook();
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
setCleaningUp(true);
|
||||
|
||||
try {
|
||||
// Eliminar todos los iframes del DOM primero
|
||||
const iframes = document.querySelectorAll('iframe[src*="blob:"]');
|
||||
iframes.forEach(iframe => {
|
||||
try {
|
||||
iframe.src = 'about:blank';
|
||||
iframe.remove();
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
// Desconectar todos los listeners de rendition
|
||||
if (renditionRef.current) {
|
||||
try {
|
||||
const events = ['relocated', 'rendered', 'resized', 'displayed', 'orientationchange', 'layout'];
|
||||
events.forEach(event => {
|
||||
try {
|
||||
renditionRef.current.off(event);
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
// Destruir manager de vistas
|
||||
if (renditionRef.current.manager) {
|
||||
try {
|
||||
renditionRef.current.manager.destroy?.();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Destruir rendition
|
||||
renditionRef.current.destroy?.();
|
||||
} catch (err) {
|
||||
// Silenciar
|
||||
}
|
||||
renditionRef.current = null;
|
||||
}
|
||||
|
||||
// Destruir libro
|
||||
if (bookRef.current) {
|
||||
try {
|
||||
bookRef.current.destroy?.();
|
||||
} catch (err) {
|
||||
// Silenciar
|
||||
}
|
||||
bookRef.current = null;
|
||||
}
|
||||
|
||||
// Limpiar el contenedor
|
||||
if (viewerRef.current) {
|
||||
viewerRef.current.innerHTML = '';
|
||||
viewerRef.current.remove();
|
||||
}
|
||||
} catch (e) {
|
||||
// Silenciar cualquier error
|
||||
} finally {
|
||||
setCleaningUp(false);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [epubUrl]);
|
||||
|
||||
// Aplicar tema al libro
|
||||
useEffect(() => {
|
||||
if (renditionRef.current) {
|
||||
applyTheme(renditionRef.current, theme);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [theme]);
|
||||
|
||||
// Aplicar tamaño de fuente
|
||||
useEffect(() => {
|
||||
if (renditionRef.current) {
|
||||
renditionRef.current.themes.fontSize(`${fontSize}%`);
|
||||
}
|
||||
}, [fontSize]);
|
||||
|
||||
const applyTheme = (rendition, selectedTheme) => {
|
||||
if (selectedTheme === 'dark') {
|
||||
rendition.themes.override('color', '#e0e0e0');
|
||||
rendition.themes.override('background', '#1a1a1a');
|
||||
} else if (selectedTheme === 'sepia') {
|
||||
rendition.themes.override('color', '#5b4636');
|
||||
rendition.themes.override('background', '#f4ecd8');
|
||||
} else {
|
||||
rendition.themes.override('color', '#000');
|
||||
rendition.themes.override('background', '#fff');
|
||||
}
|
||||
};
|
||||
|
||||
const nextPage = () => {
|
||||
if (renditionRef.current) {
|
||||
renditionRef.current.next();
|
||||
// Actualizar ubicación manualmente
|
||||
setTimeout(() => {
|
||||
const location = renditionRef.current.currentLocation();
|
||||
if (location) {
|
||||
setCurrentLocation(location);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
const prevPage = () => {
|
||||
if (renditionRef.current) {
|
||||
renditionRef.current.prev();
|
||||
// Actualizar ubicación manualmente
|
||||
setTimeout(() => {
|
||||
const location = renditionRef.current.currentLocation();
|
||||
if (location) {
|
||||
setCurrentLocation(location);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
const increaseFontSize = () => {
|
||||
setFontSize((prev) => Math.min(prev + 10, 200));
|
||||
};
|
||||
|
||||
const decreaseFontSize = () => {
|
||||
setFontSize((prev) => Math.max(prev - 10, 50));
|
||||
};
|
||||
|
||||
const goToChapter = (href) => {
|
||||
if (renditionRef.current) {
|
||||
renditionRef.current.display(href);
|
||||
setShowToc(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="epub-viewer">
|
||||
<div className="viewer-header">
|
||||
<h2>{bookTitle}</h2>
|
||||
<button onClick={onClose} className="close-button" aria-label="Cerrar visor">
|
||||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
<div className="error-message">
|
||||
<p>{error}</p>
|
||||
<button onClick={onClose} className="btn-primary">
|
||||
Volver al catálogo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="epub-viewer">
|
||||
<div className="viewer-header">
|
||||
<div className="viewer-title">
|
||||
<h2>{bookTitle}</h2>
|
||||
</div>
|
||||
<div className="viewer-controls">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="control-button"
|
||||
aria-label="Descargar libro"
|
||||
title="Descargar EPUB"
|
||||
>
|
||||
<FaDownload />
|
||||
</button>
|
||||
<button
|
||||
onClick={onShare}
|
||||
className="control-button"
|
||||
aria-label="Compartir libro"
|
||||
title="Compartir"
|
||||
>
|
||||
<FaShare />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowToc(!showToc)}
|
||||
className="control-button"
|
||||
aria-label="Tabla de contenidos"
|
||||
title="Tabla de contenidos"
|
||||
>
|
||||
<FaList />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
className="control-button"
|
||||
aria-label="Configuración"
|
||||
title="Configuración"
|
||||
>
|
||||
<FaCog />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="close-button"
|
||||
aria-label="Cerrar visor"
|
||||
title="Cerrar"
|
||||
>
|
||||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showToc && toc.length > 0 && (
|
||||
<div className="toc-panel">
|
||||
<h3>Tabla de Contenidos</h3>
|
||||
<ul className="toc-list">
|
||||
{toc.map((item, index) => (
|
||||
<li key={index}>
|
||||
<button
|
||||
onClick={() => goToChapter(item.href)}
|
||||
className="toc-item"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSettings && (
|
||||
<div className="settings-panel">
|
||||
<h3>Configuración</h3>
|
||||
|
||||
<div className="setting-group">
|
||||
<label>Tamaño de fuente: {fontSize}%</label>
|
||||
<div className="font-controls">
|
||||
<button onClick={decreaseFontSize} aria-label="Reducir fuente">
|
||||
<FaSearchMinus />
|
||||
</button>
|
||||
<button onClick={increaseFontSize} aria-label="Aumentar fuente">
|
||||
<FaSearchPlus />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label>Tema:</label>
|
||||
<div className="theme-buttons">
|
||||
<button
|
||||
className={`theme-btn ${theme === 'light' ? 'active' : ''}`}
|
||||
onClick={() => setTheme('light')}
|
||||
>
|
||||
Claro
|
||||
</button>
|
||||
<button
|
||||
className={`theme-btn ${theme === 'sepia' ? 'active' : ''}`}
|
||||
onClick={() => setTheme('sepia')}
|
||||
>
|
||||
Sepia
|
||||
</button>
|
||||
<button
|
||||
className={`theme-btn ${theme === 'dark' ? 'active' : ''}`}
|
||||
onClick={() => setTheme('dark')}
|
||||
>
|
||||
Oscuro
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="viewer-content">
|
||||
{isLoading && (
|
||||
<div className="loading-overlay">
|
||||
<div className="spinner"></div>
|
||||
<p>Cargando libro desde IPFS...</p>
|
||||
</div>
|
||||
)}
|
||||
<div ref={viewerRef} className="epub-container" />
|
||||
</div>
|
||||
|
||||
<div className="viewer-footer">
|
||||
<button
|
||||
onClick={prevPage}
|
||||
className="nav-button"
|
||||
aria-label="Página anterior"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<FaArrowLeft /> Anterior
|
||||
</button>
|
||||
|
||||
<div className="page-info">
|
||||
{currentLocation && currentLocation.start && totalSections > 0 ? (
|
||||
<span>
|
||||
Sección {currentLocation.start.index + 1} de {totalSections} ({Math.round(((currentLocation.start.index + 1) / totalSections) * 100)}%)
|
||||
</span>
|
||||
) : (
|
||||
<span>Iniciando lectura...</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={nextPage}
|
||||
className="nav-button"
|
||||
aria-label="Página siguiente"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Siguiente <FaArrowRight />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EpubViewer;
|
||||
73
src/components/ErrorBoundary.js
Archivo normal
73
src/components/ErrorBoundary.js
Archivo normal
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
// Filtrar errores de epub.js
|
||||
const msg = error?.message || error?.toString() || '';
|
||||
if (msg.includes('getComputedStyle') ||
|
||||
msg.includes('resizeCheck') ||
|
||||
(msg.includes('width') && msg.includes('null'))) {
|
||||
// No actualizar el estado para estos errores
|
||||
return null;
|
||||
}
|
||||
|
||||
// Para otros errores, mostrar UI de error
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
const msg = error?.message || error?.toString() || '';
|
||||
|
||||
// Silenciar errores de epub.js
|
||||
if (msg.includes('getComputedStyle') ||
|
||||
msg.includes('resizeCheck') ||
|
||||
(msg.includes('width') && msg.includes('null'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Error capturado por ErrorBoundary:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
padding: '2rem',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<h1>Algo salió mal</h1>
|
||||
<p>Ocurrió un error inesperado. Por favor, recarga la página.</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '0.75rem 1.5rem',
|
||||
background: '#667eea',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem'
|
||||
}}
|
||||
>
|
||||
Recargar página
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
32
src/components/GatewaySelector.js
Archivo normal
32
src/components/GatewaySelector.js
Archivo normal
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { FaNetworkWired } from 'react-icons/fa';
|
||||
import '../styles/GatewaySelector.css';
|
||||
|
||||
/**
|
||||
* Componente para seleccionar el gateway IPFS
|
||||
*/
|
||||
const GatewaySelector = ({ gateways, selectedGateway, onGatewayChange }) => {
|
||||
return (
|
||||
<div className="gateway-selector">
|
||||
<label htmlFor="gateway-select" className="gateway-label">
|
||||
<FaNetworkWired className="gateway-icon" />
|
||||
<span>Gateway IPFS:</span>
|
||||
</label>
|
||||
<select
|
||||
id="gateway-select"
|
||||
value={selectedGateway}
|
||||
onChange={(e) => onGatewayChange(e.target.value)}
|
||||
className="gateway-select"
|
||||
aria-label="Seleccionar gateway IPFS"
|
||||
>
|
||||
{gateways.map((gateway, index) => (
|
||||
<option key={index} value={gateway.url}>
|
||||
{gateway.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GatewaySelector;
|
||||
@@ -3,11 +3,15 @@ import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import './setupErrorHandler';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
|
||||
119
src/setupErrorHandler.js
Archivo normal
119
src/setupErrorHandler.js
Archivo normal
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Manejador global de errores para prevenir que epub.js lance excepciones
|
||||
* cuando los iframes son destruidos
|
||||
*/
|
||||
|
||||
// Flag para rastrear si estamos en proceso de limpieza
|
||||
let isCleaningUp = false;
|
||||
|
||||
// Función auxiliar para detectar errores de epub.js
|
||||
const isEpubError = (msg) => {
|
||||
const str = msg?.toString() || '';
|
||||
return str.includes('getComputedStyle') ||
|
||||
str.includes('resizeCheck') ||
|
||||
str.includes('_locations') ||
|
||||
str.includes('_display') ||
|
||||
str.includes('width') ||
|
||||
str.includes('iframe') ||
|
||||
str.includes('dequeue');
|
||||
};
|
||||
|
||||
// Guardar handlers originales
|
||||
const originalError = console.error;
|
||||
const originalWarn = console.warn;
|
||||
const originalOnError = window.onerror;
|
||||
|
||||
// Sobreescribir console.error
|
||||
console.error = (...args) => {
|
||||
if (args.length > 0 && isEpubError(args[0])) {
|
||||
return;
|
||||
}
|
||||
originalError.apply(console, args);
|
||||
};
|
||||
|
||||
// Sobreescribir console.warn
|
||||
console.warn = (...args) => {
|
||||
if (args.length > 0 && isEpubError(args[0])) {
|
||||
return;
|
||||
}
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
|
||||
// Capturar errores globales
|
||||
window.onerror = function(message, source, lineno, colno, error) {
|
||||
const isEpub = isEpubError(message) || isEpubError(error?.message) || isEpubError(error?.toString());
|
||||
|
||||
if (isEpub) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (originalOnError) {
|
||||
return originalOnError(message, source, lineno, colno, error);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Capturar rechazos de promesas
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const msg = event.reason?.message || event.reason?.toString() || '';
|
||||
if (isEpubError(msg) || isEpubError(event.reason)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Parchar getComputedStyle para retornar objeto seguro
|
||||
const originalGetComputedStyle = window.getComputedStyle;
|
||||
window.getComputedStyle = function(element, pseudo) {
|
||||
try {
|
||||
if (!element || !element.ownerDocument) {
|
||||
return {
|
||||
width: '0px',
|
||||
height: '0px',
|
||||
display: 'none'
|
||||
};
|
||||
}
|
||||
return originalGetComputedStyle.call(window, element, pseudo);
|
||||
} catch (e) {
|
||||
return {
|
||||
width: '0px',
|
||||
height: '0px',
|
||||
display: 'none'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Interceptar Object.defineProperty para prevenir errores en setters
|
||||
const originalDefineProperty = Object.defineProperty;
|
||||
Object.defineProperty = function(obj, prop, descriptor) {
|
||||
try {
|
||||
return originalDefineProperty.call(Object, obj, prop, descriptor);
|
||||
} catch (e) {
|
||||
// Silenciar errores de definición de propiedades
|
||||
return obj;
|
||||
}
|
||||
};
|
||||
|
||||
// Parchar Reflect.get para manejar accesos fallidos
|
||||
const originalReflectGet = Reflect.get;
|
||||
if (originalReflectGet) {
|
||||
Reflect.get = function(target, prop, receiver) {
|
||||
try {
|
||||
return originalReflectGet.call(Reflect, target, prop, receiver);
|
||||
} catch (e) {
|
||||
if (prop === '_locations') return [];
|
||||
if (prop === 'length') return 0;
|
||||
if (prop === '_display') return () => {};
|
||||
if (prop === 'width') return '0px';
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Exportar la función para permitir control manual
|
||||
export const setCleaningUp = (value) => {
|
||||
isCleaningUp = value;
|
||||
};
|
||||
|
||||
export const isCleanup = () => isCleaningUp;
|
||||
|
||||
export default {};
|
||||
396
src/styles/BookList.css
Archivo normal
396
src/styles/BookList.css
Archivo normal
@@ -0,0 +1,396 @@
|
||||
.book-list-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 1rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-box:focus-within {
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
color: #667eea;
|
||||
font-size: 1.2rem;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 1rem;
|
||||
color: #333;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.clear-search {
|
||||
background: #f0f0f0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.clear-search:hover {
|
||||
background: #e0e0e0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.clear-search:focus {
|
||||
outline: 2px solid #667eea;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.book-count {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.controls-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.items-per-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.items-select {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
color: #333;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.items-select:hover {
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.items-select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.book-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding-right: 0.5rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.book-list::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.book-list::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.book-list::-webkit-scrollbar-thumb {
|
||||
background: #667eea;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.book-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #764ba2;
|
||||
}
|
||||
|
||||
.book-list-item {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.book-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
border: 2px solid transparent;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.book-item:hover {
|
||||
transform: translateX(5px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8f9ff 100%);
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.book-item:focus {
|
||||
outline: 3px solid #667eea;
|
||||
outline-offset: 2px;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.book-item:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.book-item:focus-visible {
|
||||
outline: 3px solid #667eea;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.book-icon {
|
||||
color: #667eea;
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.book-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.book-title {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.book-item:visited .book-title {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.book-filename {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
word-break: break-word;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.book-filename small {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #667eea;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid rgba(102, 126, 234, 0.2);
|
||||
border-top-color: #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.no-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #999;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.no-results-icon {
|
||||
font-size: 4rem;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.no-results p {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.no-results small {
|
||||
font-size: 0.9rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 0.5rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.pagination-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, #5568d3 0%, #6941a3 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.pagination-button:hover:not(:disabled) {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.pagination-button:focus:not(:disabled) {
|
||||
outline: 3px solid #667eea;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.pagination-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.page-input {
|
||||
width: 50px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.page-input:hover {
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.page-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
/* Ocultar flechas de input number en Chrome, Safari, Edge, Opera */
|
||||
.page-input::-webkit-outer-spin-button,
|
||||
.page-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Ocultar flechas de input number en Firefox */
|
||||
.page-input[type=number] {
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.book-item {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.book-title {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.book-filename {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.controls-bar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
}
|
||||
|
||||
.pagination-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.page-input {
|
||||
width: 45px;
|
||||
padding: 0.3rem 0.4rem;
|
||||
}
|
||||
}
|
||||
367
src/styles/EpubViewer.css
Archivo normal
367
src/styles/EpubViewer.css
Archivo normal
@@ -0,0 +1,367 @@
|
||||
.epub-viewer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.viewer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.viewer-title h2 {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.viewer-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.control-button,
|
||||
.close-button {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
color: white;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 1.1rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.control-button:hover,
|
||||
.close-button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.control-button:focus,
|
||||
.close-button:focus {
|
||||
outline: 3px solid rgba(255, 255, 255, 0.5);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.control-button:active,
|
||||
.close-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.viewer-content {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.epub-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: white;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.epub-container iframe {
|
||||
border: none;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.loading-overlay .spinner {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 5px solid rgba(102, 126, 234, 0.2);
|
||||
border-top-color: #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-overlay p {
|
||||
color: #667eea;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.viewer-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
background: white;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.nav-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toc-panel,
|
||||
.settings-panel {
|
||||
position: absolute;
|
||||
top: 70px;
|
||||
right: 1.5rem;
|
||||
width: 320px;
|
||||
max-height: 70vh;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
z-index: 20;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.toc-panel h3,
|
||||
.settings-panel h3 {
|
||||
margin: 0;
|
||||
padding: 1rem 1.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toc-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
max-height: calc(70vh - 60px);
|
||||
}
|
||||
|
||||
.toc-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.toc-list::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.toc-list::-webkit-scrollbar-thumb {
|
||||
background: #667eea;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.toc-item {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.95rem;
|
||||
color: #333;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.toc-item:hover {
|
||||
background: #f8f9ff;
|
||||
color: #667eea;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
padding: 1rem 1.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.setting-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.font-controls {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.font-controls button {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.font-controls button:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.theme-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.theme-btn {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
background: #f0f0f0;
|
||||
color: #666;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-btn:hover {
|
||||
background: #e0e0e0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.theme-btn.active {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-message p {
|
||||
color: #e74c3c;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.viewer-header {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.viewer-title h2 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.control-button,
|
||||
.close-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.toc-panel,
|
||||
.settings-panel {
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.viewer-footer {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
}
|
||||
66
src/styles/GatewaySelector.css
Archivo normal
66
src/styles/GatewaySelector.css
Archivo normal
@@ -0,0 +1,66 @@
|
||||
.gateway-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.gateway-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gateway-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.gateway-select {
|
||||
flex: 1;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.gateway-select:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.gateway-select:focus {
|
||||
outline: none;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-color: white;
|
||||
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.gateway-select option {
|
||||
background: #1a1a2e;
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.gateway-selector {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.gateway-label {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
95
src/utils/ipfsParser.js
Archivo normal
95
src/utils/ipfsParser.js
Archivo normal
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Utilidad para parsear el catálogo HTML de IPFS y extraer información de libros EPUB
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extrae la información de los libros EPUB del HTML de IPFS
|
||||
* @param {string} htmlContent - Contenido HTML del catálogo IPFS
|
||||
* @returns {Array} Array de objetos con información de los libros
|
||||
*/
|
||||
export const parseIPFSCatalog = (htmlContent) => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlContent, 'text/html');
|
||||
const books = [];
|
||||
const seenHashes = new Set();
|
||||
|
||||
// Buscar todos los enlaces que terminen en .epub y NO sean de clase ipfs-hash (los acortados)
|
||||
const links = doc.querySelectorAll('a[href*=".epub"]:not(.ipfs-hash)');
|
||||
|
||||
links.forEach(link => {
|
||||
const href = link.getAttribute('href');
|
||||
if (!href || !href.endsWith('.epub')) return;
|
||||
|
||||
// Extraer el nombre del archivo (ya viene decodificado en el textContent)
|
||||
const filename = link.textContent.trim();
|
||||
|
||||
// Buscar el hash IPFS completo del archivo individual
|
||||
// La estructura es divs, no una tabla, así que buscamos el siguiente enlace con clase ipfs-hash
|
||||
let ipfsHash = '';
|
||||
|
||||
// Buscar en el contenedor padre y luego en el siguiente elemento
|
||||
const parentDiv = link.closest('div');
|
||||
if (parentDiv) {
|
||||
// Buscar en los siguientes elementos hermanos
|
||||
let nextElement = parentDiv.nextElementSibling;
|
||||
while (nextElement && !ipfsHash) {
|
||||
const hashLink = nextElement.querySelector('a.ipfs-hash');
|
||||
if (hashLink) {
|
||||
const hashHref = hashLink.getAttribute('href');
|
||||
const match = hashHref.match(/\/ipfs\/([^?]+)/);
|
||||
if (match) {
|
||||
ipfsHash = match[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
nextElement = nextElement.nextElementSibling;
|
||||
}
|
||||
}
|
||||
|
||||
// Si no encontramos el hash IPFS individual, no agregamos el libro
|
||||
if (!ipfsHash) return;
|
||||
|
||||
// Evitar duplicados usando el hash IPFS
|
||||
if (seenHashes.has(ipfsHash)) return;
|
||||
seenHashes.add(ipfsHash);
|
||||
|
||||
// Extraer información del nombre del archivo
|
||||
const titleMatch = filename.match(/^(.+?)\s*\[(\d+)\]/);
|
||||
const title = titleMatch ? titleMatch[1].trim() : filename.replace('.epub', '');
|
||||
|
||||
books.push({
|
||||
id: ipfsHash,
|
||||
filename,
|
||||
title,
|
||||
ipfsHash,
|
||||
href,
|
||||
});
|
||||
});
|
||||
|
||||
return books;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lista de gateways IPFS públicos
|
||||
*/
|
||||
export const IPFS_GATEWAYS = [
|
||||
{ name: 'EU Orbitor', url: 'https://eu.orbitor.dev' },
|
||||
{ name: 'IPFS Orbitor', url: 'https://ipfs.orbitor.dev' },
|
||||
{ name: 'IPFS.io', url: 'https://ipfs.io' },
|
||||
{ name: 'Dweb.link', url: 'https://dweb.link' },
|
||||
{ name: 'APAC Orbitor', url: 'https://apac.orbitor.dev' },
|
||||
{ name: 'Storry.tv', url: 'https://storry.tv' },
|
||||
{ name: 'LATAM Orbitor', url: 'https://latam.orbitor.dev' },
|
||||
{ name: 'Pinata Cloud', url: 'https://gateway.pinata.cloud' },
|
||||
{ name: 'DGet', url: 'https://dget.top' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Construye la URL completa del EPUB basándose en el gateway seleccionado
|
||||
* @param {string} ipfsHash - Hash IPFS del archivo
|
||||
* @param {string} gatewayUrl - URL del gateway IPFS
|
||||
* @returns {string} URL completa del archivo
|
||||
*/
|
||||
export const buildEpubUrl = (ipfsHash, gatewayUrl) => {
|
||||
return `${gatewayUrl}/ipfs/${ipfsHash}`;
|
||||
};
|
||||
Referencia en una nueva incidencia
Block a user