229
README.md
229
README.md
@@ -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:
|

|
||||||
|

|
||||||
|

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