37 Commits
Author SHA1 Message Date
ninoandClaude Opus 5.5 311b548410 Quitar el laboratorio del giroscopio
Ya sirvió para calibrar createTilt con grabaciones del iPad mini 6 y el Pixel 10: fuera la página #/gyrolab, su botón en
Ajustes y la ruta /api/gyrolab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 21:12:38 -06:00
ninoandClaude Opus 5.5 8ae3594803 Estante 3D: entradas, orden A–Z, sin conexión, pósit y giroscopio calibrado
- Estante: entradas desde el menú, la librería y la ficha; orden reciente o A–Z; foto del estante, arte de lomos y portadas
  guardados en el dispositivo; sin conexión muestra solo lo guardado.
- Notas de 40 letras sin cortar palabras, lomo curvo, sombra en las portadas de frente y lomos con logo sin el título.
- Pósit cuadrado con el capítulo y la página por la que va el usuario; paso al lector sin pantalla de carga.
- Giroscopio: integra rotationRate (sin los saltos de β/γ con el dispositivo vertical), tope suave, menos sensibilidad al
  caminar, lado en horizontal según la gravedad, suavizado por tiempo y más rango en el estante y el libro.
- Laboratorio del giroscopio en Ajustes (temporal, solo desarrollo) para grabar los sensores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 21:08:51 -06:00
nino 8b82cbbfdb Configuración de ejemplo genérica y datos internos fuera del repo 2026-11-06 09:28:26 -06:00
nino bda8800a89 Estante 3D: mueble, lomos, logos y vista previa de tomos 2026-11-06 09:28:23 -06:00
ninoandClaude Opus 5.5 66502409fe Modo libro 3D, tomos con cubiertas reales y ocultar créditos
Libro 3D (app/public/book3d.js): lector WebGL2 con mesa de madera, cubierta,
hojas que giran, cámara real y giroscopio, ahora con más recorrido alrededor
del libro.

Tomos (app/volumes.js): agrupa los capítulos en un solo libro con portada y
contraportada reales. El reparto sale de Wikipedia, MangaDex, M440 y
MangaUpdates, contrastados entre sí. Lo que ninguna fuente sitúa se estima, de
modo que solo quedan sueltos los capítulos del final, y los fuera de orden se
recolocan. Manhwa y manhua no tienen tomos.

Ocultar créditos (app/credits.js): botón por obra en la ficha. Al leer por
tomos oculta, sin borrarlas, la página cero y las dos últimas de cada capítulo
si se repiten en otros capítulos de la obra.

HANDOFF-LIBRO3D.md recoge el estado, las decisiones y las pruebas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:28:15 -06:00
ninoandClaude Opus 5.5 bb1f3ff8e4 Libros: Wikisource, EPUB propios y lectura sin conexión
Wikisource en español como segunda fuente, con el EPUB que genera ws-export
y portadas generadas con el título.

Cada usuario puede subir sus EPUB, visibles solo para él en cualquier ruta.
Los archivos manipulados (ZIP bombs, cabeceras falsas) se rechazan.

Un libro se puede guardar entero en el dispositivo para leerlo sin conexión.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:00:44 -06:00
ninoandClaude Opus 5.5 be19e35156 Libros de texto: fuente Project Gutenberg y lectura continua
Los EPUB se convierten en el servidor en una lista cerrada de bloques y el
navegador los maqueta en páginas según su pantalla. Cada página se dibuja en
un canvas y llega al lector como una imagen más, así que hoja, sombra,
hendidura y sonido no cambian.

El libro se lee de corrido: cada capítulo empieza debajo del anterior y el
progreso se sigue guardando por capítulo. El catálogo sale del OPDS de
gutenberg.org, con reintentos.

Ajustes: mostrar solo manga, solo libros o ambos, por usuario.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:02:29 -06:00
ninoandClaude Opus 5.5 5776389ebb La vista de una página funciona como un libro abierto
Entre las dos caras de una pareja la vista se recorre, con las páginas unidas
por el lomo. Entre parejas gira la hoja, con la página nueva en el reverso, la
siguiente debajo y la vista acompañando al giro. La hendidura alterna de lado
según la cara, y las páginas anchas ya no la llevan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 17:05:18 -06:00
ninoandClaude Opus 5.5 eabf82ad90 La hendidura del lomo también con el ajuste a lo ancho
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:44:40 -06:00
ninoandClaude Opus 5.5 a80391f382 Hendidura del lomo en las páginas y en la hoja que gira
En doble página va en el centro, en una página en el lado del lomo según el
sentido de lectura y en una página ancha en su centro.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:35:13 -06:00
ninoandClaude Opus 5.5 a6f01b0e54 La hoja proyecta su sombra sobre el papel, con luz de frente
La sombra sigue la forma de la hoja y su borde se ensancha con la altura. Sin
WebGL se mantiene el degradado desde el lomo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:20:11 -06:00
ninoandClaude Opus 5.5 52e9edc214 Recortes con puntos en las esquinas y captura larga en lectura vertical
En lectura vertical la zona sigue creciendo al llegar al borde y une varias
páginas en una sola imagen. El servidor admite hasta 8000 px y 64 MB, y la
galería y el visor se adaptan a los recortes largos. La animación de la hoja
dura 30 ms más.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:52:05 -06:00
ninoandClaude Opus 5.5 28aa0b4051 La hoja se dibuja con WebGL y el toque elige la animación según su altura
Una sola malla en un canvas en lugar de una capa con filtro por cara. Las
páginas se suben a la GPU en reposo; sin WebGL2 se usa la hoja de capas.
Tocar arriba, al centro o abajo levanta la hoja por esa parte, como al
arrastrarla, y el giro dura 20 ms más.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:27:56 -06:00
ninoandClaude Opus 5.5 811df85349 Más curvatura al agarrar la hoja por arriba o por abajo, con el lomo fijo
- Curvatura de 14° en el plano de la página y 10° hacia quien lee.
- La tira del lomo no se curva, así que la hoja no se separa del libro.
- Más solape entre tiras al curvarse, para que no se abran huecos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 22:38:14 -06:00
ninoandClaude Opus 5.5 ca97cca7f0 Modo manga: capítulo siguiente a la izquierda y la hoja se curva según por dónde se agarra
- En der→izq, la barra inferior del lector va como la lectura: Cap. sig. a la izquierda y Cap. ant. a la derecha.
- En móvil, la página y la barra de progreso van arriba, y cada botón de capítulo en su extremo.
- Al arrastrar con el dedo por arriba o por abajo, esa parte de la hoja va por delante y el lomo no se mueve;
  por el centro, como siempre. Sin capas nuevas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 22:28:59 -06:00
ninoandClaude Opus 5.5 53e5a0d40e La ficha del manga incluye la info de la obra, menú lateral y banners reescalados
- Ficha e Info son una sola página: banner, puntuaciones, sinopsis, obras relacionadas y recomendaciones.
- La info se guarda una vez por manga para todos los usuarios y se actualiza en segundo plano; los mangas
  seguidos la mantienen al día solos. Si una fuente falla, se conserva lo guardado.
- Menú lateral (⋯) con seguir, listas, recortes, otras fuentes, descargas y datos de la obra.
- Banners reescalados con Real-ESRGAN; si la obra no tiene, se usa un recorte del propio usuario.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:33:03 -06:00
ninoandClaude Opus 5.5 41a87bc5ad Filtro de luz azul en el lector
Capa ámbar en modo multiplicar sobre las páginas (también durante las animaciones), bajo las barras.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:58:21 -06:00
ninoandClaude Opus 5.5 09354f675e Colas por usuario y sin pantalla negra mientras carga la página siguiente
- Descargas y reescalados tienen usuarios (download_users): Colas y la biblioteca descargada muestran
  solo lo de cada uno. Un manga ya descargado por otro se lee desde el disco y entra en tu biblioteca
  al empezar a leerlo; borrar uno compartido solo lo quita de la tuya. Lo anterior se asigna a quien lo
  lee, sigue o tiene en una lista (o al primer usuario).
- Lector: si la página siguiente no ha llegado, la actual se queda con un indicador hasta que llega
  (máx. 4 s); si no, la nueva aparece con fundido y un indicador en vez de negro. Precarga de 6
  páginas por delante, ya decodificadas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:20:09 -06:00
ninoandClaude Opus 5.5 84a60a6501 La página sigue al dedo al deslizar, sonido de papel al pasar página y arreglos de la hoja en doble página
- Arrastrar con el dedo pasa la página siguiendo el toque (hoja, deslizar o fundido); al soltar termina
  de pasar o vuelve a su sitio según lo recorrido y el impulso.
- Sonido de roce de papel sintetizado en el navegador, al azar entre 6 variantes; volumen o sin sonido
  en las opciones del lector.
- Doble página hacia atrás: se precargan también las 2 páginas anteriores, el lado de las páginas nuevas
  sale del orden de lectura y, si aún no han cargado, se desliza en vez de pasar la hoja.
- Safari: sin líneas entre las tiras de la hoja (sombreado con filtro en la propia cara y más solape).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:46:56 -06:00
ninoandClaude Opus 5.5 e66b5fea95 Vista Info de la obra (AniList y MangaUpdates) y fechas de los capítulos
- Info: autores, revista y editorial, fechas, tomos, puntuaciones, rankings, anime, obras relacionadas,
  recomendaciones y enlaces. La obra se identifica por los enlaces de MangaDex o por título.
