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>
This commit is contained in:
2026-11-01 07:52:13 -06:00
co-authored by Claude Opus 5.5
parent 9086896a89
commit d3e196a2f3
16 changed files with 931 additions and 59 deletions
+402 -31
View File
@@ -38,6 +38,11 @@ const ICONS = {
scissors: '<circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M20 4 8.1 15.9M14.5 14.5 20 20M8.1 8.1 12 12"/>',
image: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-5-5L5 21"/>',
queue: '<path d="M3 6h13M3 12h13M3 18h9"/><path d="m17 15 3 3-3 3"/><path d="M20 18h-6"/>',
library: '<path d="M4 4v16M9 4v16"/><path d="m14 4.5 4.5 15.2"/><path d="M3 20h18"/>',
bookmark: '<path d="M6 3h12v18l-6-4-6 4z"/>',
phone: '<rect x="6" y="2" width="12" height="20" rx="2.5"/><path d="M11 18h2"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
pencil: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13.5 6.5 4 4"/>',
sparkles: '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>',
}
const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`
@@ -89,6 +94,13 @@ let cleanup = null
// ---------- Utilidades ----------
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
const $ = (sel, root = document) => root.querySelector(sel)
// Portadas que no cargan: icono de la app (la CSP no permite onerror="…" en el HTML).
document.addEventListener('error', e => {
const img = e.target
if (img.tagName !== 'IMG' || !img.hasAttribute('data-fallback') || img.dataset.failed) return
img.dataset.failed = '1'
img.src = '/icon.svg'
}, true)
const store = {
get(k, d) { try { return JSON.parse(localStorage.getItem(k)) ?? d } catch { return d } },
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)) } catch {} },
@@ -121,6 +133,107 @@ function timeAgo(ts) {
const chapLabel = c => (c.num ? `Cap. ${c.num}` : 'Oneshot') + (c.title ? ` — ${c.title}` : '')
const readUrl = (mangaId, chapterId, page) => `#/read/${mangaId}/${chapterId}${page != null ? `?page=${page + 1}` : ''}`
// ---------- Sin conexión (capítulos guardados en este dispositivo) ----------
// 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 OFF_CACHE = 'offline-v1'
if (OFFLINE_OK) navigator.serviceWorker.register('/sw.js').catch(() => {})
const offIdx = () => store.get('offlineChapters', {}) // chapterId -> { mangaId, mangaTitle, cover, lang, label, num, pages, bytes, at }
const offHas = id => !!offIdx()[id]
const offState = { running: null, queue: [], listeners: new Set() }
const offEmit = () => offState.listeners.forEach(f => { try { f() } catch {} })
// Capítulos guardados agrupados por manga.
function offByManga() {
const g = new Map()
for (const [chapterId, c] of Object.entries(offIdx())) {
const m = g.get(c.mangaId) || { mangaId: c.mangaId, title: c.mangaTitle, cover: c.cover, chapters: [], bytes: 0, at: 0 }
m.chapters.push({ chapterId, ...c }); m.bytes += c.bytes || 0; m.at = Math.max(m.at, c.at)
g.set(c.mangaId, m)
}
return [...g.values()].sort((a, b) => b.at - a.at)
}
// items: [{ mangaId, mangaTitle, cover, lang, chapterId, label, num }]
function offEnqueue(items) {
for (const it of items) if (!offHas(it.chapterId) && !offState.queue.some(q => q.chapterId === it.chapterId) && offState.running?.chapterId !== it.chapterId) offState.queue.push(it)
navigator.storage?.persist?.().catch(() => {})
offEmit()
offRun()
}
async function offRun() {
if (offState.running) return
while (offState.queue.length) {
const it = offState.queue.shift()
offState.running = { ...it, done: 0, total: 0 }
offEmit()
try { await offSaveChapter(it) } catch (err) { toast(`No se pudo guardar ${it.label}: ${err.message}`) }
}
offState.running = null
offEmit()
}
async function offSaveChapter(it) {
const cache = await caches.open(OFF_CACHE)
const hd = store.get('readerSettings', {}).hd !== false
const chapUrl = `/api/chapter/${it.chapterId}?saver=0&hd=${hd ? 1 : 0}`
const res = await fetch(chapUrl)
if (!res.ok) throw new Error(`Error ${res.status}`)
const data = await res.clone().json()
await cache.put(chapUrl, res)
const mUrl = `/api/manga/${it.mangaId}?lang=${encodeURIComponent(it.lang || '')}`
const mres = await fetch(mUrl).catch(() => null)
if (mres?.ok) await cache.put(mUrl, mres)
if (it.cover) { const c = await fetch(it.cover).catch(() => null); if (c?.ok) await cache.put(it.cover, c) }
offState.running.total = data.pages.length
let bytes = 0, i = 0
const worker = async () => {
while (i < data.pages.length) {
const u = data.pages[i++]
const r = await fetch(u)
if (!r.ok) throw new Error(`página ${r.status}`)
const blob = await r.blob()
bytes += blob.size
await cache.put(u, new Response(blob, { headers: { 'Content-Type': r.headers.get('Content-Type') || 'image/jpeg' } }))
offState.running.done++
offEmit()
}
}
await Promise.all([worker(), worker(), worker()])
const idx = offIdx()
idx[it.chapterId] = { mangaId: it.mangaId, mangaTitle: it.mangaTitle, cover: it.cover, lang: it.lang, label: it.label, num: it.num ?? null, pages: data.pages.length, bytes, at: Date.now() }
store.set('offlineChapters', idx)
}
async function offRemove(chapterIds) {
const cache = await caches.open(OFF_CACHE)
const idx = offIdx()
for (const id of chapterIds) {
const hit = await cache.match(`/api/chapter/${id}`, { ignoreSearch: true })
if (hit) {
const data = await hit.json().catch(() => ({ pages: [] }))
await Promise.all((data.pages || []).map(u => cache.delete(u)))
await cache.delete(`/api/chapter/${id}`, { ignoreSearch: true })
}
const mangaId = idx[id]?.mangaId
delete idx[id]
if (mangaId && !Object.values(idx).some(c => c.mangaId === mangaId)) await cache.delete(`/api/manga/${mangaId}`, { ignoreSearch: true })
}
store.set('offlineChapters', idx)
offEmit()
}
// Progreso leído sin conexión: se guarda aquí y se envía al volver la red.
function queueProgress(p) { const q = store.get('pendingProgress', {}); q[p.mangaId] = p; store.set('pendingProgress', q) }
async function flushProgress() {
const q = store.get('pendingProgress', {})
for (const [k, p] of Object.entries(q)) {
try { await api('/api/progress', { method: 'POST', body: p }); delete q[k] } catch { if (!navigator.onLine) break; delete q[k] } // con red y error: descartarlo
}
store.set('pendingProgress', q)
}
window.addEventListener('online', flushProgress)
setTimeout(flushProgress, 3000)
// Instalar como app (Chrome/Edge/Android ofrecen el aviso; se guarda para el botón de Ajustes).
let installPrompt = null
window.addEventListener('beforeinstallprompt', e => { e.preventDefault(); installPrompt = e })
// ---------- Router ----------
let prevHash = '', curHash = '' // para volver atrás sin pasar por el lector
async function router() {
@@ -142,7 +255,8 @@ async function router() {
if (parts[0] === 'login') return (location.hash = '#/')
if (parts[0] === 'manga') return await viewManga(parts[1])
if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1)
if (parts[0] === 'history') return await viewHistory()
if (parts[0] === 'history') return (location.hash = '#/library') // antiguo nombre
if (parts[0] === 'library') return await viewLibrary(params)
if (parts[0] === 'downloads') return (location.hash = '#/queues') // antiguo nombre
if (parts[0] === 'queues') return await viewQueues()
if (parts[0] === 'stats') return await viewStats()
@@ -235,6 +349,20 @@ function bindNews() {
})
}
const tagHref = (source, t) => `#/?source=${source}&tag=${encodeURIComponent(t.id)}&tagName=${encodeURIComponent(t.name)}`
// Recomendaciones según los géneros que más lees (las calcula el servidor, caché de 6 h).
async function loadRecs(refresh = false) {
if (!$('#recs')) return
const r = await api(`/api/recommendations${refresh ? '?refresh=1' : ''}`).catch(() => null)
// La portada pudo redibujarse mientras tanto: usar la sección actual.
const box = $('#recs')
if (!box) return
if (!r?.items?.length) { box.innerHTML = ''; return }
box.innerHTML = `<div class="row rhead"><h2>${icon('sparkles')} Recomendado para ti</h2>
<span class="muted small">Según lo que más lees: ${r.genres.map(g => `<a href="${tagHref(g.source || 'md', g)}">${esc(g.name)}</a>`).join(', ')}</span>
<button class="btn small" id="recref" title="Otras recomendaciones">${icon('refresh')}</button></div>
<div class="recrow">${r.items.map(mangaCard).join('')}</div>`
$('#recref', box).addEventListener('click', e => { e.currentTarget.disabled = true; loadRecs(true) })
}
async function viewHome(params) {
const q = params.get('q') || ''
const tag = params.get('tag') || ''
@@ -246,6 +374,7 @@ async function viewHome(params) {
const showCats = mode === 'cats' && !tag
app.innerHTML = `
<section id="cont"></section>
<section id="recs"></section>
<div class="tabs" id="sources">${Object.entries(SOURCES).map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}">${v}</button>`).join('')}</div>
<form class="search"><input type="search" name="q" placeholder="Buscar ${tag ? `en «${esc(tagName)}»` : `en ${esc(SOURCES[source])}`}…" value="${esc(q)}"><button class="btn primary">${icon('search')}<span class="lbl">Buscar</span></button></form>
<label class="muted row" style="font-size:.88rem" ${source === 'md' ? '' : 'hidden'}><input type="checkbox" name="any" ${anyLang ? 'checked' : ''}> Incluir mangas sin traducción a ${esc(LANGS[me.lang] || me.lang)}</label>
@@ -272,6 +401,7 @@ async function viewHome(params) {
$('#cont').innerHTML = `<div id="news">${newsSection(fol)}</div>` + (hist = hist.filter(h => !h.finished), hist.length ? `<h2>Continuar leyendo</h2><div class="continue">${hist.slice(0, 12).map(continueCard).join('')}</div>` : '')
bindNews(fol)
}).catch(() => {})
loadRecs()
}
const res = $('#results')
@@ -311,7 +441,7 @@ async function viewManga(id, langOverride) {
if (cleanup) { cleanup(); cleanup = null }
app.innerHTML = '<div class="loading">Cargando…</div>'
const lang = langOverride || store.get(`lang:${id}`, me.lang)
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, clips: clipCount = 0, newKeys = [] } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, clips: clipCount = 0, lists: inLists = [], newKeys = [] } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
const dlSet = new Set(downloaded)
const newSet = new Set(newKeys.map(String))
let isFollowing = following
@@ -343,11 +473,12 @@ async function viewManga(id, langOverride) {
<p class="desc">${esc(manga.description)}</p>
<div class="actions">${mainBtn}
<button class="btn" id="follow"></button>
${offline ? '' : `<details class="menu" id="lmenu"><summary class="btn" id="lbtn" title="Añadir a tus listas">${icon('bookmark')} Listas</summary><div class="menu-list" id="lmlist"><div class="muted note">Cargando…</div></div></details>`}
${menuItems ? `<details class="menu"><summary class="btn" title="Más opciones" aria-label="Más opciones">${icon('dots')}</summary><div class="menu-list">${menuItems}</div></details>` : ''}
</div>
<div class="facts muted"><span>Fuente: ${esc(SOURCES[manga.source])}</span>${download ? `<span>Carpeta: mangas/${esc(download.folder)}</span>` : ''}${clipCount ? `<a href="#/clips?manga=${encodeURIComponent(id)}">${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}</a>` : ''}</div>
${offline ? '<p class="error">Sin conexión con la fuente: mostrando solo los capítulos descargados.</p>' : ''}
<div class="libpanel"><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></div></div>
<div class="libpanel"><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></div><div class="psec" id="off" hidden></div></div>
</div>
</div>
<div class="list-head">
@@ -406,6 +537,18 @@ async function viewManga(id, langOverride) {
},
},
}
if (OFFLINE_OK) KINDS.off = {
verb: 'Guardar', ic: 'phone', modes: { range: 'Rango', pick: 'Elegir' },
candidates: () => chapters.filter(c => !offHas(c.id) && !offState.queue.some(q => q.chapterId === c.id) && offState.running?.chapterId !== c.id),
rangeBase: () => chapters,
mark: c => (offHas(c.id) ? ' (en el dispositivo)' : ''),
summary: ids => plural(ids.length, 'capítulo'),
offTitle: () => 'Ya está en el dispositivo',
run: async ids => {
offEnqueue(ids.map(cid => { const c = chapters.find(x => x.id === cid); return { mangaId: id, mangaTitle: manga.title, cover: manga.cover, lang, chapterId: cid, label: chapLabel(c), num: c.num } }))
toast(`Guardando ${plural(ids.length, 'capítulo')} en este dispositivo`)
},
}
const selIds = () => {
if (!sel) return []
const K = KINDS[sel.kind]
@@ -456,6 +599,7 @@ async function viewManga(id, langOverride) {
if (wasPick !== (next?.mode === 'pick') || (wasPick && prevKind !== next?.kind)) renderList()
$('#dlopen')?.classList.toggle('on', sel?.kind === 'dl')
$('#hdopen')?.classList.toggle('on', sel?.kind === 'hd')
$('#offopen')?.classList.toggle('on', sel?.kind === 'off')
updateSummary()
}
function renderSelForm(kind) {
@@ -612,6 +756,89 @@ async function viewManga(id, langOverride) {
if (changed) refreshSel()
}
// --- En este dispositivo (sin conexión).
const renderOff = () => {
const box = $('#off')
if (!box || !OFFLINE_OK) return
const mine = Object.entries(offIdx()).filter(([, c]) => c.mangaId === id)
const bytes = mine.reduce((n, [, c]) => n + (c.bytes || 0), 0)
const run = offState.running?.mangaId === id ? offState.running : null
const queued = offState.queue.filter(q => q.mangaId === id).length
box.hidden = false
// Siguientes 3 sin guardar a partir de donde vas (o desde el principio).
const from = Math.max(0, chapters.findIndex(c => c.id === progress?.chapter_id))
const nextIds = chapters.slice(from).filter(c => !offHas(c.id)).slice(0, 3)
let body = ''
if (run || queued) {
const pct = run?.total ? (run.done / run.total) * 100 : 0
body = `<div class="bar dlbar"><i style="width:${pct.toFixed(1)}%"></i></div>
<div class="note muted">${run ? `${esc(run.label)} · pág. ${run.done}/${run.total || '?'}` : 'Esperando…'}${queued ? ` · ${queued} más` : ''}</div>`
} else if (!mine.length) body = '<div class="note muted">Guarda capítulos en este móvil o PC para leerlos sin internet (también los no descargados en el servidor).</div>'
const actions = [
nextIds.length && !run ? `<button class="btn small" id="offnext">${icon('phone')} Guardar ${nextIds.length === 1 ? 'el siguiente' : `los ${nextIds.length} siguientes`}</button>` : '',
KINDS.off.candidates().length ? `<button class="btn small ${sel?.kind === 'off' ? 'on' : ''}" id="offopen">${icon('phone')} Elegir capítulos…</button>` : '',
mine.length ? `<button class="btn small danger push" id="offdel">${icon('trash')} Borrar del dispositivo</button>` : '',
].join('')
if (!$('#offtop')) box.innerHTML = '<div id="offtop" class="psec-grid"></div><div id="offform" class="hdform" hidden></div>'
$('#offtop').innerHTML = secHead('phone', 'En este dispositivo', mine.length ? `${plural(mine.length, 'capítulo')} · ${fmtSize(bytes)}` : '') + body + `<div class="psec-actions">${actions}</div>`
$('#offnext')?.addEventListener('click', () => KINDS.off.run(nextIds.map(c => c.id)))
$('#offopen')?.addEventListener('click', () => setSel(sel?.kind === 'off' ? null : { kind: 'off', mode: 'range' }))
$('#offdel')?.addEventListener('click', async () => {
if (!confirm(`¿Borrar de este dispositivo los ${plural(mine.length, 'capítulo')} guardados de «${manga.title}» (${fmtSize(bytes)})?`)) return
await offRemove(mine.map(([cid]) => cid))
toast('Borrado del dispositivo')
})
}
let offRefreshAt = 0
const onOff = () => {
if (!$('#off')) return
renderOff()
// La lista y el selector solo cuando cambia qué está guardado (no en cada página descargada).
const n = Object.keys(offIdx()).length + offState.queue.length + (offState.running ? 1 : 0)
if (n !== offRefreshAt) { offRefreshAt = n; refreshSel() }
}
if (OFFLINE_OK) offState.listeners.add(onOff)
// --- Listas propias (menú "Listas").
let listSet = new Set(inLists)
const renderListsBtn = () => {
const b = $('#lbtn')
if (!b) return
b.classList.toggle('on', listSet.size > 0)
b.innerHTML = `${icon('bookmark')} ${listSet.size ? `En ${plural(listSet.size, 'lista')}` : 'Listas'}`
}
renderListsBtn()
async function renderListsMenu() {
const box = $('#lmlist')
if (!box) return
const lists = await api('/api/lists').catch(() => [])
box.innerHTML = lists.map(l => `<label class="check lcheck"><input type="checkbox" data-list="${l.id}" ${listSet.has(l.id) ? 'checked' : ''}> ${esc(l.name)}</label>`).join('')
+ `<button id="lnew">${icon('plus')} Nueva lista…</button><a href="#/library">${icon('library')} Ver mi librería</a>`
box.querySelectorAll('[data-list]').forEach(x => x.addEventListener('change', async () => {
const lid = Number(x.dataset.list)
try {
const r = x.checked
? await api(`/api/lists/${lid}/items`, { method: 'POST', body: { mangaId: id, title: manga.title, cover: manga.cover } })
: await api(`/api/lists/${lid}/items/${encodeURIComponent(id)}`, { method: 'DELETE' })
listSet = new Set(r.lists)
renderListsBtn()
} catch (err) { toast(err.message); x.checked = !x.checked }
}))
$('#lnew').addEventListener('click', async () => {
const name = prompt('Nombre de la nueva lista:')
if (!name?.trim()) return
try {
const l = await api('/api/lists', { method: 'POST', body: { name } })
const r = await api(`/api/lists/${l.id}/items`, { method: 'POST', body: { mangaId: id, title: manga.title, cover: manga.cover } })
listSet = new Set(r.lists)
renderListsBtn()
renderListsMenu()
toast(`Añadido a «${l.name}»`)
} catch (err) { toast(err.message) }
})
}
$('#lmenu')?.addEventListener('toggle', e => { if (e.target.open) renderListsMenu() })
const renderFollow = () => {
const b = $('#follow')
b.innerHTML = isFollowing ? `${icon('bell')} Siguiendo` : `${icon('bellOff')} Seguir`
@@ -635,10 +862,9 @@ async function viewManga(id, langOverride) {
location.hash = back ? prevHash : '#/'
})
// Cerrar el menú ⋯ al pulsar fuera.
const menu = $('.menu', app)
const closeMenu = e => { if (menu && !menu.contains(e.target)) menu.open = false }
const closeMenu = e => app.querySelectorAll('.menu[open]').forEach(menu => { if (!menu.contains(e.target)) menu.open = false })
document.addEventListener('click', closeMenu)
cleanup = () => { clearTimeout(dlTimer); clearTimeout(hdTimer); document.removeEventListener('click', closeMenu) }
cleanup = () => { clearTimeout(dlTimer); clearTimeout(hdTimer); document.removeEventListener('click', closeMenu); offState.listeners.delete(onOff) }
app.querySelectorAll('[data-lang]').forEach(a => a.addEventListener('click', e => { e.preventDefault(); store.set(`lang:${id}`, a.dataset.lang); viewManga(id, a.dataset.lang) }))
const ul = $('.chapters', app)
@@ -647,6 +873,7 @@ async function viewManga(id, langOverride) {
+ (dlSet.has(c.id) ? `<span title="Descargado">${icon('download')}</span> ` : wantedSet.has(c.id) ? `<span class="queued" title="En cola de descarga">${icon('download')}…</span> ` : '')
+ (hdSet.has(c.id) ? '<span class="hd" title="Reescalado a HD">HD</span> '
: hdJobs[c.id] ? `<span class="hd pending" title="${hdJobs[c.id] === 'error' ? 'Falló el reescalado' : 'En cola para HD'}">HD${hdJobs[c.id] === 'error' ? '!' : '…'}</span> ` : '')
+ (OFFLINE_OK && offHas(c.id) ? `<span class="dev" title="Guardado en este dispositivo">${icon('phone')}</span> ` : '')
+ (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group))
function renderList() {
if (!ul) return
@@ -685,6 +912,7 @@ async function viewManga(id, langOverride) {
renderList()
renderDl(download)
renderHd(upscale)
renderOff()
$('#order')?.addEventListener('click', () => { desc = !desc; lastPick = null; store.set('chapterOrderDesc', desc); renderList() })
$('#lang')?.addEventListener('change', e => { store.set(`lang:${id}`, e.target.value); viewManga(id, e.target.value) })
}
@@ -730,9 +958,9 @@ async function viewReader(mangaId, chapterId, startPage) {
chapterTitle: chapter.title, lang: manga.source === 'lc' ? 'es' : chapter.lang || lang, page: lastShown(), totalPages: total,
isLast: idx >= 0 && !next, // al terminarlo, el manga sale de "Continuar leyendo"
})
const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; api('/api/progress', { method: 'POST', body: payload() }).catch(() => {}) }
const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; const p = payload(); api('/api/progress', { method: 'POST', body: p }).catch(() => queueProgress(p)) }
const scheduleSave = () => { clearTimeout(saveTimer); saveTimer = setTimeout(saveNow, 700) }
const beacon = () => { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; navigator.sendBeacon('/api/progress', JSON.stringify(payload())) } }
const beacon = () => { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; navigator.onLine ? navigator.sendBeacon('/api/progress', JSON.stringify(payload())) : queueProgress(payload()) } }
const onVis = () => document.visibilityState === 'hidden' && beacon()
document.addEventListener('visibilitychange', onVis)
window.addEventListener('pagehide', beacon)
@@ -758,6 +986,7 @@ async function viewReader(mangaId, chapterId, startPage) {
${manga.source === 'lc' || chap.local ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
${chap.hdAvailable ? '<label class="row muted" style="font-size:.85rem" title="Usar la versión reescalada de este capítulo"><input type="checkbox" id="hd"> Versión HD</label>'
: chap.local && info.upscale?.available ? `<button class="btn small" id="hdone" title="Reescala este capítulo con IA en segundo plano (unos minutos)">${icon('sparkles')} Reescalar a HD</button>` : ''}
${OFFLINE_OK ? `<button class="btn small" id="offone" ${offHas(chapterId) ? 'disabled' : ''} title="Guarda las páginas en este dispositivo para leerlas sin internet">${icon('phone')} ${offHas(chapterId) ? 'En el dispositivo' : 'Guardar sin conexión'}</button>` : ''}
<p class="muted">Modo, ajuste y dirección se recuerdan para este manga. ${icon('scroll')} en la barra de arriba cambia entre páginas y vertical. Zoom: pellizca o toca dos veces en el centro (Ctrl+rueda o + / − en PC).</p>
</div>
<div id="pages"></div>
@@ -1055,6 +1284,14 @@ async function viewReader(mangaId, chapterId, startPage) {
try { await api(`/api/upscale/${mangaId}`, { method: 'POST', body: { chapterId } }); toast('En cola: estará en HD en unos minutos') } catch (err) { toast(err.message); e.target.closest('button').disabled = false }
})
$('#chap').addEventListener('change', e => { location.hash = readUrl(mangaId, e.target.value) })
$('#offone')?.addEventListener('click', e => {
const b = e.target.closest('button')
b.disabled = true
b.innerHTML = `${icon('phone')} Guardando…`
const done = () => { if (offHas(chapterId) && b.isConnected) { b.innerHTML = `${icon('phone')} En el dispositivo`; offState.listeners.delete(done) } }
offState.listeners.add(done)
offEnqueue([{ mangaId, mangaTitle: manga.title, cover: manga.cover, lang, chapterId, label: chapLabel(chapter), num: chapter.num }])
})
slider.addEventListener('input', () => goTo(Number(slider.value) - 1))
// --- Recortes: se arrastra un rectángulo sobre la página; se recorta del original (no de la versión HD)
@@ -1331,30 +1568,117 @@ async function viewClips(params) {
cleanup = () => { clearTimeout(timer); document.removeEventListener('keydown', onKey); document.body.classList.remove('noscroll') }
}
// ---------- Historial ----------
async function viewHistory() {
// ---------- Librería ----------
// Pestañas: Leyendo, Siguiendo, Terminados, En el dispositivo y las listas propias.
const libCard = ({ href, cover, title, sub, pct, badge, del }) => `<a class="card hcard" href="${href}">
<img class="cover" loading="lazy" src="${esc(cover || '/icon.svg')}" alt="" data-fallback>
${badge ? `<span class="badge corner">${badge}</span>` : ''}
${del ? `<button class="hdel" ${del.attrs} title="${esc(del.title)}" aria-label="${esc(del.title)}">${icon(del.icon || 'trash')}</button>` : ''}
<div class="bar" ${pct == null ? 'style="visibility:hidden"' : ''}><i style="width:${pct || 0}%"></i></div>
<div class="t">${esc(title)}</div>
${sub ? `<div class="sub">${sub}</div>` : ''}
</a>`
async function viewLibrary(params) {
const tab = params.get('tab') || 'reading'
app.innerHTML = '<div class="loading">Cargando…</div>'
const hist = await api('/api/history')
app.innerHTML = `<h1>Historial de lectura</h1>
${hist.length ? '' : '<p class="muted">Aún no has leído nada.</p>'}
<div class="grid">${hist.map(h => {
const pct = h.total_pages ? Math.round(((h.page + 1) / h.total_pages) * 100) : 0
return `<a class="card hcard" href="#/manga/${h.manga_id}">
<img class="cover" loading="lazy" src="${esc(h.cover || '/icon.svg')}" alt="">
${h.finished ? '<span class="badge corner">Terminado</span>' : h.hasNew ? '<span class="badge corner">Caps. nuevos</span>' : ''}
<button class="hdel" data-del="${h.manga_id}" title="Borrar del historial" aria-label="Borrar del historial">${icon('trash')}</button>
<div class="bar" title="${pct}% del capítulo"><i style="width:${pct}%"></i></div>
<div class="t">${esc(h.manga_title)}</div>
<div class="sub">${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · ${timeAgo(h.updated_at)}</div>
</a>`
}).join('')}</div>`
app.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', async e => {
const [hist, fol, lists] = await Promise.all([api('/api/history').catch(() => null), api('/api/follows').catch(() => null), api('/api/lists').catch(() => [])])
if (!location.hash.startsWith('#/library')) return
const online = !!hist
const dev = OFFLINE_OK ? offByManga() : []
const reading = (hist || []).filter(h => !h.finished)
const finished = (hist || []).filter(h => h.finished)
const follows = fol?.follows || []
const tabs = [
['reading', 'Leyendo', 'book', reading.length],
['following', 'Siguiendo', 'bell', follows.length],
['finished', 'Terminados', 'check', finished.length],
...(OFFLINE_OK && (dev.length || tab === 'device') ? [['device', 'En el dispositivo', 'phone', dev.length]] : []),
...lists.map(l => [`list-${l.id}`, l.name, 'bookmark', l.count]),
]
const curList = tab.startsWith('list-') ? lists.find(l => `list-${l.id}` === tab) : null
app.innerHTML = `<h1>Librería</h1>
<div class="tabs libtabs">${tabs.map(([k, label, ic, n]) => `<button data-tab="${k}" class="${k === tab ? 'on' : ''}">${icon(ic)} ${esc(label)}${n ? ` <span class="muted">${n}</span>` : ''}</button>`).join('')}
${online ? `<button id="newlist" title="Crear una lista">${icon('plus')} Nueva lista</button>` : ''}</div>
${curList ? `<div class="row lhead"><span class="muted small">Lista propia · añade mangas desde su ficha con ${icon('bookmark')} Listas</span>
<button class="btn small" id="lren">${icon('pencil')} Renombrar</button><button class="btn small danger" id="ldel">${icon('trash')} Eliminar lista</button></div>` : ''}
<div id="libbody"><div class="loading">Cargando…</div></div>`
app.querySelectorAll('[data-tab]').forEach(b => b.addEventListener('click', () => { location.hash = `#/library?tab=${b.dataset.tab}` }))
const body = $('#libbody')
const grid = (cards, empty) => (cards.length ? `<div class="grid">${cards.join('')}</div>` : `<p class="muted">${empty}</p>`)
const histCard = h => libCard({
href: `#/manga/${h.manga_id}`, cover: h.cover, title: h.manga_title,
pct: h.total_pages ? Math.round(((h.page + 1) / h.total_pages) * 100) : 0,
badge: h.finished ? (h.hasNew ? 'Caps. nuevos' : '') : h.hasNew ? 'Caps. nuevos' : '',
sub: `${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · ${timeAgo(h.updated_at)}`,
del: { attrs: `data-delhist="${h.manga_id}"`, title: 'Borrar del historial' },
})
if (!online && tab !== 'device') {
body.innerHTML = `<p class="muted">Sin conexión con el servidor.${dev.length ? ' Puedes leer lo guardado en este dispositivo:' : ''}</p>`
+ (dev.length ? `<p><a class="btn" href="#/library?tab=device">${icon('phone')} En el dispositivo</a></p>` : '')
} else if (tab === 'reading') body.innerHTML = grid(reading.map(histCard), 'No estás leyendo nada ahora mismo.')
else if (tab === 'finished') body.innerHTML = grid(finished.map(histCard), 'Aún no has terminado ningún manga.')
else if (tab === 'following') {
body.innerHTML = grid(follows.map(f => libCard({
href: `#/manga/${f.manga_id}`, cover: f.cover, title: f.title, badge: f.newCount ? `${f.newCount} nuevo${f.newCount > 1 ? 's' : ''}` : '',
sub: f.last_new_at ? `Último capítulo nuevo ${timeAgo(f.last_new_at)}` : 'Sin novedades aún',
del: { attrs: `data-unfollow="${f.manga_id}"`, title: 'Dejar de seguir', icon: 'bellOff' },
})), 'No sigues ningún manga. Pulsa «Seguir» en su ficha para recibir avisos de capítulos nuevos.')
} else if (tab === 'device') {
const total = dev.reduce((n, m) => n + m.bytes, 0)
body.innerHTML = (dev.length ? `<p class="muted small">${plural(dev.reduce((n, m) => n + m.chapters.length, 0), 'capítulo')} · ${fmtSize(total)} en este dispositivo. Se leen sin internet.</p>` : '')
+ grid(dev.map(m => {
const last = m.chapters.sort((a, b) => (Number(a.num) || 0) - (Number(b.num) || 0))
return libCard({
href: `#/manga/${m.mangaId}`, cover: m.cover, title: m.title,
sub: `${plural(m.chapters.length, 'capítulo')} · ${fmtSize(m.bytes)}<br>${esc(last.map(c => c.num ?? '·').slice(0, 8).join(', '))}${last.length > 8 ? '…' : ''}`,
del: { attrs: `data-deldev="${m.mangaId}"`, title: 'Borrar del dispositivo' },
})
}), 'No hay nada guardado en este dispositivo. En la ficha de un manga, «En este dispositivo» guarda capítulos para leerlos sin internet.')
} else if (curList) {
const l = await api(`/api/lists/${curList.id}`).catch(err => (toast(err.message), { items: [] }))
body.innerHTML = grid(l.items.map(it => libCard({
href: `#/manga/${it.manga_id}`, cover: it.cover, title: it.title, sub: `Añadido ${timeAgo(it.added_at)}`,
del: { attrs: `data-unlist="${it.manga_id}"`, title: 'Quitar de la lista', icon: 'x' },
})), 'Esta lista está vacía. Añade mangas desde su ficha con el botón «Listas».')
} else body.innerHTML = '<p class="muted">Esa lista ya no existe.</p>'
const reload = () => viewLibrary(params)
body.addEventListener('click', async e => {
const b = e.target.closest('button.hdel')
if (!b) return
e.preventDefault()
e.stopPropagation()
if (!confirm('¿Borrar el historial de este manga?')) return
await api(`/api/history/${b.dataset.del}`, { method: 'DELETE' })
viewHistory()
}))
try {
if (b.dataset.delhist) {
if (!confirm('¿Borrar el historial de este manga?')) return
await api(`/api/history/${b.dataset.delhist}`, { method: 'DELETE' })
} else if (b.dataset.unfollow) {
await api(`/api/follow/${b.dataset.unfollow}`, { method: 'DELETE' })
toast('Ya no sigues este manga')
} else if (b.dataset.deldev) {
const m = dev.find(x => x.mangaId === b.dataset.deldev)
if (!confirm(`¿Borrar de este dispositivo los ${plural(m.chapters.length, 'capítulo')} de «${m.title}» (${fmtSize(m.bytes)})?`)) return
await offRemove(m.chapters.map(c => c.chapterId))
} else if (b.dataset.unlist) {
await api(`/api/lists/${curList.id}/items/${encodeURIComponent(b.dataset.unlist)}`, { method: 'DELETE' })
}
reload()
} catch (err) { toast(err.message) }
})
$('#newlist')?.addEventListener('click', async () => {
const name = prompt('Nombre de la nueva lista:')
if (!name?.trim()) return
try { const l = await api('/api/lists', { method: 'POST', body: { name } }); location.hash = `#/library?tab=list-${l.id}` } catch (err) { toast(err.message) }
})
$('#lren')?.addEventListener('click', async () => {
const name = prompt('Nuevo nombre de la lista:', curList.name)
if (!name?.trim()) return
try { await api(`/api/lists/${curList.id}`, { method: 'PATCH', body: { name } }); reload() } catch (err) { toast(err.message) }
})
$('#ldel')?.addEventListener('click', async () => {
if (!confirm(`¿Eliminar la lista «${curList.name}»? Los mangas no se tocan.`)) return
try { await api(`/api/lists/${curList.id}`, { method: 'DELETE' }); location.hash = '#/library' } catch (err) { toast(err.message) }
})
}
// ---------- Descargas ----------
@@ -1402,7 +1726,7 @@ async function viewQueues() {
// Tarjeta vertical (como en Historial): portada, barra de progreso, título, detalles y acciones.
const qcard = ({ key, id, cover, badge, pct, title, lines, actions = '', del = '' }) => `
<div class="card hcard qcard" data-key="${esc(key)}">
<a href="#/manga/${id}"><img class="cover" loading="lazy" src="${esc(cover || '/icon.svg')}" alt="" onerror="this.onerror=null;this.dataset.failed=1;this.src='/icon.svg'"></a>
<a href="#/manga/${id}"><img class="cover" loading="lazy" src="${esc(cover || '/icon.svg')}" alt="" data-fallback></a>
${badge ? `<span class="qpos corner">${badge}</span>` : ''}${del}
<div class="bar" ${pct == null ? 'style="visibility:hidden"' : ''}><i style="width:${(pct || 0).toFixed(1)}%"></i></div>
<a class="t" href="#/manga/${id}">${esc(title)}</a>
@@ -1600,8 +1924,22 @@ function viewSettings() {
<section><b>Cambiar contraseña</b>
<form id="pw" style="display:grid;gap:8px">
<input type="password" name="old" placeholder="Contraseña actual" autocomplete="current-password" required>
<input type="password" name="new" placeholder="Nueva contraseña" autocomplete="new-password" required minlength="6">
<input type="password" name="new" placeholder="Nueva contraseña" autocomplete="new-password" required minlength="8">
<button class="btn primary">${icon('key')} Guardar</button></form></section>
<section><b>${icon('download')} Descarga anticipada</b>
<p class="muted" style="margin:0;font-size:.88rem">Al pasar la mitad de un capítulo, el servidor descarga los siguientes en segundo plano para que abran al instante (y estén en la copia local).</p>
<div class="row" style="flex-wrap:wrap;gap:10px">
<select id="pfn">${[[0, 'Desactivada'], [1, 'El siguiente capítulo'], [2, '2 capítulos siguientes'], [3, '3 capítulos siguientes'], [5, '5 capítulos siguientes']].map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select>
<label class="row muted" style="font-size:.88rem"><input type="checkbox" id="pfhd"> Reescalarlos también a HD</label>
</div></section>
<section><b>${icon('phone')} App y lectura sin conexión</b>
${OFFLINE_OK ? `<p class="muted" style="margin:0;font-size:.88rem">Capítulos guardados en este dispositivo: <b id="offsum">…</b>. Se guardan desde la ficha de cada manga («En este dispositivo») o desde el lector.</p>
<div class="row" style="flex-wrap:wrap;gap:8px"><a class="btn small" href="#/library?tab=device">${icon('library')} Ver lo guardado</a>
<button class="btn small danger" id="offclear">${icon('trash')} Borrar todo del dispositivo</button>
<button class="btn small" id="install" hidden>${icon('download')} Instalar como app</button></div>`
: `<p class="muted" style="margin:0;font-size:.88rem">Instalar la app y leer sin conexión necesita que la página se abra por <b>HTTPS</b> (el navegador no lo permite en <code>http://${esc(location.host)}</code>). Por ejemplo con <code>tailscale serve</code> o un proxy inverso con certificado.</p>`}
</section>
<section id="secsec"><b>${icon('key')} Seguridad: IPs bloqueadas</b><div id="bans" class="muted" style="font-size:.88rem">Cargando…</div></section>
<section><b>${icon('bell')} Avisos de capítulos nuevos</b>
<p class="muted" style="margin:0;font-size:.88rem">El servidor revisa cada pocas horas los mangas que sigues (los que empiezas a leer se siguen solos).
Si un manga tiene copia local, los capítulos nuevos se descargan automáticamente. Además puedes recibir una notificación:</p>
@@ -1635,6 +1973,39 @@ function viewSettings() {
viewSettings()
} catch (err) { toast(err.message) }
})
const renderBans = async () => {
const b = await api('/api/bans').catch(() => null)
const box = $('#bans')
if (!box || !b) return
box.innerHTML = `<p style="margin:0">Tras ${b.maxFails} intentos fallidos en ${b.windowMin} min (contraseña incorrecta o registro cerrado), la IP queda bloqueada ${b.banHours} h. La red local y Tailscale no se bloquean nunca.</p>
${b.bans.length ? `<div class="banlist">${b.bans.map(x => `<div class="row"><code>${esc(x.ip)}</code><span class="muted small" style="flex:1">hasta ${new Date(x.until).toLocaleString('es')} · ${esc(x.reason || '')}</span>
<button class="btn small" data-unban="${esc(x.ip)}">Desbloquear</button></div>`).join('')}</div>` : '<p style="margin:6px 0 0">Ninguna IP bloqueada.</p>'}
<p class="muted small" style="margin:6px 0 0">Tu IP vista por el servidor: <code>${esc(b.you.ip)}</code>${b.you.protected ? ' (red local, nunca se bloquea)' : ''}${b.trustedHeader ? ` · leída de <code>${esc(b.trustedHeader)}</code>` : ''}</p>
${b.fails.length ? `<p style="margin:6px 0 0">Intentos fallidos recientes: ${b.fails.map(f => `<code>${esc(f.ip)}</code> ×${f.n}`).join(', ')}</p>` : ''}`
box.querySelectorAll('[data-unban]').forEach(x => x.addEventListener('click', async () => {
try { await api(`/api/bans/${encodeURIComponent(x.dataset.unban)}`, { method: 'DELETE' }); toast('IP desbloqueada'); renderBans() } catch (err) { toast(err.message) }
}))
}
renderBans()
api('/api/prefetch').then(c => { if (!$('#pfn')) return; $('#pfn').value = String(c.n); $('#pfhd').checked = c.hd }).catch(() => {})
const savePf = () => api('/api/prefetch', { method: 'POST', body: { n: Number($('#pfn').value), hd: $('#pfhd').checked } }).then(() => toast('Descarga anticipada guardada')).catch(err => toast(err.message))
$('#pfn').addEventListener('change', savePf)
$('#pfhd').addEventListener('change', savePf)
if (OFFLINE_OK) {
const dev = offByManga()
$('#offsum').textContent = dev.length ? `${plural(dev.reduce((n, m) => n + m.chapters.length, 0), 'capítulo')} de ${plural(dev.length, 'manga')} · ${fmtSize(dev.reduce((n, m) => n + m.bytes, 0))}` : 'ninguno'
$('#offclear').hidden = !dev.length
$('#offclear').addEventListener('click', async () => {
if (!confirm('¿Borrar todos los capítulos guardados en este dispositivo?')) return
await caches.delete(OFF_CACHE)
store.set('offlineChapters', {})
toast('Borrado')
viewSettings()
})
const inst = $('#install')
inst.hidden = !installPrompt
inst.addEventListener('click', async () => { installPrompt?.prompt(); installPrompt = null; inst.hidden = true })
}
$('#ntest').addEventListener('click', async () => {
try { await api('/api/notify/test', { method: 'POST' }); toast('Notificación de prueba enviada') } catch (err) { toast(err.message) }
})