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>
This commit is contained in:
2026-11-01 12:41:22 -06:00
co-authored by Claude Opus 5.5
parent 86999b5725
commit 29af893917
5 changed files with 237 additions and 14 deletions
+194 -11
View File
@@ -204,7 +204,8 @@ const readUrl = (mangaId, chapterId, page) => `#/read/${mangaId}/${chapterId}${p
// Requiere service worker, que el navegador solo permite con HTTPS (o en localhost). // Requiere service worker, que el navegador solo permite con HTTPS (o en localhost).
const OFFLINE_OK = 'serviceWorker' in navigator && window.isSecureContext && 'caches' in window const OFFLINE_OK = 'serviceWorker' in navigator && window.isSecureContext && 'caches' in window
const OFF_CACHE = 'offline-v1' const OFF_CACHE = 'offline-v1'
if (OFFLINE_OK) navigator.serviceWorker.register('/sw.js').catch(() => {}) // Con versión en la URL: si un proxy guarda sw.js en caché, cada versión nueva tiene otra URL.
if (OFFLINE_OK) navigator.serviceWorker.register(`/sw.js?v=${document.querySelector('meta[name="sw-version"]')?.content || ''}`).catch(() => {})
const offIdx = () => store.get('offlineChapters', {}) // chapterId -> { mangaId, mangaTitle, cover, lang, label, num, pages, bytes, at } const offIdx = () => store.get('offlineChapters', {}) // chapterId -> { mangaId, mangaTitle, cover, lang, label, num, pages, bytes, at }
const offHas = id => !!offIdx()[id] const offHas = id => !!offIdx()[id]
const offState = { running: null, queue: [], listeners: new Set(), cancelled: new Set(), abort: null } const offState = { running: null, queue: [], listeners: new Set(), cancelled: new Set(), abort: null }
@@ -1061,6 +1062,7 @@ async function viewManga(id, langOverride) {
// ---------- Lector ---------- // ---------- Lector ----------
const READ_MODES = { paged: 'Una página', double: 'Doble página', vertical: 'Vertical' } const READ_MODES = { paged: 'Una página', double: 'Doble página', vertical: 'Vertical' }
const PAGE_ANIMS = { flip: 'Pasar hoja', slide: 'Deslizar', fade: 'Fundido', none: 'Sin animación' }
// Ajustes por defecto según el tipo de obra (se usan hasta que el usuario cambie algo en ese manga). // Ajustes por defecto según el tipo de obra (se usan hasta que el usuario cambie algo en ese manga).
function defaultPrefs(manga) { function defaultPrefs(manga) {
if (['Manhwa', 'Manhua', 'Webtoon'].includes(manga.type)) return { mode: 'vertical', fit: 'width', dir: 'ltr' } if (['Manhwa', 'Manhua', 'Webtoon'].includes(manga.type)) return { mode: 'vertical', fit: 'width', dir: 'ltr' }
@@ -1074,7 +1076,8 @@ async function viewReader(mangaId, chapterId, startPage) {
app.innerHTML = '<div class="reader"><div class="loading">Cargando capítulo…</div></div>' app.innerHTML = '<div class="reader"><div class="loading">Cargando capítulo…</div></div>'
const lang = store.get(`lang:${mangaId}`, me.lang) const lang = store.get(`lang:${mangaId}`, me.lang)
// Filtro, brillo y ahorro de datos son globales (navegador); modo, ajuste y dirección van por manga (servidor). // Filtro, brillo y ahorro de datos son globales (navegador); modo, ajuste y dirección van por manga (servidor).
const globalSettings = { saver: false, filter: 'none', brightness: 100, ...store.get('readerSettings', {}) } const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches
const globalSettings = { saver: false, filter: 'none', brightness: 100, anim: reduceMotion ? 'fade' : 'flip', ...store.get('readerSettings', {}) }
const [info, chap] = await Promise.all([ const [info, chap] = await Promise.all([
api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`), api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`),
api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}&hd=${globalSettings.hd === false ? 0 : 1}`), api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}&hd=${globalSettings.hd === false ? 0 : 1}`),
@@ -1123,6 +1126,7 @@ async function viewReader(mangaId, chapterId, startPage) {
<select id="mode" title="Modo de lectura (se recuerda para este manga)">${Object.entries(READ_MODES).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select> <select id="mode" title="Modo de lectura (se recuerda para este manga)">${Object.entries(READ_MODES).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
<select id="fit" title="Ajuste (se recuerda para este manga)"><option value="height">Ajustar alto</option><option value="width">Ajustar ancho</option></select> <select id="fit" title="Ajuste (se recuerda para este manga)"><option value="height">Ajustar alto</option><option value="width">Ajustar ancho</option></select>
<select id="dir" title="Dirección (se recuerda para este manga)"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select> <select id="dir" title="Dirección (se recuerda para este manga)"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select>
<select id="anim" title="Animación al pasar página (modos por página)">${Object.entries(PAGE_ANIMS).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
<select id="filter" title="Filtro">${Object.entries(FILTERS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select> <select id="filter" title="Filtro">${Object.entries(FILTERS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
<label class="row muted" style="font-size:.85rem" title="Brillo">☀ <input type="range" id="bright" min="40" max="120" step="5" style="width:90px"></label> <label class="row muted" style="font-size:.85rem" title="Brillo">☀ <input type="range" id="bright" min="40" max="120" step="5" style="width:90px"></label>
${manga.source !== 'md' || chap.local ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'} ${manga.source !== 'md' || chap.local ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
@@ -1200,11 +1204,16 @@ async function viewReader(mangaId, chapterId, startPage) {
im.src = pages[i] im.src = pages[i]
} }
} }
function showPaged() { // dir: 1 hacia delante, -1 hacia atrás, 0 sin animación (salto con la barra, cambio de modo…).
let stage = $('.stage', box) function showPaged(dir = 0) {
if (!stage) { box.innerHTML = '<div class="stage"></div>'; stage = $('.stage', box) } finishTurn()
const old = $('.stage', box)
const stage = document.createElement('div')
stage.className = 'stage'
stage.classList.toggle('two', shown.length === 2) stage.classList.toggle('two', shown.length === 2)
stage.innerHTML = shown.map(i => `<img data-i="${i}" src="${pages[i]}" alt="" draggable="false">`).join('') stage.innerHTML = shown.map(i => `<img data-i="${i}" src="${pages[i]}" alt="" draggable="false">`).join('')
if (old && dir && settings.anim !== 'none' && zoom.s === 1) turnPage(old, stage, dir)
else { box.replaceChildren(stage) }
stage.querySelectorAll('img').forEach(img => img.addEventListener('load', () => { stage.querySelectorAll('img').forEach(img => img.addEventListener('load', () => {
const i = Number(img.dataset.i) const i = Number(img.dataset.i)
if (isWide(img) && !wide.has(i)) { if (isWide(img) && !wide.has(i)) {
@@ -1216,24 +1225,191 @@ async function viewReader(mangaId, chapterId, startPage) {
window.scrollTo(0, 0) window.scrollTo(0, 0)
preload() preload()
} }
// --- Animación al pasar página. La página nueva se pone debajo y la vieja (o una hoja) se anima encima;
// al terminar solo queda la nueva. Si se pasa otra página antes de acabar, la animación se completa ya.
let turning = null // { anims, cleanup }
function finishTurn() {
if (!turning) return
const t = turning
turning = null
t.anims.forEach(a => { try { a.cancel() } catch {} })
t.cleanup()
}
// Animación hecha a mano (requestAnimationFrame) con la misma forma que las de la Web Animations API
// que usa finishTurn: cancel() y la promesa finished.
function frameAnim(duration, step) {
let raf = 0, settle
const finished = new Promise((res, rej) => { settle = { res, rej } })
const t0 = performance.now()
const tick = now => {
const g = Math.min(1, (now - t0) / duration)
step(g)
if (g < 1) raf = requestAnimationFrame(tick)
else settle.res()
}
step(0)
raf = requestAnimationFrame(tick)
return { finished, cancel() { cancelAnimationFrame(raf); settle.rej(new Error('cancelada')) } }
}
// Hoja de papel que se dobla: tiras verticales anidadas, cada una girada un poco respecto a la anterior,
// así que la hoja forma una curva. Cada tira enseña su trozo de la página por delante y del reverso
// (la página siguiente en doble página, o papel en blanco) por detrás.
function paperCurl(r, frontSrc, backSrc, spineLeft) {
const N = Math.max(10, Math.min(22, Math.round(r.w / 24)))
const w = r.w / N
const sgn = spineLeft ? -1 : 1 // hacia dónde gira: el borde libre se levanta hacia quien mira
const el = document.createElement('div')
el.className = 'curl'
const strips = []
let parent = el
for (let i = 0; i < N; i++) {
const fc = spineLeft ? i : N - 1 - i // columna de la página (desde la izquierda) de esta tira
const bc = N - 1 - fc // en el reverso, la hoja está dada la vuelta
const s = document.createElement('div')
s.className = 'strip'
Object.assign(s.style, { width: `${w}px`, height: `${r.h}px`, [spineLeft ? 'left' : 'right']: '0', transformOrigin: spineLeft ? 'left center' : 'right center' })
const img = (src, col) => `<img src="${src}" alt="" style="width:${r.w}px;height:${r.h}px;left:${-col * w}px">`
s.innerHTML = `<div class="sf front">${img(frontSrc, fc)}<i></i></div><div class="sf back${backSrc ? '' : ' paper'}">${backSrc ? img(backSrc, bc) : ''}<i></i></div>`
parent.append(s)
strips.push({ s, f: s.firstChild.lastChild, b: s.children[1].lastChild })
parent = s
}
const step = spineLeft ? w : -w
// Ángulo (0 = en su sitio, 180 = pasada) de cada tira en el avance g (0 a 1). El borde libre va por
// delante del lomo y la curva es máxima a mitad de camino. reverse: la hoja vuelve a su sitio (el
// mismo gesto al revés, con el borde libre llegando primero).
function angles(g, reverse) {
const e = g < 0.5 ? 4 * g ** 3 : 1 - (-2 * g + 2) ** 3 / 2
const bend = Math.sin(Math.PI * g) * 125
return strips.map((t, i) => {
const a = Math.min(180, Math.max(0, 180 * e + bend * (((i + 0.5) / N) ** 1.4 - 0.45)))
return reverse ? 180 - a : a
})
}
// Devuelve cuánto está levantada la hoja (0 a 1), para la sombra de debajo.
function set(g, reverse) {
let prev = 0, lift = 0
angles(g, reverse).forEach((a, i) => {
const t = strips[i]
t.s.style.transform = `${i ? `translateX(${step}px) ` : ''}rotateY(${(sgn * (a - prev)).toFixed(2)}deg)`
prev = a
const c = Math.cos((a * Math.PI) / 180)
t.f.style.opacity = ((1 - c) * 0.3).toFixed(3) // se oscurece al inclinarse
t.b.style.opacity = ((1 + c) * 0.3).toFixed(3)
lift = Math.max(lift, Math.sin((a * Math.PI) / 180))
})
return lift
}
// Tramo de g en el que se ve algo de la hoja sobre la página (alguna tira por debajo de 90°): con una
// sola página, lo que pasa del lomo queda fuera de la pantalla y no vale la pena esperarlo.
function span(reverse) {
const seen = g => angles(g, reverse).some(a => a < 88)
let g0 = 0, g1 = 1
while (g0 < 1 && !seen(g0)) g0 += 0.01
while (g1 > g0 && !seen(g1)) g1 -= 0.01
return [Math.max(0, g0 - 0.02), Math.min(1, g1 + 0.04)]
}
return { el, set, span }
}
function turnPage(old, stage, dir) {
// "Delante" es hacia la izquierda en izq→der (la hoja derecha pasa a la izquierda) y al revés en der→izq.
const sign = (settings.dir === 'rtl' ? -1 : 1) * dir
const fitWidth = box.classList.contains('fit-width')
let kind = settings.anim
const ready = im => im.complete && im.naturalWidth > 0
const oldImgs = [...old.querySelectorAll('img')]
// La hoja necesita las páginas ya cargadas y la misma disposición (1↔1 o 2↔2); si no, deslizar.
if (kind === 'flip' && (fitWidth || !oldImgs.every(ready))) kind = 'slide'
if (fitWidth && kind === 'slide') kind = 'fade' // páginas largas con scroll: un fundido queda mejor
old.classList.add('leaving')
box.prepend(stage) // la nueva debajo (antes en el DOM), la vieja encima
const double = oldImgs.length === 2 && shown.length === 2
// Una página hacia atrás: la hoja que vuelve es la nueva, que tiene que estar ya cargada.
if (kind === 'flip' && !double && dir < 0 && !ready(stage.querySelector('img'))) kind = 'slide'
const anims = []
const extra = []
const cleanup = () => { old.remove(); extra.forEach(e => e.remove()) }
const ease = 'cubic-bezier(.2,.7,.2,1)'
if (kind === 'fade') {
anims.push(old.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 180, easing: 'ease-out', fill: 'forwards' }))
} else if (kind === 'slide') {
anims.push(old.animate([{ transform: 'translateX(0)' }, { transform: `translateX(${-sign * 100}%)` }], { duration: 260, easing: ease, fill: 'forwards' }))
anims.push(stage.animate([{ transform: `translateX(${sign * 100}%)` }, { transform: 'translateX(0)' }], { duration: 260, easing: ease }))
} else {
// Pasar hoja: la hoja se dobla como papel (se levanta primero el borde libre y el lomo la sigue).
// Medidas relativas al contenedor.
const B = box.getBoundingClientRect()
const rel = im => { const r = im.getBoundingClientRect(); return { l: r.left - B.left, t: r.top - B.top, w: r.width, h: r.height } }
const byX = imgs => [...imgs].sort((a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left)
const place = (el, r) => Object.assign(el.style, { left: `${r.l}px`, top: `${r.t}px`, width: `${r.w}px`, height: `${r.h}px` })
let rect, frontSrc, backSrc = null, spineLeft, reverse = false
if (double) {
// Doble página: gira la hoja del lado hacia el que se avanza (derecha si sign > 0); su reverso es
// la página nueva del otro lado del lomo, y la página vieja de ese lado sigue a la vista (copia
// encima de la nueva) hasta que la hoja cae.
const oldX = byX(oldImgs)
const newX = byX(stage.querySelectorAll('img'))
const front = sign > 0 ? oldX.at(-1) : oldX[0]
const stay = sign > 0 ? oldX[0] : oldX.at(-1)
rect = rel(front)
frontSrc = front.src
backSrc = (sign > 0 ? newX[0] : newX.at(-1)).src
spineLeft = sign > 0
const keep = document.createElement('img')
keep.className = 'leafkeep'
keep.src = stay.src
place(keep, rel(stay))
box.append(keep)
extra.push(keep)
old.style.visibility = 'hidden' // la hoja y la copia ocupan su lugar
} else {
// Una página: el lomo está a la izquierda en izq→der y a la derecha en der→izq. Hacia delante, la
// página actual se levanta y se va; hacia atrás, la anterior vuelve y se coloca encima (el mismo
// gesto al revés, con el borde libre por delante), con la actual debajo hasta que la tapa.
const img = dir > 0 ? oldImgs[0] : stage.querySelector('img')
rect = rel(img)
frontSrc = img.src
spineLeft = settings.dir !== 'rtl'
reverse = dir < 0
if (dir > 0) old.style.visibility = 'hidden'
}
// Sombra que la hoja levantada proyecta sobre lo que hay debajo.
const drop = document.createElement('i')
drop.className = `curldrop ${spineLeft ? 'l' : 'r'}`
place(drop, rect)
const curl = paperCurl(rect, frontSrc, backSrc, spineLeft)
place(curl.el, rect)
box.append(drop, curl.el)
extra.push(drop, curl.el)
const [g0, g1] = double ? [0, 1] : curl.span(reverse)
anims.push(frameAnim(double ? 700 : 560, t => {
const lift = curl.set(g0 + (g1 - g0) * t, reverse)
drop.style.opacity = (lift * 0.8).toFixed(3)
}))
}
turning = { anims, cleanup }
Promise.all(anims.map(a => a.finished)).then(() => { if (turning?.anims === anims) { turning = null; cleanup() } }, () => {})
}
function showEnd() { function showEnd() {
finishTurn()
atEnd = true atEnd = true
resetZoom() resetZoom()
box.innerHTML = endBlock box.innerHTML = endBlock
reader.classList.remove('hide-ui') reader.classList.remove('hide-ui')
} }
function goTo(n, fromScroll = false, force = false) { function goTo(n, fromScroll = false, force = false, dir = 0) {
if (isPaged()) { if (isPaged()) {
if (n >= total) return showEnd() if (n >= total) return showEnd()
n = Math.min(Math.max(n, 0), total - 1) n = Math.min(Math.max(n, 0), total - 1)
const g = groupOf(n) const g = groupOf(n)
const changed = atEnd || g[0] !== shown[0] || g.length !== shown.length const changed = atEnd || g[0] !== shown[0] || g.length !== shown.length
const wasEnd = atEnd
atEnd = false atEnd = false
shown = g shown = g
page = g[0] page = g[0]
setPageLabel() setPageLabel()
if (changed || force || !$('.stage img', box)) showPaged() if (changed || force || !$('.stage img', box)) showPaged(changed && !wasEnd ? dir : 0)
if (changed) scheduleSave() if (changed) scheduleSave()
return return
} }
@@ -1245,10 +1421,10 @@ async function viewReader(mangaId, chapterId, startPage) {
if (!fromScroll) box.querySelectorAll('img')[page]?.scrollIntoView() if (!fromScroll) box.querySelectorAll('img')[page]?.scrollIntoView()
if (changed) scheduleSave() if (changed) scheduleSave()
} }
const forward = () => { if (!atEnd) goTo(shown.at(-1) + 1) } const forward = () => { if (!atEnd) goTo(shown.at(-1) + 1, false, false, 1) }
const backward = () => { const backward = () => {
if (atEnd) { box.innerHTML = ''; goTo(total - 1, false, true); return } if (atEnd) { box.innerHTML = ''; goTo(total - 1, false, true); return }
if (page > 0) goTo(groupOf(page - 1)[0]) if (page > 0) goTo(groupOf(page - 1)[0], false, false, -1)
} }
function render() { function render() {
@@ -1296,7 +1472,7 @@ async function viewReader(mangaId, chapterId, startPage) {
// --- Zoom (modos por página): pellizco, doble toque/clic en el centro, Ctrl+rueda y teclas + - 0. // --- Zoom (modos por página): pellizco, doble toque/clic en el centro, Ctrl+rueda y teclas + - 0.
const zoom = { s: 1, x: 0, y: 0 } const zoom = { s: 1, x: 0, y: 0 }
const stageEl = () => $('.stage', box) const stageEl = () => $('.stage:not(.leaving)', box)
function applyZoom() { function applyZoom() {
const st = stageEl() const st = stageEl()
if (st) st.style.transform = zoom.s === 1 ? '' : `translate(${zoom.x}px, ${zoom.y}px) scale(${zoom.s})` if (st) st.style.transform = zoom.s === 1 ? '' : `translate(${zoom.x}px, ${zoom.y}px) scale(${zoom.s})`
@@ -1384,6 +1560,11 @@ async function viewReader(mangaId, chapterId, startPage) {
gesture = { type: 'pan', x: p.x, y: p.y } gesture = { type: 'pan', x: p.x, y: p.y }
} else if (pts.size === 0) { } else if (pts.size === 0) {
if (e.type === 'pointerup' && tap && !tap.moved && Date.now() - tap.t < 350) onTap(e.clientX, e.clientY) if (e.type === 'pointerup' && tap && !tap.moved && Date.now() - tap.t < 350) onTap(e.clientX, e.clientY)
else if (e.type === 'pointerup' && tap?.moved && zoom.s === 1 && e.pointerType !== 'mouse') {
// Deslizar el dedo en horizontal pasa página (hacia la izquierda = adelante en izq→der).
const dx = e.clientX - tap.x, dy = e.clientY - tap.y
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && Date.now() - tap.t < 800) ((dx < 0) === (settings.dir === 'ltr') ? forward : backward)()
}
gesture = null gesture = null
tap = null tap = null
} }
@@ -1403,7 +1584,7 @@ async function viewReader(mangaId, chapterId, startPage) {
$('#zoomreset').addEventListener('click', resetZoom) $('#zoomreset').addEventListener('click', resetZoom)
$('#ropts').addEventListener('click', () => { const p = $('.rpanel', reader); p.hidden = !p.hidden; $('#ropts').classList.toggle('on', !p.hidden) }) $('#ropts').addEventListener('click', () => { const p = $('.rpanel', reader); p.hidden = !p.hidden; $('#ropts').classList.toggle('on', !p.hidden) })
const saveGlobal = () => store.set('readerSettings', { saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false }) const saveGlobal = () => store.set('readerSettings', { saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false, anim: settings.anim })
const savePrefs = () => { const savePrefs = () => {
api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } }) api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } })
.then(() => toast('Guardado para este manga')).catch(err => toast(err.message)) .then(() => toast('Guardado para este manga')).catch(err => toast(err.message))
@@ -1417,6 +1598,8 @@ async function viewReader(mangaId, chapterId, startPage) {
$('#fit').addEventListener('change', e => { settings.fit = e.target.value; savePrefs(); render() }) $('#fit').addEventListener('change', e => { settings.fit = e.target.value; savePrefs(); render() })
$('#dir').addEventListener('change', e => { settings.dir = e.target.value; box.dataset.dir = settings.dir; slider.dir = settings.dir; savePrefs() }) $('#dir').addEventListener('change', e => { settings.dir = e.target.value; box.dataset.dir = settings.dir; slider.dir = settings.dir; savePrefs() })
$('#filter').addEventListener('change', e => { settings.filter = e.target.value; saveGlobal(); applyFilter() }) $('#filter').addEventListener('change', e => { settings.filter = e.target.value; saveGlobal(); applyFilter() })
$('#anim').value = settings.anim
$('#anim').addEventListener('change', e => { settings.anim = e.target.value; saveGlobal() })
$('#bright').addEventListener('input', e => { settings.brightness = Number(e.target.value); saveGlobal(); applyFilter() }) $('#bright').addEventListener('input', e => { settings.brightness = Number(e.target.value); saveGlobal(); applyFilter() })
$('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; saveGlobal(); applyFilter() }) $('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; saveGlobal(); applyFilter() })
$('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); router() }) $('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); router() })
+1
View File
@@ -2,6 +2,7 @@
<html lang="es"> <html lang="es">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="sw-version" content="">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#14121a"> <meta name="theme-color" content="#14121a">
<title>Manga Reader</title> <title>Manga Reader</title>
+16
View File
@@ -352,3 +352,19 @@ body.noscroll { overflow: hidden; }
background: #3a2a12; color: #ffd9a0; border-bottom: 1px solid #5a4220; font-size: .9rem; } background: #3a2a12; color: #ffd9a0; border-bottom: 1px solid #5a4220; font-size: .9rem; }
#netbanner svg { flex: none; width: 18px; height: 18px; } #netbanner svg { flex: none; width: 18px; height: 18px; }
body.reading #netbanner { display: none; } body.reading #netbanner { display: none; }
/* Animación al pasar página (modos por página) */
.paged { position: relative; perspective: 2200px; }
.paged .stage.leaving { position: absolute; inset: 0; z-index: 1; pointer-events: none; background: #000; }
.paged.fit-width .stage.leaving { bottom: auto; }
.paged .curl { position: absolute; z-index: 3; transform-style: preserve-3d; pointer-events: none; }
.paged .curl .strip { position: absolute; top: 0; transform-style: preserve-3d; will-change: transform; }
.paged .curl .sf { position: absolute; top: 0; bottom: 0; left: -0.5px; right: -0.5px; overflow: hidden; backface-visibility: hidden; -webkit-backface-visibility: hidden; }
.paged .curl .sf img { position: absolute; top: 0; max-width: none; max-height: none; object-fit: fill; display: block; }
.paged .curl .back { transform: rotateY(180deg); }
.paged .curl .back.paper { background: #e9e5dc; }
.paged .curl .sf i { position: absolute; inset: 0; background: #000; opacity: 0; }
.paged .curldrop { position: absolute; z-index: 2; pointer-events: none; opacity: 0; }
.paged .curldrop.l { background: linear-gradient(90deg, rgba(0,0,0,.55), rgba(0,0,0,.2) 35%, rgba(0,0,0,0) 75%); }
.paged .curldrop.r { background: linear-gradient(270deg, rgba(0,0,0,.55), rgba(0,0,0,.2) 35%, rgba(0,0,0,0) 75%); }
.paged .leafkeep { position: absolute; z-index: 2; pointer-events: none; max-width: none; max-height: none; object-fit: fill; }
+17 -3
View File
@@ -4,8 +4,20 @@ const SHELL = 'shell-v1'
const OFFLINE = 'offline-v1' const OFFLINE = 'offline-v1'
const SHELL_FILES = ['/', '/app.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png'] const SHELL_FILES = ['/', '/app.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png']
// Se guarda la portada tal como la sirve el servidor y justo las versiones de app.js y style.css que
// enlaza (?v=…); cache: 'reload' para no copiar lo que tenga la caché HTTP (un proxy puede alargarla horas).
async function installShell() {
const c = await caches.open(SHELL)
const html = await fetch('/', { cache: 'reload' })
const text = await html.clone().text()
const linked = [...text.matchAll(/(?:src|href)="(\/(?:app\.js|style\.css)\?v=[^"]+)"/g)].map(m => m[1])
const files = [...linked, ...SHELL_FILES.filter(f => f !== '/' && !linked.some(l => l.startsWith(f + '?')))]
for (const f of files) await c.delete(f, { ignoreSearch: true })
await c.addAll(files.map(u => new Request(u, { cache: 'reload' })))
await c.put('/', html)
}
self.addEventListener('install', e => { self.addEventListener('install', e => {
e.waitUntil(caches.open(SHELL).then(c => c.addAll(SHELL_FILES)).then(() => self.skipWaiting())) e.waitUntil(installShell().then(() => self.skipWaiting()))
}) })
self.addEventListener('activate', e => { self.addEventListener('activate', e => {
e.waitUntil(caches.keys() e.waitUntil(caches.keys()
@@ -25,11 +37,13 @@ async function fromCache(res) {
headers.set('x-from-cache', '1') headers.set('x-from-cache', '1')
return new Response(await res.blob(), { status: res.status, statusText: res.statusText, headers }) return new Response(await res.blob(), { status: res.status, statusText: res.statusText, headers })
} }
async function networkFirst(req, cacheName, { store = true, ignoreSearch = false, fallback } = {}) { // single: guardar solo la última versión (app.js?v=… sustituye a la anterior).
async function networkFirst(req, cacheName, { store = true, ignoreSearch = false, fallback, single = false } = {}) {
const cached = () => caches.match(req, { ignoreSearch, cacheName }).then(hit => hit || (fallback && caches.match(fallback))) const cached = () => caches.match(req, { ignoreSearch, cacheName }).then(hit => hit || (fallback && caches.match(fallback)))
const net = fetch(req).then(async res => { const net = fetch(req).then(async res => {
if (store && res.ok) { if (store && res.ok) {
const c = await caches.open(cacheName) const c = await caches.open(cacheName)
if (single) await c.delete(req, { ignoreSearch: true })
if (store === true || (await c.match(req, { ignoreSearch }))) await c.put(req, res.clone()) if (store === true || (await c.match(req, { ignoreSearch }))) await c.put(req, res.clone())
} }
return res return res
@@ -54,7 +68,7 @@ self.addEventListener('fetch', e => {
if (url.origin !== location.origin) return if (url.origin !== location.origin) return
const p = url.pathname const p = url.pathname
if (req.mode === 'navigate') return e.respondWith(networkFirst(req, SHELL, { fallback: '/' })) if (req.mode === 'navigate') return e.respondWith(networkFirst(req, SHELL, { fallback: '/' }))
if (SHELL_FILES.includes(p) || p === '/sw.js') return e.respondWith(networkFirst(req, SHELL)) if (SHELL_FILES.includes(p) || p === '/sw.js') return e.respondWith(networkFirst(req, SHELL, { ignoreSearch: true, single: true }))
if (p === '/api/me') return e.respondWith(networkFirst(req, SHELL)) if (p === '/api/me') return e.respondWith(networkFirst(req, SHELL))
// Ficha y capítulo: solo se guardan si el usuario los guardó (store: 'existing' = refrescar si ya estaban). // Ficha y capítulo: solo se guardan si el usuario los guardó (store: 'existing' = refrescar si ya estaban).
if (p.startsWith('/api/manga/')) return e.respondWith(networkFirst(req, OFFLINE, { store: 'existing', ignoreSearch: true })) if (p.startsWith('/api/manga/')) return e.respondWith(networkFirst(req, OFFLINE, { store: 'existing', ignoreSearch: true }))
+9
View File
@@ -765,10 +765,19 @@ const requireUser = req => {
} }
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.webmanifest': 'application/manifest+json', '.png': 'image/png' } const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.webmanifest': 'application/manifest+json', '.png': 'image/png' }
// index.html enlaza app.js y style.css con ?v=<hash del contenido>: si un proxy delante cambia el
// no-cache por una caché larga (Nginx Proxy Manager con «Cache Assets»), cada versión nueva tiene otra
// URL y el navegador no se queda con la vieja. Los archivos no cambian mientras el contenedor vive.
const assetVersion = name => crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, name))).digest('hex').slice(0, 10)
const INDEX_HTML = fs.readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8')
.replace('src="/app.js"', `src="/app.js?v=${assetVersion('app.js')}"`)
.replace('href="/style.css"', `href="/style.css?v=${assetVersion('style.css')}"`)
.replace('<meta name="sw-version" content="">', `<meta name="sw-version" content="${assetVersion('sw.js')}">`)
function serveStatic(res, pathname) { function serveStatic(res, pathname) {
let file = path.join(PUBLIC_DIR, path.normalize(pathname).replace(/^(\.\.[/\\])+/, '')) let file = path.join(PUBLIC_DIR, path.normalize(pathname).replace(/^(\.\.[/\\])+/, ''))
if (!file.startsWith(PUBLIC_DIR) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) file = path.join(PUBLIC_DIR, 'index.html') if (!file.startsWith(PUBLIC_DIR) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) file = path.join(PUBLIC_DIR, 'index.html')
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache' }) res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache' })
if (file === path.join(PUBLIC_DIR, 'index.html')) return res.end(INDEX_HTML)
fs.createReadStream(file).pipe(res) fs.createReadStream(file).pipe(res)
} }