- Fechas de los capítulos: primera traducción conocida (MangaDex en cualquier idioma y MangaUpdates),
  ordenadas y con los huecos estimados (≈); se ignoran las fechas de subidas masivas de las fuentes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:32:23 -06:00
ninoandClaude Opus 5.5 29af893917 Animación de hoja de papel al pasar página y URLs versionadas para saltar la caché del proxy
- Lector por páginas: animaciones Pasar hoja (la hoja se curva al levantarla, con sombra), deslizar y
  fundido; hacia atrás la página anterior vuelve y se coloca encima.
- app.js, style.css y sw.js se sirven con ?v=<hash> para que ni el proxy ni el navegador den una copia vieja.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:41:22 -06:00
ninoandClaude Opus 5.5 86999b5725 Fuente M440, modo sin conexión (servidor sin internet y app sin servidor), cola del dispositivo en Colas y arreglo del reescalado de tiras altas
- M440 (m440.in, español): búsqueda, populares, actualizados paginados, ficha, capítulos, descargas y seguimiento. La lista de capítulos viene cifrada: la clave se saca de un script ofuscado del sitio ejecutándolo en un proceso aislado con permisos mínimos (m440-key.mjs) y se renueva sola.
- Servidor sin internet (connectivity.js): se detecta en segundos; las fuentes fallan al instante, la portada muestra lo descargado en el servidor con buscador, portadas locales de respaldo, descargas y comprobaciones en pausa y reanudación automática al volver la conexión.
- App sin conexión con el servidor: el service worker usa la copia guardada si el servidor no contesta en 3 s, las peticiones comprueban si el servidor sigue vivo en vez de quedarse colgadas, aviso arriba e inicio con lo guardado en el dispositivo; al volver, se reconecta sola y envía el progreso pendiente.
- Colas: sección «En este dispositivo» con el guardado en curso en directo y cancelación (sin dejar capítulos a medias); también visible sin servidor.
- Imágenes: el proxy y el guardado en el dispositivo reintentan con tiempo límite cuando el CDN de la fuente falla.
- Reescalado HD: las páginas que superan 16383 px al reescalar (tiras de webtoon) se reducen al límite de WebP en vez de hacer fallar el capítulo.
- Entidades HTML con nombre en las sinopsis; pestañas de fuentes cortas en móvil; portadas rotas con icono de respaldo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 09:52:19 -06:00
ninoandClaude Opus 5.5 d3e196a2f3 Librería con listas, recomendaciones, descarga anticipada, app instalable con lectura offline y seguridad para exponerlo a internet
- Historial pasa a Librería: pestañas Leyendo, Siguiendo, Terminados, En el dispositivo y listas propias (Favoritos, Pendientes, Abandonados por defecto)
- Recomendaciones en el inicio según tus géneros más leídos (populares de MangaDex)
- Descarga anticipada de los N capítulos siguientes (opcionalmente en HD) al pasar la mitad de uno
- PWA: manifest, iconos y service worker; capítulos guardados en el dispositivo para leer sin conexión, progreso offline sincronizado después
- Seguridad: baneo de IP tras 6 intentos fallidos (configurable), IP real detrás de proxies de confianza, CSP y cabeceras, cookie Secure/HSTS en HTTPS, login sin diferencias de tiempo, registro cerrado por defecto, contraseñas de 8+, protección SSRF en el proxy de imágenes, errores 500 genéricos
- Ajustes: sección Seguridad con IPs bloqueadas, intentos recientes y la IP vista por el servidor
- LeerCapitulo: reintentos ante caídas (522/5xx), copia anterior si sigue caído y mensaje claro

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 07:52:13 -06:00
ninoandClaude Opus 5.5 9086896a89 Portadas HD de lo seguido o leído; sección Colas con descargas, reescalado y biblioteca en tarjetas
- Portadas: se reescalan (×4/×2) solo las de mangas en historial o seguidos; caché en data/covers/.
- Colas (antes Descargas): cola de descargas y de reescalado HD con progreso, tiempo restante,
  reintentar/detener, recortes y portadas pendientes; biblioteca descargada en tarjetas verticales.
- Refresco de Colas sin parpadeo: solo se actualiza lo que cambia.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:31:18 -06:00
ninoandClaude Opus 5.5 36ff53cad4 Historial en tarjetas verticales; «Dejar de leer» vuelve atrás o al inicio
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:48:01 -06:00
ninoandClaude Opus 5.5 5d82a5e020 Recortes de paneles desde el lector, reescalados aparte, con galería y acceso directo a la página
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:38:12 -06:00
ninoandClaude Opus 5.5 d48378aa49 Vertical: páginas pegadas, sin separación entre ellas
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 12:54:52 -06:00
ninoandClaude Opus 5.5 aabb87f6c1 Descarga de capítulos sueltos: rango o selección en la lista
Las descargas tienen modo 'all' (manga entero, también lo nuevo) o 'some' (solo
los capítulos pedidos, en download_wanted). El selector de capítulos se comparte
con el reescalado HD.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:24:38 -06:00
ninoandClaude Opus 5.5 bc84a9f6fe Reescalado HD, ficha reorganizada y mejoras del lector
- Reescalado HD con Real-ESRGAN (ncnn-vulkan, GPU por /dev/dri): cola persistente,
  todo lo pendiente, un rango o capítulos elegidos en la lista, con estimación de tiempo.
  La versión HD va a <carpeta>/.hd/ sin tocar el original; marca HD en la lista de
  capítulos y casilla "Versión HD" en el lector.
- Imagen Debian slim (glibc + Mesa Vulkan + cwebp); compose con /dev/dri y RENDER_GID.
- Ficha: acciones principales + menú ⋯, panel "Biblioteca local" (copia local y HD),
  idioma junto a la lista de capítulos.
- Lector: la barra de progreso avanza de derecha a izquierda en der → izq; botón rápido
  páginas ⇄ vertical; en vertical "Ajustar alto" y "Separar páginas" para mangas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:51:29 -06:00
ninoandClaude Opus 5.5 9e9139f43a Ocultar de «Continuar leyendo» los mangas terminados salvo que haya capítulos nuevos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:31:20 -06:00
ninoandClaude Opus 5.5 af90d34c95 Docker autónomo: configuración del host en .env e imagen fijada
- Puertos, carpeta de mangas, PUID/PGID, URL pública y registro se
  configuran en .env (plantilla en .env.example); sin .env arranca con
  valores portables (0.0.0.0:3352, ./mangas).
- Publicar en 0.0.0.0 evita fallar al arrancar si la IP de la LAN o de
  Tailscale aún no existe; el acceso lo siguen limitando las reglas
  DOCKER-USER del host.
- Imagen base fijada a node:24.22.2-alpine3.24 (node:sqlite es experimental).
- ALLOW_SIGNUP=false por defecto (el primer usuario puede registrarse igual).
- Comprobación de permisos al arrancar con mensaje claro (chown / PUID).
- README: qué guardar y cómo mover el servicio a otro servidor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:59:55 -06:00
ninoandClaude Opus 5.5 73fc1d5849 Novedades, ajustes del lector por manga, doble página, zoom y estadísticas
- Seguimiento de mangas (automático al empezar a leer o con "Seguir"):
  comprobación cada CHECK_INTERVAL_HOURS, "N caps. nuevos" en el inicio,
  marcas NUEVO en la ficha, autodescarga si hay copia local y avisos por
  ntfy o Telegram configurables en Ajustes.
- Modo, ajuste y dirección del lector guardados por manga en el servidor,
  con valores por defecto según el tipo (manhwa/manhua vertical, manga der→izq).
- Modo doble página (portada y páginas apaisadas solas; vuelve a una página
  en pantallas estrechas o en vertical).
- Zoom: pellizco, doble toque en el centro, Ctrl+rueda y teclas + - 0.
- Opciones del lector en un panel desplegable.
- Página de estadísticas: rachas, capítulos/páginas por semana, calendario,
  géneros favoritos y lo más leído (con migración de las lecturas previas).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:45:19 -06:00
ninoandClaude Opus 5.5 bb25cde388 Iconos SVG en el menú y en los botones
Iconos en línea (estilo Lucide, sin dependencias) en el menú superior,
pestañas, ficha del manga, lector, historial, descargas y ajustes.
En móvil el menú muestra solo los iconos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:12:18 -06:00
ninoandClaude Opus 5.5 f294c1cdf4 Categorías, tags clicables, progreso de descarga y eliminar copia local
- Pestaña "Categorías" (géneros/temáticas de LeerCapitulo, tags de MangaDex)
  con búsqueda dentro de la categoría y "Cargar más".
- Los tags de la ficha llevan a su categoría.
- Barra de progreso de descarga con el capítulo y la página en curso.
- Eliminar la copia local (también a mitad de descarga) desde la ficha y
  desde la página Descargas, mostrando el espacio ocupado.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:04:09 -06:00
