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

229
README.md
Ver fichero

@@ -1,70 +1,223 @@
# Getting Started with Create React App # 📚 La Biblioteca
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). **Visor de documentos EPUB accesibles desde IPFS**
## Available Scripts Una aplicación web moderna para explorar y leer libros en formato EPUB almacenados en la red descentralizada IPFS. Desarrollada con React, ofrece una experiencia de lectura fluida con múltiples opciones de personalización y accesibilidad.
In the project directory, you can run: ![React](https://img.shields.io/badge/React-19.0.0-61dafb?style=flat&logo=react)
![License](https://img.shields.io/badge/license-MIT-green)
![IPFS](https://img.shields.io/badge/IPFS-enabled-65c2cb?style=flat&logo=ipfs)
### `npm start` ## ✨ Características
Runs the app in the development mode.\ ### 📖 Lectura
Open [http://localhost:3000](http://localhost:3000) to view it in your browser. - **Visor EPUB integrado** con modo de scroll continuo
- **Tabla de contenidos** navegable
- **Control de tipografía** - ajusta el tamaño de fuente (50% - 200%)
- **Temas de lectura** - Claro, Sepia y Oscuro
- **Indicador de progreso** - muestra tu avance en tiempo real
- **Navegación intuitiva** - botones de anterior/siguiente página
The page will reload when you make changes.\ ### 🔍 Exploración
You may also see any lint errors in the console. - **Buscador en tiempo real** - filtra por título instantáneamente
- **Paginación configurable** - 10, 50 o 100 libros por página
- **Catálogo completo** - acceso a toda la biblioteca IPFS
- **Selector de Gateway** - 9 gateways IPFS optimizados por velocidad
### `npm test` ### 🌐 Compartir y Descargar
- **Enlaces directos** - comparte libros específicos con un solo clic
- **Descarga EPUB** - guarda libros localmente con su nombre original
- **URLs amigables** - enlaces permanentes a través de IPFS
- **Múltiples gateways** - resiliencia y velocidad
Launches the test runner in the interactive watch mode.\ ### ♿ Accesibilidad
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. - **WCAG 2.1 AA** - cumplimiento de estándares de accesibilidad
- **Navegación por teclado** - control completo sin ratón
- **Lectores de pantalla** - soporte ARIA completo
- **Skip links** - navegación rápida al contenido principal
- **Alto contraste** - mejores colores para legibilidad
- **Elementos semánticos** - HTML5 semántico correcto
### `npm run build` ## 🚀 Inicio Rápido
Builds the app for production to the `build` folder.\ ### Prerequisitos
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\ - Node.js 16 o superior
Your app is ready to be deployed! - npm o yarn
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. ### Instalación
### `npm run eject` ```bash
# Clonar el repositorio
git clone https://git.manalejandro.com/ale/labiblioteca.git
cd labiblioteca
**Note: this is a one-way operation. Once you `eject`, you can't go back!** # Instalar dependencias
npm install
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. # Iniciar servidor de desarrollo
npm start
```
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own. La aplicación se abrirá automáticamente en [http://localhost:3000](http://localhost:3000)
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it. ### Compilación para Producción
## Learn More ```bash
# Generar build optimizado
npm run build
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). # La carpeta 'build' contendrá los archivos listos para desplegar
```
To learn React, check out the [React documentation](https://reactjs.org/). ## 📋 Uso
### Code Splitting ### Explorar el Catálogo
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) 1. Al abrir la aplicación, verás el catálogo completo de libros
2. Usa el **buscador** para filtrar por título
3. Ajusta la **paginación** según tus preferencias
4. Cambia el **gateway IPFS** si experimentas lentitud
### Analyzing the Bundle Size ### Leer un Libro
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) 1. Haz clic en cualquier libro del catálogo
2. El visor se abrirá con el libro cargado
3. Usa los controles de la cabecera para:
- 📥 **Descargar** el EPUB
- 🔗 **Compartir** el enlace directo
- 📑 **Ver la tabla de contenidos**
- ⚙️ **Ajustar configuración** (tema, tamaño de fuente)
4. Navega con los botones de anterior/siguiente o scroll
### Making a Progressive Web App ### Compartir un Libro
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) 1. Abre el libro que quieres compartir
2. Haz clic en el botón de compartir (🔗)
3. El enlace se copiará automáticamente al portapapeles
4. Compártelo - cualquiera con el enlace podrá leer el libro
### Advanced Configuration ## 🏗️ Arquitectura del Proyecto
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) ```
labiblioteca/
├── public/
│ ├── index.html # HTML principal con parches de accesibilidad
│ └── manifest.json # Configuración PWA
├── src/
│ ├── components/
│ │ ├── BookList.js # Lista y búsqueda de libros
│ │ ├── EpubViewer.js # Visor de EPUB
│ │ ├── ErrorBoundary.js # Captura de errores React
│ │ └── GatewaySelector.js # Selector de gateway IPFS
│ ├── styles/
│ │ ├── BookList.css
│ │ ├── EpubViewer.css
│ │ └── GatewaySelector.css
│ ├── utils/
│ │ └── ipfsParser.js # Parser del catálogo IPFS
│ ├── App.js # Componente principal
│ ├── App.css # Estilos globales
│ ├── index.js # Punto de entrada
│ └── setupErrorHandler.js # Manejo global de errores
├── package.json
└── README.md
```
### Deployment ## 🛠️ Tecnologías Utilizadas
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) - **React 19.0.0** - Framework de UI
- **epubjs** - Renderizado de archivos EPUB
- **react-icons** - Iconos de interfaz
- **IPFS** - Almacenamiento descentralizado
- **Create React App** - Configuración y build
### `npm run build` fails to minify ## 🌍 Gateways IPFS
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) La aplicación soporta múltiples gateways IPFS para garantizar disponibilidad y velocidad:
1. EU Orbitor (Europa)
2. IPFS Orbitor (Global)
3. IPFS.io (Oficial)
4. Cloudflare IPFS (CDN)
5. Dweb.link (Oficial)
6. Pinata (Pinning service)
7. 4everland (Web3)
8. Gateway.pinata.cloud
9. NFT.storage
## 🔧 Configuración Avanzada
### Cambiar el Catálogo IPFS
Edita `src/utils/ipfsParser.js` y modifica la constante `IPFS_CATALOG_HASH`:
```javascript
const IPFS_CATALOG_HASH = 'QmTuHashDelCatalogo';
```
### Agregar Nuevos Gateways
En `src/utils/ipfsParser.js`, agrega nuevos gateways al array `IPFS_GATEWAYS`:
```javascript
export const IPFS_GATEWAYS = [
// ... gateways existentes
{ name: 'Mi Gateway', url: 'https://mi-gateway.com' }
];
```
## 🐛 Solución de Problemas
### Los libros no cargan
- Prueba cambiando el gateway IPFS
- Verifica tu conexión a internet
- Algunos gateways pueden estar temporalmente caídos
### Errores en el visor EPUB
- Los errores de ResizeObserver son normales y están silenciados
- Si el libro no se renderiza, intenta recargarlo
- Algunos EPUBs pueden tener formato incompatible
### El catálogo está vacío
- Verifica que el hash del catálogo IPFS sea correcto
- Prueba con diferentes gateways
- Revisa la consola del navegador para errores de red
## 🤝 Contribuciones
Las contribuciones son bienvenidas. Por favor:
1. Fork el proyecto
2. Crea una rama para tu feature (`git checkout -b feature/AmazingFeature`)
3. Commit tus cambios (`git commit -m 'Add some AmazingFeature'`)
4. Push a la rama (`git push origin feature/AmazingFeature`)
5. Abre un Pull Request
## 📄 Licencia
Este proyecto está bajo la Licencia MIT. Ver el archivo `LICENSE` para más detalles.
## 👤 Autor
**Ale** - [git.manalejandro.com](https://git.manalejandro.com/ale)
## 🔗 Enlaces
- **Repositorio**: [https://git.manalejandro.com/ale/labiblioteca](https://git.manalejandro.com/ale/labiblioteca)
- **IPFS**: [https://ipfs.io](https://ipfs.io)
- **React**: [https://react.dev](https://react.dev)
- **Epub.js**: [https://github.com/futurepress/epub.js](https://github.com/futurepress/epub.js)
## 🙏 Agradecimientos
- A la comunidad de IPFS por la infraestructura descentralizada
- A los desarrolladores de Epub.js por el excelente visor
- A todos los contribuidores de bibliotecas de código abierto utilizadas
---
**⭐ Si este proyecto te resulta útil, considera darle una estrella en el repositorio**

17559
package-lock.json generado

La diferencia del archivo ha sido suprimido porque es demasiado grande Cargar Diff

Ver fichero

@@ -7,8 +7,11 @@
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"epubjs": "^0.3.93",
"jszip": "^3.10.1",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-icons": "^5.5.0",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },

Archivo binario no mostrado.

Antes

Anchura:  |  Altura:  |  Tamaño: 3.8 KiB

Después

Anchura:  |  Altura:  |  Tamaño: 15 KiB

Ver fichero

@@ -1,30 +1,234 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="es">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#667eea" />
<meta <meta
name="description" name="description"
content="Web site created using create-react-app" content="LaBiblioteca - Visor de libros EPUB desde IPFS. Lee documentos de forma accesible con múltiples gateways, búsqueda avanzada y diseño moderno."
/> />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> <meta name="keywords" content="EPUB, IPFS, libros, lector, biblioteca, descentralizado, accesible" />
<!-- <meta name="author" content="LaBiblioteca" />
manifest.json provides metadata used when your web app is installed on a <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo.png" />
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!-- <title>LaBiblioteca - Visor EPUB desde IPFS</title>
Notice the use of %PUBLIC_URL% in the tags above. <script>
It will be replaced with the URL of the `public` folder during the build. // Parche para prevenir errores de epub.js cuando los iframes son destruidos
Only files inside the `public` folder can be referenced from the HTML. // Este script corre ANTES de que se cargue cualquier otra cosa
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will // Monkey-patch ResizeObserver para prevenir loops infinitos
work correctly both with client-side routing and a non-root public URL. const originalResizeObserver = window.ResizeObserver;
Learn how to configure a non-root public URL by running `npm run build`. if (originalResizeObserver) {
--> window.ResizeObserver = class PatchedResizeObserver {
<title>React App</title> constructor(callback) {
const safeCallback = (entries, observer) => {
try {
callback(entries, observer);
} catch (e) {
// Silenciar ResizeObserver errors silently
if (e.message && !e.message.includes('ResizeObserver')) {
throw e;
}
}
};
this._observer = new originalResizeObserver(safeCallback);
}
observe(target) {
try {
this._observer.observe(target);
} catch (e) {}
}
unobserve(target) {
try {
this._observer.unobserve(target);
} catch (e) {}
}
disconnect() {
try {
this._observer.disconnect();
} catch (e) {}
}
};
}
// Crear objeto CSSStyleDeclaration seguro
const safeCSSStyleDeclaration = {
width: '0px',
height: '0px',
display: 'none',
position: 'static',
margin: '0px',
padding: '0px',
border: 'none',
boxSizing: 'content-box',
fontSize: '16px',
lineHeight: 'normal',
color: '#000000',
backgroundColor: 'transparent',
getPropertyValue: function(prop) {
return this[prop] || '0px';
},
setProperty: function() {},
removeProperty: function() {}
};
// Parchear getComputedStyle globalmente
const originalGetComputedStyle = window.getComputedStyle;
window.getComputedStyle = function(element, pseudo) {
try {
if (!element || !element.ownerDocument) {
return safeCSSStyleDeclaration;
}
const result = originalGetComputedStyle.call(window, element, pseudo);
return result || safeCSSStyleDeclaration;
} catch (e) {
return safeCSSStyleDeclaration;
}
};
// Parchear el prototype de HTMLIFrameElement para que tenga window.getComputedStyle seguro
const originalIFrameContentWindow = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'contentWindow');
Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
get: function() {
try {
const realWindow = originalIFrameContentWindow.get.call(this);
if (!realWindow) {
// Retornar un objeto window falso con getComputedStyle seguro
return {
getComputedStyle: function() {
return safeCSSStyleDeclaration;
},
document: {
defaultView: {
getComputedStyle: function() {
return safeCSSStyleDeclaration;
}
}
}
};
}
// Parchear getComputedStyle en el window real del iframe
if (realWindow && !realWindow._patched) {
realWindow._patched = true;
const originalIframeGetComputedStyle = realWindow.getComputedStyle;
realWindow.getComputedStyle = function(element, pseudo) {
try {
if (!element) {
return safeCSSStyleDeclaration;
}
const result = originalIframeGetComputedStyle.call(realWindow, element, pseudo);
return result || safeCSSStyleDeclaration;
} catch (e) {
return safeCSSStyleDeclaration;
}
};
}
return realWindow;
} catch (e) {
return {
getComputedStyle: function() {
return safeCSSStyleDeclaration;
}
};
}
}
});
// Interceptar errores antes de que lleguen a React
// Suprimir todos los errores de ResizeObserver
let errorCount = 0;
const originalError = window.onerror;
window.onerror = function(message, source, lineno, colno, error) {
const msg = (message || '').toString();
// Suprimir completamente cualquier error de ResizeObserver
if (msg.includes('ResizeObserver')) {
return true; // Retornar true previene que el error se propague
}
if (msg.includes('getComputedStyle') ||
msg.includes('width') ||
msg.includes('resizeCheck') ||
msg.includes('_locations') ||
msg.includes('dequeue')) {
return true;
}
if (originalError) {
return originalError(message, source, lineno, colno, error);
}
return false;
};
// Capturar promesas rechazadas
window.addEventListener('unhandledrejection', function(event) {
const msg = (event.reason?.message || event.reason?.toString() || '').toLowerCase();
// Suprimir cualquier error relacionado con ResizeObserver
if (msg.includes('resizeobserver') || msg.includes('undelivered notifications')) {
event.preventDefault();
return;
}
if (msg.includes('getcomputedstyle') ||
msg.includes('width') ||
msg.includes('resizecheck') ||
msg.includes('_locations') ||
msg.includes('dequeue')) {
event.preventDefault();
}
});
// Parchar console.error
const originalConsoleError = console.error;
console.error = function(...args) {
const msg = (args[0] || '').toString();
// Suprimir ResizeObserver loop errors completamente
if (msg.includes('ResizeObserver') ||
msg.includes('undelivered notifications') ||
msg.includes('getComputedStyle') ||
msg.includes('width') ||
msg.includes('resizeCheck')) {
return;
}
originalConsoleError.apply(console, args);
};
// Parchar console.warn también para ResizeObserver
const originalConsoleWarn = console.warn;
console.warn = function(...args) {
const msg = (args[0] || '').toString();
if (msg.includes('ResizeObserver') || msg.includes('undelivered notifications')) {
return;
}
originalConsoleWarn.apply(console, args);
};
</script>
<script>
// Inyectar un hook adicional para capturar errores en tiempo de ejecución
if (window.__REACT_DEVTOOLS_GLOBAL_HOOK__) {
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
const originalOnCommitFiberRoot = hook.onCommitFiberRoot;
if (originalOnCommitFiberRoot) {
hook.onCommitFiberRoot = function(...args) {
try {
return originalOnCommitFiberRoot.apply(hook, args);
} catch (e) {
if (!(e.message && e.message.includes('ResizeObserver'))) {
throw e;
}
}
};
}
}
</script>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>

BIN
public/logo.png Archivo normal

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.2 KiB

Archivo binario no mostrado.

Antes

Anchura:  |  Altura:  |  Tamaño: 5.2 KiB

Archivo binario no mostrado.

Antes

Anchura:  |  Altura:  |  Tamaño: 9.4 KiB

Ver fichero

@@ -1,6 +1,7 @@
{ {
"short_name": "React App", "short_name": "LaBiblioteca",
"name": "Create React App Sample", "name": "LaBiblioteca - Visor de EPUB desde IPFS",
"description": "Aplicación web para leer libros EPUB alojados en la red IPFS con búsqueda, múltiples gateways y diseño accesible",
"icons": [ "icons": [
{ {
"src": "favicon.ico", "src": "favicon.ico",
@@ -8,18 +9,20 @@
"type": "image/x-icon" "type": "image/x-icon"
}, },
{ {
"src": "logo192.png", "src": "logo.png",
"type": "image/png", "type": "image/png",
"sizes": "192x192" "sizes": "192x192"
}, },
{ {
"src": "logo512.png", "src": "logo.png",
"type": "image/png", "type": "image/png",
"sizes": "512x512" "sizes": "512x512"
} }
], ],
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"theme_color": "#000000", "theme_color": "#667eea",
"background_color": "#ffffff" "background_color": "#ffffff",
"orientation": "any",
"categories": ["books", "education", "productivity"]
} }

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 { .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; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
justify-content: center; }
font-size: calc(10px + 2vmin);
/* Mejorar contraste para accesibilidad */
.App-header {
background: linear-gradient(135deg, #5568d3 0%, #6941a3 100%);
color: white; color: white;
padding: 2rem 1.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
} }
.App-link { .header-content {
color: #61dafb; 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 { from {
transform: rotate(0deg); opacity: 0;
transform: translateX(-50%) translateY(-20px);
} }
to { 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'; import './App.css';
/**
* Componente principal de La Biblioteca
* Aplicación para visualizar documentos EPUB desde IPFS
*/
function App() { 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 ( return (
<div className="App"> <div className="App">
<header className="App-header"> <a href="#main-content" className="skip-link">Saltar al contenido principal</a>
<img src={logo} className="App-logo" alt="logo" /> {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> <p>
Edit <code>src/App.js</code> and save to reload. <span aria-label="Libros disponibles">{books.length} libros disponibles en IPFS</span>
</p> {' | '}
<a <a
className="App-link" href="https://ipfs.io"
href="https://reactjs.org"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label="Información sobre IPFS (abre en nueva ventana)"
> >
Learn React ¿Qué es IPFS?
</a> </a>
</header> {' | '}
<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> </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 './index.css';
import App from './App'; import App from './App';
import reportWebVitals from './reportWebVitals'; import reportWebVitals from './reportWebVitals';
import ErrorBoundary from './components/ErrorBoundary';
import './setupErrorHandler';
const root = ReactDOM.createRoot(document.getElementById('root')); const root = ReactDOM.createRoot(document.getElementById('root'));
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<ErrorBoundary>
<App /> <App />
</ErrorBoundary>
</React.StrictMode> </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}`;
};