initial commit

Signed-off-by: ale <ale@manalejandro.com>
Este commit está contenido en:
ale
2025-11-18 21:45:12 +01:00
padre 585716d86c
commit b2bd63a47e
Se han modificado 21 ficheros con 2633 adiciones y 17660 borrados

Ver fichero

@@ -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;
}
}

Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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;

Ver fichero

@@ -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;

Ver fichero

@@ -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;

Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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}`;
};