ninoandClaude Opus 5.5 fea5ec0c65 Descarga de mangas completos a /zdata/media/mangas en CBZ
- Cola de descargas en segundo plano (se reanuda tras reinicios; repetirla
  solo baja capítulos nuevos; se puede cancelar).
- <Título>/Capitulo NNNN.cbz con ComicInfo.xml + cover.jpg (Kavita/Komga/Mihon).
- Los capítulos descargados se leen desde disco; sin conexión la ficha usa
  los datos guardados.
- Página "Descargas" y marcas ⬇ en la lista de capítulos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:43:13 -06:00
ninoandClaude Opus 5.5 57a37c67c0 Botón "Dejar de leer" en la ficha del manga
Quita el manga del historial y borra el progreso de sus capítulos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:31:17 -06:00
ninoandClaude Opus 5.5 9d031d4ba3 Manga Reader: lector web de manga con historial de lectura
Basado en las fuentes de Tanko (MangaDex y LeerCapitulo), con usuarios
con contraseña, progreso por página guardado en SQLite, lector por página
o vertical, filtros de imagen y proxy de imágenes con URLs firmadas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:26:33 -06:00
5 changed files with 176 additions and 108 deletions
+24 -13
View File
@@ -3075,6 +3075,7 @@ async function viewReader(mangaId, chapterId, startPage) {
const fromShelf = shelfTrip?.mangaId === mangaId // desde el estante se lee en 3D aunque el ajuste diga otra cosa (sin guardarlo) const fromShelf = shelfTrip?.mangaId === mangaId // desde el estante se lee en 3D aunque el ajuste diga otra cosa (sin guardarlo)
const b3dWanted = () => B3D_OK && (!!settings.book3d || fromShelf) && isPaged() && settings.fit !== 'width' const b3dWanted = () => B3D_OK && (!!settings.book3d || fromShelf) && isPaged() && settings.fit !== 'width'
const b3dOn = () => b3dWanted() && !atEnd const b3dOn = () => b3dWanted() && !atEnd
if (fromShelf) document.body.dataset.handoff = b3dWanted() ? '3d' : 'flat' // la foto del estante espera al libro 3D, o no (handoffCover)
const b3dPaper = () => { const b3dPaper = () => {
const hex = book ? (BOOK_PAPERS[bookSettings().paper] || BOOK_PAPERS.light).bg : '#efeadf' const hex = book ? (BOOK_PAPERS[bookSettings().paper] || BOOK_PAPERS.light).bg : '#efeadf'
return [1, 3, 5].map(n => parseInt(hex.slice(n, n + 2), 16) / 255) return [1, 3, 5].map(n => parseInt(hex.slice(n, n + 2), 16) / 255)
@@ -3833,15 +3834,21 @@ function handoffCover(src) {
document.body.append(img) document.body.append(img)
document.body.classList.add('handing') document.body.classList.add('handing')
const t0 = performance.now() const t0 = performance.now()
let mounted = 0 // cuándo apareció el lector: si no va a haber libro 3D (modo vertical, error), no se espera más let mounted = 0 // cuándo se supo que el lector se monta sin libro 3D
const fade = () => { document.body.classList.remove('handing'); img.classList.add('out'); setTimeout(() => img.remove(), 450) } delete document.body.dataset.handoff // lo dice el lector al montarse: '3d' o 'flat'
const fade = why => { img.dataset.why = why; delete document.body.dataset.handoff; document.body.classList.remove('handing'); img.classList.add('out'); setTimeout(() => img.remove(), 450) }
const wait = () => { const wait = () => {
if (!img.isConnected) return document.body.classList.remove('handing') if (!img.isConnected) return document.body.classList.remove('handing')
const ready = document.querySelector('#b3d:not([hidden]) canvas') const ready = document.querySelector('#b3d:not([hidden]) canvas')
if (ready) return setTimeout(fade, 350) // las texturas de la tapa tardan un instante if (ready) return setTimeout(() => fade('3d'), 350) // las texturas de la tapa tardan un instante
if (!mounted && document.querySelector('.reader .rbar')) mounted = performance.now() // Montado sin libro 3D (modo vertical, ajuste a lo ancho): ya no habrá lienzo que esperar. Con él, se espera a que dibuje.
const failed = !document.querySelector('.reader') && performance.now() - t0 > 400 // el lector no llegó a montarse (error) if (!mounted && document.body.dataset.handoff === 'flat') mounted = performance.now()
if (failed || (mounted && performance.now() - mounted > 1500) || performance.now() - t0 > 30000 || !location.hash.startsWith('#/read/')) return fade() // El lector no llegó a montarse (un error en su lugar). Mientras siga el estante, la ruta aún no ha cambiado de vista.
const failed = !document.querySelector('.reader, #shelf3d') && performance.now() - t0 > 400
if (failed) return fade('error')
if (mounted && performance.now() - mounted > 600) return fade('flat')
if (performance.now() - t0 > 90000) return fade('tarde') // por si el lector se queda colgado: mientras carga, la foto sigue
if (!location.hash.startsWith('#/read/')) return fade('ruta')
setTimeout(wait, 60) setTimeout(wait, 60)
} }
// Se navega cuando la foto ya está pintada: así no asoma la pantalla de carga entre el estante y el lector. // Se navega cuando la foto ya está pintada: así no asoma la pantalla de carga entre el estante y el lector.
@@ -3913,15 +3920,19 @@ async function viewShelf(params) {
const showPeek = info => { const showPeek = info => {
if (!info) { card.classList.remove('on'); clearTimeout(card.hideTimer); card.hideTimer = setTimeout(() => { if (!card.classList.contains('on')) card.hidden = true }, 250); return } if (!info) { card.classList.remove('on'); clearTimeout(card.hideTimer); card.hideTimer = setTimeout(() => { if (!card.classList.contains('on')) card.hidden = true }, 250); return }
const { work, vol, state } = info, n = vol.chapters?.length || 0, at = n && work.chapterId ? vol.chapters.indexOf(work.chapterId) : -1 const { work, vol, state } = info, n = vol.chapters?.length || 0, at = n && work.chapterId ? vol.chapters.indexOf(work.chapterId) : -1
const facts = [vol.vol ? `Tomo ${esc(vol.vol)}` : '', n ? `${n} ${n === 1 ? 'capítulo' : 'capítulos'}` : '', vol.pages ? `${vol.pages} páginas` : ''].filter(Boolean).join(' · ') const facts = [vol.vol ? `Tomo ${esc(vol.vol)}` : '', n ? `${n} ${n === 1 ? 'capítulo' : 'capítulos'}` : ''].filter(Boolean).join(' · ')
const pct = state === 'read' ? 100 : at >= 0 ? Math.round((at / n) * 100) : 0 // Por dónde va, escrito: el capítulo de la obra (si se sabe su número) y la página.
const where = state === 'read' ? 'Leído' : state === 'reading' ? (at >= 0 ? `Vas por el capítulo ${at + 1} de ${n} de este tomo` : 'Lo estás leyendo') : 'Sin empezar' const page = work.page > 0 ? `, página ${work.page + 1}` : ''
const where = state === 'read' ? 'Ya leído'
: state === 'reading' || state === 'unknown' ? (work.chapterNum != null && work.chapterNum !== '' ? `Vas por el capítulo ${esc(work.chapterNum)}${page}` : at >= 0 ? `Vas por el capítulo ${at + 1} de este tomo${page}` : 'Lo estás leyendo')
: 'Sin empezar'
const href = readOf(info), st = shelfMod.noteStyle(work.id) const href = readOf(info), st = shelfMod.noteStyle(work.id)
card.style.cssText = `--np:${st.paper};--ni:${st.ink};--nf:"${st.family}";--nw:${st.weight};--ns:${st.scale};--nt:${(st.tilt * -18).toFixed(2)}deg` card.style.cssText = `--np:${st.paper};--ni:${st.ink};--nf:"${st.family}";--nw:${st.weight};--ns:${st.scale};--nt:${(st.tilt * -18).toFixed(2)}deg`
// Un pósit de verdad: sin botones. Se lee tocando el tomo (o esta línea, que lleva al mismo sitio).
card.innerHTML = `<button class="peekx" type="button" aria-label="Devolver al estante">${icon('x')}</button> card.innerHTML = `<button class="peekx" type="button" aria-label="Devolver al estante">${icon('x')}</button>
<h2 class="ptitle">${esc(shelfMod.shortTitle(work.title, 60))}</h2>${facts ? `<p class="psub">${facts}</p>` : ''} <h2 class="ptitle">${esc(shelfMod.shortTitle(work.title))}</h2>${facts ? `<p class="psub">${facts}</p>` : ''}
<div class="pbar"><i style="width:${pct}%"></i></div><p class="pwhere">${where}</p> <p class="pwhere">${where}</p>
<div class="pacts">${href ? `<a class="btn primary" id="peekread" href="${href}">${icon('book')} ${state === 'reading' ? 'Seguir leyendo' : 'Leer'}</a>` : '<span class="pwhere">No está guardado en el dispositivo</span>'}${offline ? '' : `<a class="btn" href="#/manga/${work.id}">Ver ficha</a>`}</div>` ${href ? `<a class="phint" id="peekread" href="${href}">Toca el tomo para ${state === 'reading' ? 'seguir leyendo' : 'leerlo'} →</a>` : '<p class="phint">No está guardado en el dispositivo</p>'}`
card.querySelector('.peekx').addEventListener('click', () => shelf?.unpeek()) card.querySelector('.peekx').addEventListener('click', () => shelf?.unpeek())
card.querySelector('#peekread')?.addEventListener('click', e => { e.preventDefault(); toTable(info) }) card.querySelector('#peekread')?.addEventListener('click', e => { e.preventDefault(); toTable(info) })
clearTimeout(card.hideTimer) clearTimeout(card.hideTimer)
@@ -3978,7 +3989,7 @@ async function viewShelf(params) {
const had = new Map(works.map(w => [w.id, w])) const had = new Map(works.map(w => [w.id, w]))
const next = fresh.works.map(w => (had.get(w.id)?.vols ? { ...w, vols: had.get(w.id).vols } : w)) const next = fresh.works.map(w => (had.get(w.id)?.vols ? { ...w, vols: had.get(w.id).vols } : w))
if (!next.length) { shelf?.destroy(); shelf = null; works = []; store.set(shelfSnapKey(), null); return say('Aún no hay nada en tu biblioteca: los tomos de lo que leas, sigas o descargues aparecerán aquí.') } if (!next.length) { shelf?.destroy(); shelf = null; works = []; store.set(shelfSnapKey(), null); return say('Aún no hay nada en tu biblioteca: los tomos de lo que leas, sigas o descargues aparecerán aquí.') }
const sameList = shelf && next.length === works.length && next.every((w, i) => { const o = had.get(w.id); return o && o.title === w.title && o.cover === w.cover && o.chapterId === w.chapterId && o.page === w.page && o.last === w.last }) const sameList = shelf && next.length === works.length && next.every((w, i) => { const o = had.get(w.id); return o && o.title === w.title && o.cover === w.cover && o.chapterId === w.chapterId && o.chapterNum === w.chapterNum && o.page === w.page && o.last === w.last })
if (!shelf) start(next, lib) if (!shelf) start(next, lib)
else if (!sameList) { works = next; shelf.setWorks(shelfSort(works, order)) } else if (!sameList) { works = next; shelf.setWorks(shelfSort(works, order)) }
else works = next.map(w => had.get(w.id)) else works = next.map(w => had.get(w.id))
+55 -30
View File
@@ -303,33 +303,48 @@ function rimShape(pts, y0, y1, thick) {
return s return s
} }
// El sensor: los ejes de la pantalla a partir de la orientación del dispositivo. La inclinación se desvanece con el tiempo. // El sensor. Fuente principal: el giroscopio (devicemotion.rotationRate, en °/s y en los ejes del dispositivo), que no falla con
// MAX_DEG: lo que hay que girar el dispositivo para llegar al tope. ORBIT_*: cuánto rodea entonces la cámara al libro, en grados. // el dispositivo vertical. La orientación del sistema (deviceorientation) solo se usa si no hay giroscopio: sus ángulos β/γ saltan
// cerca de β = 90°, aunque el dispositivo esté quieto. La inclinación acumulada vuelve sola al centro con el tiempo y se acerca al
// tope con suavidad (tanh). Al caminar, la agitación que mide el acelerómetro baja la sensibilidad.
// Calibrado con grabaciones reales del iPad mini 6 y el Pixel 10 (ver HANDOFF-LIBRO3D.md).
// MAX_DEG: lo que hay que girar el dispositivo para llegar al 76 % del tope. ORBIT_*: cuánto rodea la cámara al libro en el tope, en grados.
export const MAX_DEG = 15 export const MAX_DEG = 15
const FADE_S = 2.5, QUIET = 0.6, ORBIT_TILT = 7, ORBIT_YAW = 10, ORBIT_BACK = 0.06 const FADE_S = 2.5, QUIET = 0.6, DEAD = 1, ORBIT_TILT = 10, ORBIT_YAW = 15, ORBIT_BACK = 0.12
const SMOOTH_S = 0.058, WALK_FROM = 0.25, WALK_K = 2.5, WALK_S = 0.4
export function createTilt(onChange) { export function createTilt(onChange) {
const target = { x: 0, y: 0 } const target = { x: 0, y: 0 }, raw = { x: 0, y: 0 }
let listening = false, gotOrientation = false, lastT = 0, lastOri = null, lastFade = 0, askOnTouch = null let listening = false, gotGyro = false, lastT = 0, lastOri = null, lastFade = 0, askOnTouch = null
const clamp = v => Math.max(-MAX_DEG, Math.min(MAX_DEG, v)) // iOS da la gravedad con el signo al revés que la especificación; se confirma con β en cuanto se puede.
let gSign = window.DeviceMotionEvent?.requestPermission ? -1 : 1, grav = null, land = null, shake = 0
const LIM = 2 * MAX_DEG
const lim = v => Math.max(-LIM, Math.min(LIM, v))
const soft = () => { for (const k of ['x', 'y']) target[k] = MAX_DEG * Math.tanh(raw[k] / MAX_DEG) }
// Giro de la pantalla. Safari del iPad informa mal el ángulo (90 en vertical, 180 en horizontal con el borde derecho abajo):
// la disposición dice si es vertical u horizontal y, en horizontal, la gravedad dice hacia qué lado.
function screenAngle() { function screenAngle() {
let a = (((window.orientation ?? screen.orientation?.angle ?? 0) % 360) + 360) % 360 let a = (((window.orientation ?? screen.orientation?.angle ?? 0) % 360) + 360) % 360
const landscape = innerWidth > innerHeight const landscape = innerWidth > innerHeight
if (landscape && a % 180 === 0) a = 90 if (landscape) {
if (!landscape && a % 180) a = 0 if (grav && Math.abs(grav[0]) > 3) land = grav[0] > 0 ? 90 : 270
a = land ?? (a % 180 ? a : 90)
} else if (a % 180) a = 0
return (a * Math.PI) / 180 return (a * Math.PI) / 180
} }
function add(dx, dy) { function add(dx, dy) {
const a = screenAngle(), c = Math.cos(a), s = Math.sin(a) const a = screenAngle(), c = Math.cos(a), s = Math.sin(a)
target.x = clamp(target.x + dx * c - dy * s) raw.x = lim(raw.x + dx * c - dy * s)
target.y = clamp(target.y + dx * s + dy * c) raw.y = lim(raw.y + dx * s + dy * c)
soft()
lastFade = lastFade || performance.now() lastFade = lastFade || performance.now()
onChange() onChange()
} }
const stepOf = t => { const dt = Math.min(0.1, Math.max(0, (t - lastT) / 1000)); lastT = t; return dt } const stepOf = t => { const dt = Math.min(0.1, Math.max(0, (t - lastT) / 1000)); lastT = t; return dt }
const quiet = v => (Math.abs(v) < QUIET ? 0 : v)
function onOrientation(e) { function onOrientation(e) {
if (e.beta == null || e.gamma == null) return if (e.beta == null || e.gamma == null) return
gotOrientation = true const sb = Math.sin((e.beta * Math.PI) / 180)
if (grav && Math.abs(sb) > 0.7 && Math.abs(grav[1]) > 6) gSign *= Math.sign(grav[1] * sb) // y de la reacción a la gravedad = 9,8·sin β
if (gotGyro) return
const dt = stepOf(e.timeStamp) const dt = stepOf(e.timeStamp)
if (lastOri) { if (lastOri) {
const dx = e.beta - lastOri.b, dy = e.gamma - lastOri.g const dx = e.beta - lastOri.b, dy = e.gamma - lastOri.g
@@ -339,10 +354,16 @@ export function createTilt(onChange) {
lastOri = { b: e.beta, g: e.gamma } lastOri = { b: e.beta, g: e.gamma }
} }
function onMotion(e) { function onMotion(e) {
const r = e.rotationRate const r = e.rotationRate, g = e.accelerationIncludingGravity, l = e.acceleration
if (gotOrientation || r?.alpha == null || r?.beta == null) return if (g?.x != null && g?.y != null) grav = [g.x * gSign, g.y * gSign]
if (r?.alpha == null || r?.beta == null) return
gotGyro = true
const dt = stepOf(e.timeStamp) const dt = stepOf(e.timeStamp)
add(quiet(r.alpha) * dt, quiet(r.beta) * dt) // especificación: alpha es el eje x y beta el eje y if (l?.x != null) shake += (Math.hypot(l.x, l.y || 0, l.z || 0) - shake) * Math.min(1, dt / WALK_S)
const speed = Math.hypot(r.alpha, r.beta) // especificación: alpha es el eje x y beta el eje y; gamma (girar como un volante) no cuenta
if (speed <= DEAD) return
const k = ((speed - DEAD) / speed) * dt / (1 + Math.max(0, shake - WALK_FROM) * WALK_K)
add(r.alpha * k, r.beta * k)
} }
function listen(on) { function listen(on) {
if (on === listening) return if (on === listening) return
@@ -350,7 +371,7 @@ export function createTilt(onChange) {
const method = on ? 'addEventListener' : 'removeEventListener' const method = on ? 'addEventListener' : 'removeEventListener'
window[method]('devicemotion', onMotion) window[method]('devicemotion', onMotion)
window[method]('deviceorientation', onOrientation) window[method]('deviceorientation', onOrientation)
target.x = target.y = 0 target.x = target.y = raw.x = raw.y = shake = 0
lastT = performance.now() lastT = performance.now()
lastOri = null lastOri = null
onChange() onChange()
@@ -377,11 +398,26 @@ export function createTilt(onChange) {
fade(now) { fade(now) {
const dt = Math.min(0.1, Math.max(0, (now - lastFade) / 1000)), keep = Math.exp(-dt / FADE_S) const dt = Math.min(0.1, Math.max(0, (now - lastFade) / 1000)), keep = Math.exp(-dt / FADE_S)
lastFade = now lastFade = now
for (const k of ['x', 'y']) target[k] = Math.abs(target[k] * keep) < 0.3 ? 0 : target[k] * keep for (const k of ['x', 'y']) raw[k] = Math.abs(raw[k] * keep) < 0.3 ? 0 : raw[k] * keep
soft()
}, },
stop() { forget(); listen(false) }, stop() { forget(); listen(false) },
} }
} }
// La cámara sigue a la inclinación con un suavizado por tiempo, igual a 60 que a 120 Hz. cur: { x, y } en fracción del tope.
export function followTilt(tilt, cur, now, on) {
tilt.fade(now)
const dt = Math.min(1 / 30, Math.max(0, (now - (cur.t ?? now)) / 1000)), a = 1 - Math.exp(-dt / SMOOTH_S)
cur.t = now
const want = on ? { x: tilt.target.x / MAX_DEG, y: tilt.target.y / MAX_DEG } : { x: 0, y: 0 }
let moving = !!(tilt.target.x || tilt.target.y) && on
for (const k of ['x', 'y']) {
const d = want[k] - cur[k]
if (Math.abs(d) < 0.002) cur[k] = want[k]
else { cur[k] += d * a; moving = true }
}
return moving
}
export function createBook3D(container, opts) { export function createBook3D(container, opts) {
const { total, src, whenReady, spreadOf, isWide, still = false } = opts const { total, src, whenReady, spreadOf, isWide, still = false } = opts
@@ -778,18 +814,7 @@ export function createBook3D(container, opts) {
return pts.at(-1) return pts.at(-1)
} }
function stepTilt(now) { const stepTilt = now => followTilt(tilt, tiltNow, now, !still && zoom.s === 1)
tilt.fade(now)
const on = !still && zoom.s === 1
const want = on ? { x: tilt.target.x / MAX_DEG, y: tilt.target.y / MAX_DEG } : { x: 0, y: 0 }
let moving = !!(tilt.target.x || tilt.target.y) && on
for (const k of ['x', 'y']) {
const d = want[k] - tiltNow[k]
if (Math.abs(d) < 0.002) tiltNow[k] = want[k]
else { tiltNow[k] += d * 0.25; moving = true }
}
return moving
}
function stepAnim(now) { function stepAnim(now) {
if (!anim) return false if (!anim) return false
const dt = Math.min(0.12, (now - anim.last) / 1000) // tras un tirón, recuperar sin alargar la animación const dt = Math.min(0.12, (now - anim.last) / 1000) // tras un tirón, recuperar sin alargar la animación
@@ -1048,7 +1073,7 @@ export function createBook3D(container, opts) {
}, },
draw, draw,
// Para las pruebas. // Para las pruebas.
debug: () => ({ view: view?.key, focus: view?.focus, anim: anim && { kind: anim.kind, p: anim.p, side: anim.side, dragging: anim.dragging }, tilt: { ...tiltNow }, target: { ...tilt.target }, listening: tilt.listening, textures: [...textures.keys()], W, zoom: zoom.s, cam: camNow && { x: camNow.target[0], dist: camNow.dist } }), debug: () => ({ view: view?.key, focus: view?.focus, anim: anim && { kind: anim.kind, p: anim.p, side: anim.side, dragging: anim.dragging }, tilt: { x: tiltNow.x, y: tiltNow.y }, target: { ...tilt.target }, listening: tilt.listening, textures: [...textures.keys()], W, zoom: zoom.s, cam: camNow && { x: camNow.target[0], dist: camNow.dist } }),
destroy() { destroy() {
dead = true dead = true
cancelAnimationFrame(raf) cancelAnimationFrame(raf)
+63 -31
View File
@@ -117,6 +117,7 @@ uniform float uHas, uPitch, uDepth, uDim, uAlpha;
uniform vec4 uSpot; // mesa: centro del tomo y radio de la luz uniform vec4 uSpot; // mesa: centro del tomo y radio de la luz
uniform vec2 uTab; // mesa propia: escala de la foto; sin ella, madera repetida uniform vec2 uTab; // mesa propia: escala de la foto; sin ella, madera repetida
uniform vec2 uSize; uniform vec2 uSize;
uniform vec2 uFace; // portada de frente: ancho y alto, para la sombra que le hace el hueco del estante (0: sin sombra)
uniform vec4 uHide; // x0, x1, y0, y1 del hueco del tomo sacado: ni él ni sus costados se pintan en la fila uniform vec4 uHide; // x0, x1, y0, y1 del hueco del tomo sacado: ni él ni sus costados se pintan en la fila
in vec3 vPos; in vec3 vNor; in vec2 vUV; in vec3 vPos; in vec3 vNor; in vec2 vUV;
out vec4 color; out vec4 color;
@@ -131,7 +132,16 @@ void main() {
vec3 c; vec3 c;
if (uKind == 0) c = texture(uTex, vUV, -0.4).rgb * vec3(0.92, 0.84, 0.76); // madera del mueble, algo más oscura que la mesa if (uKind == 0) c = texture(uTex, vUV, -0.4).rgb * vec3(0.92, 0.84, 0.76); // madera del mueble, algo más oscura que la mesa
else if (uKind == 1) c = texture(uTex, vUV).rgb; // lomos else if (uKind == 1) c = texture(uTex, vUV).rgb; // lomos
else if (uKind == 2) c = uHas > 0.5 ? texture(uTex, vUV, -0.5).rgb : uColor; // portada else if (uKind == 2) { // portada
c = uHas > 0.5 ? texture(uTex, vUV, -0.5).rgb : uColor;
if (uFace.x > 0.0) {
// Metida en el estante: la luz llega de arriba a la izquierda, así que el lomo o el costado de la izquierda le hace
// sombra, y la balda de arriba y el fondo la oscurecen hacia los bordes.
float dl = vUV.x * uFace.x, dr = (1.0 - vUV.x) * uFace.x, dt = vUV.y * uFace.y, db = (1.0 - vUV.y) * uFace.y;
c *= mix(0.28, 1.0, smoothstep(0.0, 0.13, dl)) * mix(0.6, 1.0, smoothstep(0.0, 0.06, dr))
* mix(0.45, 1.0, smoothstep(0.0, 0.36, dt)) * mix(0.66, 1.0, smoothstep(0.0, 0.04, db)) * 0.88;
}
}
else if (uKind == 3 || uKind == 5) { // las hojas, vistas por arriba: rayas irregulares a lo ancho del tomo, medidas en el mueble else if (uKind == 3 || uKind == 5) { // las hojas, vistas por arriba: rayas irregulares a lo ancho del tomo, medidas en el mueble
float a = uKind == 3 ? vUV.x : vUV.y, v = a / 0.0042, w = fwidth(v), k = floor(v); float a = uKind == 3 ? vUV.x : vUV.y, v = a / 0.0042, w = fwidth(v), k = floor(v);
float n = fract(sin(k * 12.9898) * 43758.5453); float n = fract(sin(k * 12.9898) * 43758.5453);
@@ -156,6 +166,19 @@ void main() {
color = vec4(c * lit * shade * room * uDim * vec3(1.03, 1.0, 0.95), uAlpha); color = vec4(c * lit * shade * room * uDim * vec3(1.03, 1.0, 0.95), uAlpha);
}` }`
// Lomo curvo: a lo ancho, el centro sobresale y los bordes quedan SPINE_ROUND del ancho más atrás.
// a0 y len: dónde empieza y cuánto mide a lo ancho; top y H: arriba y alto; front: lo más saliente.
// place(a lo ancho, salida) da x y z del punto; turn pasa la normal (a lo ancho, 0, salida) a esos mismos ejes.
const SPINE_ROUND = 0.16, SPINE_COLS = 8
function roundSpine(sh, a0, len, top, H, front, R, place, [U0, U1], turn = n => n) {
const w = Math.abs(len)
sh.grid(SPINE_COLS, 1, (c, r) => {
const t = c / SPINE_COLS, out = front - R * (1 - Math.sin(Math.PI * t)), slope = ((R * Math.PI) / w) * Math.cos(Math.PI * t)
const k = Math.hypot(slope, 1), n = turn([-slope / k * Math.sign(len), 0, 1 / k])
const [px, , pz] = place(a0 + len * t, out)
return [px, top - H * r, pz, ...n, U0 + (U1 - U0) * t, r]
})
}
// Un cuadrilátero: origen, dos lados, normal y coordenadas de textura en sus esquinas. // Un cuadrilátero: origen, dos lados, normal y coordenadas de textura en sus esquinas.
function quad(sh, o, a, b, n, uv = [0, 0, 1, 1]) { function quad(sh, o, a, b, n, uv = [0, 0, 1, 1]) {
sh.grid(1, 1, (c, r) => [o[0] + a[0] * c + b[0] * r, o[1] + a[1] * c + b[1] * r, o[2] + a[2] * c + b[2] * r, ...n, uv[0] + (uv[2] - uv[0]) * c, uv[1] + (uv[3] - uv[1]) * r]) sh.grid(1, 1, (c, r) => [o[0] + a[0] * c + b[0] * r, o[1] + a[1] * c + b[1] * r, o[2] + a[2] * c + b[2] * r, ...n, uv[0] + (uv[2] - uv[0]) * c, uv[1] + (uv[3] - uv[1]) * r])
@@ -203,10 +226,10 @@ export function loadNoteFonts() {
const IDENTITY = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]) const IDENTITY = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1])
const FLY_MS = 900 const FLY_MS = 900
const PEEK_OPEN_MS = 720, PEEK_CLOSE_MS = 520, PEEK_SLIDE = 0.34 // el tomo sale recto de la fila y luego gira hacia la cámara const PEEK_OPEN_MS = 720, PEEK_CLOSE_MS = 520, PEEK_SLIDE = 0.34 // el tomo sale recto de la fila y luego gira hacia la cámara
const ZOOM_MAX = 3, ORBIT_TILT = 10, ORBIT_YAW = 18, ORBIT_BACK = 0.08, REST_TILT = -3, FRICTION_MS = 320 const ZOOM_MAX = 3, ORBIT_TILT = 14, ORBIT_YAW = 26, ORBIT_BACK = 0.11, REST_TILT = -3, FRICTION_MS = 320
export function createShelf3D(container, lib, { works: firstWorks = [], rowsLandscape = 2.3, rowsPortrait = 3, still = false, pagesOf = null, onPeek = null, onOpen = null, table = null } = {}) { export function createShelf3D(container, lib, { works: firstWorks = [], rowsLandscape = 2.3, rowsPortrait = 3, still = false, pagesOf = null, onPeek = null, onOpen = null, table = null } = {}) {
const { Shape, woodCanvas, cameraMatrix, project, FOV, coverPalette, inkOn, css, createTilt, MAX_DEG } = lib const { Shape, woodCanvas, cameraMatrix, project, FOV, coverPalette, inkOn, css, createTilt, followTilt } = lib
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
canvas.className = 'shelfcanvas' canvas.className = 'shelfcanvas'
const gl = canvas.getContext('webgl2', { alpha: false, antialias: true, depth: true, powerPreference: 'high-performance' }) const gl = canvas.getContext('webgl2', { alpha: false, antialias: true, depth: true, powerPreference: 'high-performance' })
@@ -267,7 +290,7 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) || 'shader') if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) || 'shader')
gl.useProgram(prog) gl.useProgram(prog)
u = {} u = {}
for (const n of ['uM', 'uModel', 'uKind', 'uTex', 'uColor', 'uHas', 'uPitch', 'uDepth', 'uSize', 'uDim', 'uHide', 'uAlpha', 'uSpot', 'uTab']) u[n] = gl.getUniformLocation(prog, n) for (const n of ['uM', 'uModel', 'uKind', 'uTex', 'uColor', 'uHas', 'uPitch', 'uDepth', 'uSize', 'uDim', 'uHide', 'uAlpha', 'uSpot', 'uTab', 'uFace']) u[n] = gl.getUniformLocation(prog, n)
gl.uniform1i(u.uTex, 0) gl.uniform1i(u.uTex, 0)
gl.uniformMatrix4fv(u.uModel, false, IDENTITY) gl.uniformMatrix4fv(u.uModel, false, IDENTITY)
gl.uniform1f(u.uDim, 1) gl.uniform1f(u.uDim, 1)
@@ -346,15 +369,29 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand
// keep: la imagen se guarda en el dispositivo (portadas de frente y logos) y de ahí sale la próxima vez. // keep: la imagen se guarda en el dispositivo (portadas de frente y logos) y de ahí sale la próxima vez.
const keepUrls = new Set() const keepUrls = new Set()
function loadImage(url, done, keep = false) { function loadImage(url, done, keep = false) {
if (keep && url.startsWith('/')) keepUrls.add(url)
if (loadingImg.has(url)) { loadingImg.get(url).push(done); return } if (loadingImg.has(url)) { loadingImg.get(url).push(done); return }
loadingImg.set(url, [done]) loadingImg.set(url, [done])
waiting.push(url) if (!keep || !url.startsWith('/')) { waiting.push(url); return pumpImages() }
pumpImages() keepUrls.add(url)
// Lo guardado en el dispositivo no espera a la red: no ocupa sitio en la cola.
keptBlob('img', url).then(hit => {
if (dead) return
if (!hit) { waiting.push(url); return pumpImages() }
const im = new Image(), src = URL.createObjectURL(hit)
im.decoding = 'async'
const finish = ok => {
URL.revokeObjectURL(src)
if (!ok) { waiting.push(url); return pumpImages() } // lo guardado no sirve: de la red
const list = loadingImg.get(url) || []
loadingImg.delete(url)
for (const f of list) f(im)
} }
async function keptSrc(url) { im.onload = () => finish(true)
const hit = await keptBlob('img', url) im.onerror = () => finish(false)
if (hit) return URL.createObjectURL(hit) im.src = src
})
}
async function keptSrc(url) { // ya se miró lo guardado: de la red, y se guarda
const r = await fetch(url, { credentials: 'same-origin' }).catch(() => null) const r = await fetch(url, { credentials: 'same-origin' }).catch(() => null)
if (!r?.ok || !/^image\//.test(r.headers.get('content-type') || '')) return null if (!r?.ok || !/^image\//.test(r.headers.get('content-type') || '')) return null
const blob = await r.blob() const blob = await r.blob()
@@ -386,7 +423,7 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand
keptSrc(url).catch(() => null).then(src => { keptSrc(url).catch(() => null).then(src => {
im.onload = () => { if (src) URL.revokeObjectURL(src); finish(true) } im.onload = () => { if (src) URL.revokeObjectURL(src); finish(true) }
im.onerror = () => { im.onerror = () => {
if (src) { URL.revokeObjectURL(src); src = null; im.src = url; return } // lo guardado no sirve: de la red if (src) { URL.revokeObjectURL(src); src = null; im.src = url; return } // lo bajado no se pudo leer: tal cual
finish(false) finish(false)
} }
im.src = src || url im.src = src || url
@@ -717,24 +754,26 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand
g.drawImage(logo.im, -len / 2, -thick / 2, len, thick) g.drawImage(logo.im, -len / 2, -thick / 2, len, thick)
g.restore() g.restore()
} }
// Con logo, el título ya está: solo queda el número del tomo.
if (!logo) {
let size = wText * (art ? 0.44 : 0.5) let size = wText * (art ? 0.44 : 0.5)
g.font = `700 ${Math.round(size)}px system-ui, sans-serif` g.font = `700 ${Math.round(size)}px system-ui, sans-serif`
let long = g.measureText(it.work.title).width let long = g.measureText(it.work.title).width
if (long > room) { size = Math.max(wText * 0.32, (size * room) / long); g.font = `700 ${Math.round(size)}px system-ui, sans-serif`; long = g.measureText(it.work.title).width } if (long > room) { size = Math.max(wText * 0.32, (size * room) / long); g.font = `700 ${Math.round(size)}px system-ui, sans-serif`; long = g.measureText(it.work.title).width }
const len = Math.min(long, room), mid = art ? to - len / STRIP_H / 2 - 0.012 : (from + to) / 2 const len = Math.min(long, room), mid = art ? to - len / STRIP_H / 2 - 0.012 : (from + to) / 2
if (backing && !logo) { if (backing) {
const bw = size * 1.45 * sx, pad = size * 0.55 const bw = size * 1.45 * sx, pad = size * 0.55
ink = backing((w - bw) / 2, STRIP_H * mid - len / 2 - pad, bw, len + pad * 2) ink = backing((w - bw) / 2, STRIP_H * mid - len / 2 - pad, bw, len + pad * 2)
} }
g.save() g.save()
g.scale(sx, 1) g.scale(sx, 1)
if (logo) g.globalAlpha = 0 // ya está el logo
g.fillStyle = css(ink) g.fillStyle = css(ink)
if (backing) { g.shadowColor = ink[0] > 0.5 ? 'rgba(0,0,0,.55)' : 'rgba(255,255,255,.5)'; g.shadowBlur = 3 } if (backing) { g.shadowColor = ink[0] > 0.5 ? 'rgba(0,0,0,.55)' : 'rgba(255,255,255,.5)'; g.shadowBlur = 3 }
g.translate(wText / 2, STRIP_H * mid) g.translate(wText / 2, STRIP_H * mid)
g.rotate(Math.PI / 2) g.rotate(Math.PI / 2)
g.fillText(it.work.title, 0, 0, room) g.fillText(it.work.title, 0, 0, room)
g.restore() g.restore()
}
if (it.vol.vol != null) { if (it.vol.vol != null) {
const nSize = wText * 0.5, label = String(it.vol.vol), cy = STRIP_H * 0.935 const nSize = wText * 0.5, label = String(it.vol.vol), cy = STRIP_H * 0.935
g.font = `800 ${Math.round(nSize)}px system-ui, sans-serif` g.font = `800 ${Math.round(nSize)}px system-ui, sans-serif`
@@ -772,12 +811,13 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand
faces.push({ it, mesh: makeMesh(sh), edge: makeMesh(edge), pages: makeMesh(pages) }) faces.push({ it, mesh: makeMesh(sh), edge: makeMesh(edge), pages: makeMesh(pages) })
continue continue
} }
quad(spines, [x0, base + H, F], [it.w, 0, 0], [0, -H, 0], [0, 0, 1], [U0, 0, U1, 1]) const R = it.w * SPINE_ROUND, Fe = F - R // los bordes del lomo, algo más atrás que su centro
roundSpine(spines, x0, it.w, base + H, H, F, R, (along, out) => [along, 0, out], [U0, U1])
// Los costados son las tapas: un solo color del lomo, no una columna de la tira estirada a lo largo del fondo. // Los costados son las tapas: un solo color del lomo, no una columna de la tira estirada a lo largo del fondo.
const Um = (U0 + U1) / 2, side = [Um, 0.02, Um, 0.02] const Um = (U0 + U1) / 2, side = [Um, 0.02, Um, 0.02]
quad(spines, [x0, base, F - D], [0, 0, D], [0, H, 0], [-1, 0, 0], side) quad(spines, [x0, base, F - D], [0, 0, D - R], [0, H, 0], [-1, 0, 0], side)
quad(spines, [x1, base, F], [0, 0, -D], [0, H, 0], [1, 0, 0], side) quad(spines, [x1, base, Fe], [0, 0, -(D - R)], [0, H, 0], [1, 0, 0], side)
quad(paper, [x0, base + H, F - D], [it.w, 0, 0], [0, 0, D], [0, 1, 0], [0, it.w, it.w, it.w]) // u: a lo ancho, en el mueble; v: el grosor quad(paper, [x0, base + H, F - D], [it.w, 0, 0], [0, 0, D - R], [0, 1, 0], [0, it.w, it.w, it.w]) // u: a lo ancho, en el mueble; v: el grosor
} }
paintStrip(row, strip) paintStrip(row, strip)
const b = { k, key: rowKey(row), strip, tex: texFrom(strip), spines: spines.i.length ? makeMesh(spines) : null, paper: paper.i.length ? makeMesh(paper) : null, faces, stale: 0 } const b = { k, key: rowKey(row), strip, tex: texFrom(strip), spines: spines.i.length ? makeMesh(spines) : null, paper: paper.i.length ? makeMesh(paper) : null, faces, stale: 0 }
@@ -908,18 +948,7 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand
if (Math.abs(velocity) < 0.00004) velocity = 0 if (Math.abs(velocity) < 0.00004) velocity = 0
return !!velocity return !!velocity
} }
function stepTilt(now) { const stepTilt = now => followTilt(tilt, tiltNow, now, !still && zoom === 1)
tilt.fade(now)
const on = !still && zoom === 1
const want = on ? { x: tilt.target.x / MAX_DEG, y: tilt.target.y / MAX_DEG } : { x: 0, y: 0 }
let moving = !!(tilt.target.x || tilt.target.y) && on
for (const k of ['x', 'y']) {
const d = want[k] - tiltNow[k]
if (Math.abs(d) < 0.002) tiltNow[k] = want[k]
else { tiltNow[k] += d * 0.25; moving = true }
}
return moving
}
function paint() { function paint() {
raf = 0 raf = 0
if (dead) return if (dead) return
@@ -970,8 +999,10 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand
gl.uniform1i(u.uKind, 2) gl.uniform1i(u.uKind, 2)
gl.uniform1f(u.uHas, tex ? 1 : 0) gl.uniform1f(u.uHas, tex ? 1 : 0)
gl.uniform3fv(u.uColor, base) gl.uniform3fv(u.uColor, base)
gl.uniform2f(u.uFace, f.it.w, f.it.h)
if (tex) gl.bindTexture(gl.TEXTURE_2D, tex) if (tex) gl.bindTexture(gl.TEXTURE_2D, tex)
drawMesh(f.mesh) drawMesh(f.mesh)
gl.uniform2f(u.uFace, 0, 0)
gl.uniform1i(u.uKind, 4) gl.uniform1i(u.uKind, 4)
drawMesh(f.edge) drawMesh(f.edge)
gl.uniform1i(u.uKind, 5) gl.uniform1i(u.uKind, 5)
@@ -1004,7 +1035,8 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand
function peekBox(W, H, T, U0, U1) { function peekBox(W, H, T, U0, U1) {
const cover = new Shape(), spine = new Shape(), back = new Shape(), paper = new Shape(), X = W / 2, Y = H / 2, Z = T / 2 const cover = new Shape(), spine = new Shape(), back = new Shape(), paper = new Shape(), X = W / 2, Y = H / 2, Z = T / 2
quad(cover, [-X, Y, Z], [W, 0, 0], [0, -H, 0], [0, 0, 1]) quad(cover, [-X, Y, Z], [W, 0, 0], [0, -H, 0], [0, 0, 1])
quad(spine, [X, Y, Z], [0, 0, -T], [0, -H, 0], [1, 0, 0], [U0, 0, U1, 1]) // en la fila, con el giro, coincide con su trozo de la tira // En la fila, con el giro, coincide con su trozo de la tira, también en la curva.
roundSpine(spine, Z, -T, Y, H, X, T * SPINE_ROUND, (along, out) => [out, 0, along], [U0, U1], n => [n[2], 0, n[0]])
quad(back, [X, Y, -Z], [-W, 0, 0], [0, -H, 0], [0, 0, -1]) quad(back, [X, Y, -Z], [-W, 0, 0], [0, -H, 0], [0, 0, -1])
quad(paper, [-X, Y, -Z], [W, 0, 0], [0, 0, T], [0, 1, 0], [0, 0, W, T]) quad(paper, [-X, Y, -Z], [W, 0, 0], [0, 0, T], [0, 1, 0], [0, 0, W, T])
quad(paper, [-X, -Y, -Z], [W, 0, 0], [0, 0, T], [0, -1, 0], [0, 0, W, T]) quad(paper, [-X, -Y, -Z], [W, 0, 0], [0, 0, T], [0, -1, 0], [0, 0, W, T])
@@ -1324,7 +1356,7 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand
setZoom, setZoom,
draw, draw,
// Para las pruebas. // Para las pruebas.
debug: () => ({ faces: Object.entries(palettes).filter(([k, v]) => k.startsWith('face:') && v).length, facesAsked: Object.keys(palettes).filter(k => k.startsWith('face:')).length, faceWaiting: faceWaiting.length, faceBusy, faceWorker: faceWorker === false ? 'off' : !!faceWorker, rows: rows.map(r => r.map(i => ({ id: i.work.id, vol: i.vol.vol, x: i.x, w: i.w, h: i.h, d: i.d, face: i.face }))), rowWidth, y, x, zoom, velocity, top, bottom, peek: peek && { id: peek.it.work.id, vol: peek.it.vol.vol, t: peek.t, dir: peek.dir, rank: peek.rank, rect: peekRect(), fly: peek.fly ? peek.fly.t : null }, focusId, notes: [...built.values()].map(b => ({ k: b.k, notes: (b.notes?.list || []).map(n => ({ id: n.work.id, x: n.x, w: n.w })) })), logos: logos.size, tilt: { ...tiltNow }, listening: tilt.listening, arts: arts.size, panels: panels.size, built: [...built.keys()], covers: covers.size, palettes: Object.keys(palettes).length, waiting: waiting.length, busy: imgBusy, ctx: !gl.isContextLost() }), debug: () => ({ faces: Object.entries(palettes).filter(([k, v]) => k.startsWith('face:') && v).length, facesAsked: Object.keys(palettes).filter(k => k.startsWith('face:')).length, faceWaiting: faceWaiting.length, faceBusy, faceWorker: faceWorker === false ? 'off' : !!faceWorker, rows: rows.map(r => r.map(i => ({ id: i.work.id, vol: i.vol.vol, x: i.x, w: i.w, h: i.h, d: i.d, face: i.face }))), rowWidth, y, x, zoom, velocity, top, bottom, peek: peek && { id: peek.it.work.id, vol: peek.it.vol.vol, t: peek.t, dir: peek.dir, rank: peek.rank, rect: peekRect(), fly: peek.fly ? peek.fly.t : null }, focusId, notes: [...built.values()].map(b => ({ k: b.k, notes: (b.notes?.list || []).map(n => ({ id: n.work.id, x: n.x, w: n.w })) })), logos: logos.size, tilt: { x: tiltNow.x, y: tiltNow.y }, listening: tilt.listening, arts: arts.size, panels: panels.size, built: [...built.keys()], covers: covers.size, palettes: Object.keys(palettes).length, waiting: waiting.length, busy: imgBusy, ctx: !gl.isContextLost() }),
destroy() { destroy() {
if (faceWorker) faceWorker.terminate() if (faceWorker) faceWorker.terminate()
dead = true dead = true
+15 -15
View File
@@ -487,25 +487,25 @@ body.reading #netbanner { display: none; }
.reader.b3d.b3dcrop { background: #000; } .reader.b3d.b3dcrop { background: #000; }
.reader.b3d.b3dcrop > #b3d { display: none; } .reader.b3d.b3dcrop > #b3d { display: none; }
.reader.b3d.b3dcrop > #pages img { visibility: visible; } .reader.b3d.b3dcrop > #pages img { visibility: visible; }
/* Vistazo del estante: la nota del tomo sacado, como un pósit pegado junto a él (a la derecha en apaisado, abajo en vertical). /* Vistazo del estante: la nota del tomo sacado, un pósit cuadrado pegado junto a él (a la derecha en apaisado, abajo en vertical).
Papel, tinta, letra e inclinación son los de la nota de su colección en la balda (--np, --ni, --nf, --nw, --ns, --nt). */ Papel, tinta, letra e inclinación son los de la nota de su colección en la balda (--np, --ni, --nf, --nw, --ns, --nt). */
.shelfpeek { --np: #fff27a; --ni: #24242e; --nf: cursive; --nw: 400; --ns: 1; --nt: 0deg; .shelfpeek { --np: #fff27a; --ni: #24242e; --nf: cursive; --nw: 400; --ns: 1; --nt: 0deg; --side: min(34vw, 330px);
position: fixed; z-index: 3; right: calc(4vw + env(safe-area-inset-right)); top: 50%; width: min(36vw, 360px); transform: translate(14px, -50%) rotate(var(--nt)); opacity: 0; transition: opacity .22s, transform .26s; position: fixed; z-index: 3; right: calc(5vw + env(safe-area-inset-right)); top: 50%; width: var(--side); height: var(--side); box-sizing: border-box;
padding: 30px 20px 18px; border-radius: 2px 2px 3px 18px / 2px 2px 3px 10px; color: var(--ni); font-family: var(--nf), cursive; font-weight: var(--nw); display: flex; flex-direction: column; overflow: hidden; transform: translate(14px, -50%) rotate(var(--nt)); opacity: 0; transition: opacity .22s, transform .26s;
background: linear-gradient(180deg, rgb(0 0 0 / .07) 0 22px, transparent 22px), linear-gradient(160deg, rgb(255 255 255 / .18), transparent 40%), linear-gradient(0deg, rgb(0 0 0 / .1), transparent 30%), var(--np); padding: 34px 22px 18px; border-radius: 1px 1px 2px 14px / 1px 1px 2px 8px; color: var(--ni); font-family: var(--nf), cursive; font-weight: var(--nw);
background: linear-gradient(180deg, rgb(0 0 0 / .07) 0 24px, transparent 24px), linear-gradient(160deg, rgb(255 255 255 / .18), transparent 40%), linear-gradient(0deg, rgb(0 0 0 / .1), transparent 30%), var(--np);
box-shadow: 0 1px 1px rgb(0 0 0 / .18), 4px 14px 22px -8px rgb(0 0 0 / .55); } box-shadow: 0 1px 1px rgb(0 0 0 / .18), 4px 14px 22px -8px rgb(0 0 0 / .55); }
.shelfpeek.on { opacity: 1; transform: translate(0, -50%) rotate(var(--nt)); } .shelfpeek.on { opacity: 1; transform: translate(0, -50%) rotate(var(--nt)); }
.shelfpeek .ptitle { margin: 0 30px 6px 0; font-family: inherit; font-weight: inherit; font-size: calc(1.75rem * var(--ns)); line-height: 1.05; overflow-wrap: anywhere; } .shelfpeek .ptitle { margin: 0 26px 8px 0; font-family: inherit; font-weight: inherit; font-size: calc(clamp(1.3rem, 2.6vw, 1.8rem) * var(--ns)); line-height: 1.05; overflow-wrap: anywhere; }
.shelfpeek .psub, .shelfpeek .pwhere { margin: 4px 0; font-size: calc(1.15rem * var(--ns)); line-height: 1.2; opacity: .85; } .shelfpeek .psub, .shelfpeek .pwhere, .shelfpeek .phint { margin: 4px 0; font-size: calc(clamp(1rem, 1.7vw, 1.2rem) * var(--ns)); line-height: 1.2; }
.shelfpeek .pbar { height: 5px; margin: 12px 0 6px; border-radius: 3px; background: color-mix(in srgb, var(--ni) 16%, transparent); overflow: hidden; } .shelfpeek .psub { opacity: .8; }
.shelfpeek .pbar i { display: block; height: 100%; background: var(--ni); opacity: .75; border-radius: 3px; } .shelfpeek .phint { margin-top: auto; color: inherit; text-decoration: none; opacity: .75; }
.shelfpeek .pacts { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; } .shelfpeek .peekx { position: absolute; top: 2px; right: 4px; width: 30px; height: 30px; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: inherit; cursor: pointer; opacity: .6; }
.shelfpeek .pacts .btn { flex: 1 1 auto; justify-content: center; font-family: inherit; font-weight: inherit; font-size: calc(1.15rem * var(--ns)); background: transparent; color: var(--ni); border: 2px solid var(--ni); border-radius: 14px 10px 16px 9px / 10px 15px 9px 14px; box-shadow: none; }
.shelfpeek .pacts .btn.primary { background: var(--ni); color: var(--np); }
.shelfpeek .peekx { position: absolute; top: 4px; right: 6px; width: 32px; height: 32px; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: inherit; cursor: pointer; opacity: .7; }
@media (orientation: portrait) { @media (orientation: portrait) {
.shelfpeek { left: calc(14px + env(safe-area-inset-left)); right: calc(14px + env(safe-area-inset-right)); top: auto; bottom: calc(14px + env(safe-area-inset-bottom)); width: auto; transform: translateY(14px) rotate(var(--nt)); } .shelfpeek { --side: min(64vw, 300px); right: auto; left: 50%; top: auto; bottom: calc(18px + env(safe-area-inset-bottom)); transform: translate(-50%, 14px) rotate(var(--nt)); }
.shelfpeek.on { transform: rotate(var(--nt)); } .shelfpeek.on { transform: translate(-50%, 0) rotate(var(--nt)); }
.shelfpeek .ptitle { font-size: calc(1.45rem * var(--ns)); }
.shelfpeek .psub, .shelfpeek .pwhere, .shelfpeek .phint { font-size: calc(1.08rem * var(--ns)); }
} }
body.handing .reader .loading { visibility: hidden; } body.handing .reader .loading { visibility: hidden; }
.handoff { position: fixed; inset: 0; z-index: 60; width: 100vw; height: 100vh; object-fit: cover; pointer-events: none; transition: opacity .35s ease; } .handoff { position: fixed; inset: 0; z-index: 60; width: 100vw; height: 100vh; object-fit: cover; pointer-events: none; transition: opacity .35s ease; }
+4 -4
View File
@@ -5,15 +5,15 @@ export function setupShelf({ db, json, isBookId, downloadCover, MY_DOWNLOADS })
function works(userId) { function works(userId) {
const out = new Map() const out = new Map()
const add = (id, fields) => { const add = (id, fields) => {
const w = out.get(id) || { id, title: '', cover: null, last: 0, chapterId: null, page: 0, lang: null } const w = out.get(id) || { id, title: '', cover: null, last: 0, chapterId: null, chapterNum: null, page: 0, lang: null }
w.title ||= fields.title || '' w.title ||= fields.title || ''
w.cover ||= fields.cover || null w.cover ||= fields.cover || null
w.last = Math.max(w.last, fields.at || 0) w.last = Math.max(w.last, fields.at || 0)
if (fields.chapterId) Object.assign(w, { chapterId: fields.chapterId, page: fields.page || 0, lang: fields.lang || null }) if (fields.chapterId) Object.assign(w, { chapterId: fields.chapterId, chapterNum: fields.chapterNum ?? null, page: fields.page || 0, lang: fields.lang || null })
out.set(id, w) out.set(id, w)
} }
for (const r of db.prepare('SELECT manga_id, manga_title, cover, chapter_id, page, lang, updated_at FROM progress WHERE user_id = ?').all(userId)) { for (const r of db.prepare('SELECT manga_id, manga_title, cover, chapter_id, chapter_num, page, lang, updated_at FROM progress WHERE user_id = ?').all(userId)) {
add(r.manga_id, { title: r.manga_title, cover: r.cover, chapterId: r.chapter_id, page: r.page, lang: r.lang, at: r.updated_at }) add(r.manga_id, { title: r.manga_title, cover: r.cover, chapterId: r.chapter_id, chapterNum: r.chapter_num, page: r.page, lang: r.lang, at: r.updated_at })
} }
for (const r of db.prepare('SELECT manga_id, title, cover, last_new_at, created_at FROM follows WHERE user_id = ? AND active = 1').all(userId)) { for (const r of db.prepare('SELECT manga_id, title, cover, last_new_at, created_at FROM follows WHERE user_id = ? AND active = 1').all(userId)) {
add(r.manga_id, { title: r.title, cover: r.cover, at: r.last_new_at || r.created_at }) add(r.manga_id, { title: r.title, cover: r.cover, at: r.last_new_at || r.created_at })