Files
manga-reader/app/public/app.js
T
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

2525 lines
153 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const app = document.getElementById('app')
// Iconos SVG en línea (trazo estilo Lucide), heredan el color del texto.
const ICONS = {
home: '<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/><path d="M10 21v-6h4v6"/>',
history: '<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 2"/>',
download: '<path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/>',
settings: '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h11M19 18h1"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>',
play: '<path d="M7 4v16l13-8z"/>',
book: '<path d="M2 5h6a4 4 0 0 1 4 4v12a3 3 0 0 0-3-3H2z"/><path d="M22 5h-6a4 4 0 0 0-4 4v12a3 3 0 0 1 3-3h7z"/>',
stopRead: '<circle cx="12" cy="12" r="9"/><path d="m15 9-6 6M9 9l6 6"/>',
stop: '<rect x="6" y="6" width="12" height="12" rx="1.5"/>',
trash: '<path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M6 6l1 15h10l1-15"/><path d="M10 11v6M14 11v6"/>',
refresh: '<path d="M21 12a9 9 0 1 1-2.6-6.4L21 8"/><path d="M21 3v5h-5"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
sort: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
back: '<path d="M19 12H5"/><path d="m12 19-7-7 7-7"/>',
prev: '<path d="m15 18-6-6 6-6"/>',
next: '<path d="m9 18 6-6-6-6"/>',
more: '<path d="m6 9 6 6 6-6"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
login: '<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><path d="m10 17 5-5-5-5"/><path d="M15 12H3"/>',
logout: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/>',
userPlus: '<circle cx="9" cy="8" r="4"/><path d="M2 21a7 7 0 0 1 14 0"/><path d="M19 8v6M16 11h6"/>',
flame: '<path d="M12 22c3.9 0 7-2.8 7-7 0-3.5-2.4-5.6-3.6-8.6-.9 1.8-1.9 2.9-3 3.2.2-3.1-.9-5.6-3-7.6.3 4-4.4 6.8-4.4 12.6C5 19.2 8.1 22 12 22z"/>',
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
grid: '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>',
wifiOff: '<path d="M2 2l20 20"/><path d="M8.5 16.4a5 5 0 0 1 7 0"/><path d="M5 12.9a10 10 0 0 1 5.2-2.8"/><path d="M14.4 10.2A10 10 0 0 1 19 12.9"/><path d="M2 8.8a15 15 0 0 1 4.2-2.7"/><path d="M10.7 5A15 15 0 0 1 22 8.8"/><path d="M12 20h.01"/>',
globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18z"/>',
key: '<circle cx="8" cy="15" r="4"/><path d="m11 12 9-9M17 6l3 3"/>',
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.9 1.9 0 0 0 3.4 0"/>',
bellOff: '<path d="M8.7 3A6 6 0 0 1 18 8a21 21 0 0 0 .6 5"/><path d="M17 17H3s3-2 3-9a4.7 4.7 0 0 1 .3-2"/><path d="M10.3 21a1.9 1.9 0 0 0 3.4 0"/><path d="m2 2 20 20"/>',
chart: '<path d="M3 3v18h18"/><path d="M7 16v-4M12 16V8M17 16v-7"/>',
zoomOut: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5M8 11h6"/>',
send: '<path d="M22 2 11 13"/><path d="M22 2 15 22l-4-9-9-4z"/>',
dots: '<circle cx="5" cy="12" r="1.2"/><circle cx="12" cy="12" r="1.2"/><circle cx="19" cy="12" r="1.2"/>',
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
scroll: '<rect x="6" y="2" width="12" height="9" rx="1.5"/><rect x="6" y="13" width="12" height="9" rx="1.5"/>',
pages: '<rect x="2" y="5" width="9" height="14" rx="1.5"/><rect x="13" y="5" width="9" height="14" rx="1.5"/>',
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"/>',
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v6"/><path d="M12 7.5h.01"/>',
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>`
// Iconos del menú superior (index.html).
document.querySelectorAll('#topbar nav a[data-icon]').forEach(a => { a.innerHTML = `${icon(a.dataset.icon)}<span>${a.textContent}</span>` })
const SOURCES = { lc: 'LeerCapitulo (español)', mi: 'M440 (español)', md: 'MangaDex (multi-idioma)' }
const NO_CATS = new Set(['mi']) // fuentes sin listado por categorías
// Filtros de imagen del lector (CSS filter); el brillo se suma aparte.
const FILTERS = {
none: ['Sin filtro', ''],
sepia: ['Sepia', 'sepia(.65) contrast(.95)'],
paper: ['Papel cálido', 'sepia(.35) saturate(1.15) hue-rotate(-8deg) brightness(.95)'],
vintage: ['Vintage', 'sepia(.45) contrast(1.1) saturate(.75)'],
night: ['Noche (invertido)', 'invert(.88) hue-rotate(180deg) contrast(.95)'],
gray: ['Blanco y negro', 'grayscale(1)'],
contrast: ['Alto contraste', 'contrast(1.35) brightness(1.03)'],
vivid: ['Colores vivos', 'saturate(1.45) contrast(1.05)'],
}
const DL_ACTIVE = ['queued', 'downloading']
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
const fmtDur = sec => sec < 90 ? '≈ 1 min' : sec < 3600 ? `≈ ${Math.round(sec / 60)} min` : `≈ ${Math.floor(sec / 3600)} h ${Math.round((sec % 3600) / 60)} min`
const numKey = n => { const f = parseFloat(n); return Number.isFinite(f) ? String(f) : null }
const fmtDate = d => {
const t = new Date(`${String(d).slice(0, 10)}T12:00:00`)
return Number.isNaN(t.getTime()) ? '' : t.toLocaleDateString('es', { day: 'numeric', month: 'short', year: 'numeric' })
}
const fmtSize = b => (b >= 1e9 ? `${(b / 1e9).toFixed(1)} GB` : b >= 1e6 ? `${Math.round(b / 1e6)} MB` : `${Math.round((b || 0) / 1e3)} KB`)
// Progreso global contando la fracción del capítulo en curso.
function downloadPct(d) {
if (!d?.total) return 0
const frac = d.current?.pagesTotal ? d.current.pages / d.current.pagesTotal : 0
return Math.min(100, ((d.done + frac) / d.total) * 100)
}
function downloadDetail(d) {
const c = d?.current
if (!c) return d?.status === 'queued' ? 'Esperando turno…' : ''
return c.pagesTotal ? `Cap. ${esc(c.chapter ?? '?')} · pág. ${c.pages}/${c.pagesTotal}` : `Cap. ${esc(c.chapter ?? '?')} · preparando…`
}
function downloadLabel(d) {
if (!d) return ''
const n = `${d.done}/${d.total || '?'}`
return {
queued: 'En cola…',
downloading: `Descargando ${n}`,
done: `Descargado (${d.done} caps.)`,
error: `Descarga incompleta ${n}`,
cancelled: `Descarga cancelada ${n}`,
}[d.status] || d.status
}
const LANGS = { 'es-la': 'Español (Latam)', es: 'Español (España)', en: 'English', 'pt-br': 'Português (BR)', fr: 'Français', it: 'Italiano', ja: '日本語' }
let me = null
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 {} },
}
function toast(msg) {
const t = document.getElementById('toast')
t.textContent = msg
t.hidden = false
clearTimeout(toast.t)
toast.t = setTimeout(() => (t.hidden = true), 2500)
}
const NO_SERVER = 'Sin conexión con el servidor'
async function api(path, opts = {}) {
// Si tarda, se comprueba si el servidor sigue ahí (una fuente lenta no es motivo para cortar, un
// servidor que no contesta sí): así nada se queda en «Cargando…» para siempre sin red.
const ctrl = new AbortController()
const watchdog = setTimeout(async () => { if (!(await pingServer())) ctrl.abort() }, 2500)
let res
try {
res = await fetch(path, {
...opts,
signal: ctrl.signal,
headers: opts.body ? { 'Content-Type': 'application/json' } : undefined,
body: opts.body ? JSON.stringify(opts.body) : undefined,
})
} catch {
setServerReachable(false)
throw new Error(NO_SERVER)
} finally {
clearTimeout(watchdog)
}
// Respuesta de la copia del service worker: el servidor no contestó a tiempo; comprobar el estado.
if (res.headers.get('x-from-cache')) refreshNet()
else if (!serverReachable) setServerReachable(true)
const data = await res.json().catch(() => ({}))
if (res.status === 401 && !path.startsWith('/api/login')) { me = null; location.hash = '#/login'; throw new Error('No autenticado') }
// 503: el servidor ya sabe que no hay internet. 502: una fuente falló; el servidor tarda unos segundos en confirmarlo.
if (res.status === 503) refreshNet()
else if (res.status === 502) setTimeout(refreshNet, 7000)
if (!res.ok) throw new Error(data.error || `Error ${res.status}`)
return data
}
// ---------- Estado de la conexión ----------
// Dos casos distintos, con su aviso arriba:
// - serverReachable = false: no se llega al servidor (sin red, servidor caído). Se lee lo guardado en
// este dispositivo; la portada lo muestra. Al volver, se envía el progreso pendiente.
// - serverOnline = false: el servidor contesta pero él no tiene internet. Se lee lo descargado en el
// servidor; la portada lo muestra.
// Se comprueba cada 20 s (cada 10 s sin servidor) y al volver a la pestaña.
let serverOnline = true
let serverReachable = true
let netChecked = false
const isHome = () => !(location.hash.slice(1).split('?')[0] || '/').split('/').filter(Boolean).length
async function pingServer() {
const r = await fetch('/api/status', { cache: 'no-store', signal: AbortSignal.timeout(3000) }).catch(() => null)
return r ? (r.ok ? await r.json().catch(() => ({ online: true })) : { online: true }) : null
}
function netBanner() {
let b = $('#netbanner')
const msg = !serverReachable
? `${icon('wifiOff')}<span><b>${NO_SERVER}.</b> ${OFFLINE_OK ? 'Puedes leer lo guardado en este dispositivo; ' : ''}se reconecta solo.</span>`
: !serverOnline ? `${icon('wifiOff')}<span><b>El servidor no tiene internet.</b> Puedes leer lo descargado en el servidor; se reconecta solo.</span>` : ''
if (!msg) return b?.remove()
if (!b) { b = document.createElement('div'); b.id = 'netbanner'; $('#topbar').after(b) }
if (b.innerHTML !== msg) b.innerHTML = msg
}
function setServerReachable(ok) {
if (ok === serverReachable) return
serverReachable = ok
netBanner()
if (ok) { flushProgress(); refreshNet() }
if (isHome()) router()
}
async function refreshNet() {
const st = await pingServer()
netChecked = true
if (!st) return setServerReachable(false)
const changed = !serverReachable || st.online !== serverOnline
serverOnline = st.online !== false
if (!serverReachable) return setServerReachable(true) // ya redibuja y vuelve a llamar
netBanner()
if (changed && isHome()) router()
}
setInterval(() => { if (serverReachable) refreshNet() }, 20e3)
setInterval(() => { if (!serverReachable) refreshNet() }, 10e3)
document.addEventListener('visibilitychange', () => { if (!document.hidden) refreshNet() })
function timeAgo(ts) {
const s = (Date.now() - ts) / 1000
if (s < 60) return 'hace un momento'
if (s < 3600) return `hace ${Math.floor(s / 60)} min`
if (s < 86400) return `hace ${Math.floor(s / 3600)} h`
return `hace ${Math.floor(s / 86400)} d`
}
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'
// 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 offHas = id => !!offIdx()[id]
const offState = { running: null, queue: [], listeners: new Set(), cancelled: new Set(), abort: null }
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) { if (!offState.cancelled.has(it.chapterId)) toast(`No se pudo guardar ${it.label}: ${err.message}`) }
offState.cancelled.delete(it.chapterId)
}
offState.running = null
offEmit()
}
// Cancela lo que quede por guardar de un manga (o todo): lo pendiente sale de la cola y el capítulo en
// curso se interrumpe y se borra lo que ya se había guardado de él.
function offCancel(mangaId = null) {
offState.queue = offState.queue.filter(q => mangaId && q.mangaId !== mangaId)
if (offState.running && (!mangaId || offState.running.mangaId === mangaId)) {
offState.cancelled.add(offState.running.chapterId)
offState.abort?.abort() // corta las páginas que se estén bajando
}
offEmit()
}
async function offSaveChapter(it) {
const cache = await caches.open(OFF_CACHE)
const saved = []
offState.abort = new AbortController()
try { await offSaveInto(cache, it, saved, offState.abort.signal) } catch (err) {
await Promise.all(saved.map(u => cache.delete(u, { ignoreSearch: false }))) // nada de capítulos a medias
throw err
}
}
async function offSaveInto(cache, it, saved, signal) {
const stop = () => { if (offState.cancelled.has(it.chapterId)) throw new Error('cancelado') }
const hd = store.get('readerSettings', {}).hd !== false
const chapUrl = `/api/chapter/${it.chapterId}?saver=0&hd=${hd ? 1 : 0}`
const res = await fetch(chapUrl, { signal })
if (!res.ok) throw new Error(`Error ${res.status}`)
const data = await res.clone().json()
await cache.put(chapUrl, res)
saved.push(chapUrl)
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) {
stop()
const u = data.pages[i++]
// Hasta 3 intentos por página (las fuentes fallan a ratos), cada uno con tiempo límite.
let blob, type
for (let t = 1; ; t++) {
try {
const r = await fetch(u, { signal: AbortSignal.any([signal, AbortSignal.timeout(90e3)]) })
if (!r.ok) throw new Error(`página ${r.status}`)
type = r.headers.get('Content-Type')
blob = await r.blob()
break
} catch (err) {
stop()
if (t >= 3) throw err
await new Promise(res => setTimeout(res, 2000 * t))
}
}
bytes += blob.size
stop()
await cache.put(u, new Response(blob, { headers: { 'Content-Type': type || 'image/jpeg' } }))
saved.push(u)
offState.running.done++
offEmit()
}
}
await Promise.all([worker(), worker(), worker()])
stop()
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() {
if (location.hash !== curHash) { prevHash = curHash; curHash = location.hash }
if (cleanup) { cleanup(); cleanup = null }
document.body.classList.remove('reading')
const [pathPart, query] = location.hash.slice(1).split('?')
const parts = (pathPart || '/').split('/').filter(Boolean)
const params = new URLSearchParams(query || '')
document.querySelectorAll('#topbar nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === `#/${parts[0] ?? ''}`))
if (!me) {
const [r] = await Promise.all([api('/api/me').catch(() => ({})), netChecked ? null : refreshNet()])
me = r.user
me && (me.signup = r.signup)
if (!me) return viewAuth(r.signup)
}
try {
if (parts[0] === 'login') return (location.hash = '#/')
if (parts[0] === 'manga') return await viewManga(parts[1])
if (parts[0] === 'info') return await viewInfo(parts[1])
if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1)
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()
if (parts[0] === 'clips') return await viewClips(params)
if (parts[0] === 'settings') return viewSettings()
return await viewHome(params)
} catch (e) {
if (e.message !== 'No autenticado') app.innerHTML = `<p class="error">${esc(e.message)}</p>`
}
}
window.addEventListener('hashchange', router)
router()
// ---------- Login / registro ----------
function viewAuth(signup) {
app.innerHTML = `
<div class="auth">
<h1>Manga Reader</h1>
<p class="muted">Inicia sesión para leer y guardar tu progreso.</p>
<form>
<input type="text" name="username" placeholder="Usuario" autocomplete="username" required>
<input type="password" name="password" placeholder="Contraseña" autocomplete="current-password" required>
<p class="error"></p>
<button class="btn primary" value="login">${icon('login')} Entrar</button>
${signup ? `<button class="btn" value="register">${icon('userPlus')} Crear cuenta</button>` : ''}
</form>
</div>`
const form = $('form', app)
form.addEventListener('submit', async ev => {
ev.preventDefault()
const mode = ev.submitter?.value === 'register' ? 'register' : 'login'
const fd = new FormData(form)
try {
await api(`/api/${mode}`, { method: 'POST', body: { username: fd.get('username'), password: fd.get('password') } })
me = null
location.hash = '#/'
router()
} catch (e) { $('.error', form).textContent = e.message }
})
}
// ---------- Inicio ----------
function mangaCard(m) {
return `<a class="card" href="#/manga/${m.id}">
<img class="cover" loading="lazy" src="${esc(m.cover || '/icon.svg')}" alt="" data-fallback>
<div class="t">${esc(m.title)}</div>${m.sub ? `<div class="sub">${esc(m.sub)}</div>` : ''}</a>`
}
function continueCard(h) {
const pct = h.total_pages ? Math.round(((h.page + 1) / h.total_pages) * 100) : 0
// Terminado con capítulos nuevos: a la ficha, para elegir el siguiente.
return `<a class="cont" href="${h.hasNew ? `#/manga/${h.manga_id}` : readUrl(h.manga_id, h.chapter_id, h.page)}">
<img src="${esc(h.cover || '/icon.svg')}" alt="" loading="lazy" data-fallback>
<div class="info"><b>${esc(h.manga_title)}</b>
<div class="muted">${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · pág. ${h.page + 1}/${h.total_pages || '?'}</div>
${h.finished ? '<div><span class="badge">Terminado</span></div>' : h.hasNew ? '<div><span class="badge">Caps. nuevos</span></div>' : ''}
<div class="muted" style="font-size:.8rem">${timeAgo(h.updated_at)}</div>
<div class="bar"><i style="width:${pct}%"></i></div></div></a>`
}
// Sección "Novedades": mangas seguidos con capítulos nuevos.
function newsSection(fol) {
const fresh = fol.follows.filter(f => f.newCount > 0)
const status = fol.checking ? 'Comprobando…' : fol.lastCheckAt ? `Última comprobación ${timeAgo(fol.lastCheckAt)}` : `Se comprueba cada ${fol.intervalHours} h`
if (!fol.follows.length) return ''
return `<div class="row" style="justify-content:space-between;margin-top:8px">
<h2 style="margin:12px 0">${icon('sparkles')} Novedades ${fresh.length ? `<span class="badge">${fresh.reduce((n, f) => n + f.newCount, 0)}</span>` : ''}</h2>
<span class="row muted" style="font-size:.85rem">Sigues ${fol.follows.length} manga${fol.follows.length === 1 ? '' : 's'} · ${status}
<button class="btn small" id="checknow" ${fol.checking ? 'disabled' : ''}>${icon('refresh')} Comprobar ahora</button></span></div>
${fresh.length ? `<div class="continue">${fresh.map(f => `<a class="cont" href="#/manga/${f.manga_id}">
<img src="${esc(f.cover || '/icon.svg')}" alt="" loading="lazy">
<div class="info"><b>${esc(f.title)}</b>
<div><span class="badge">${f.newCount} ${f.newCount === 1 ? 'cap. nuevo' : 'caps. nuevos'}</span></div>
<div class="muted" style="font-size:.8rem">${f.last_new_at ? timeAgo(f.last_new_at) : ''}</div></div></a>`).join('')}</div>`
: '<p class="muted" style="margin-top:0">No hay capítulos nuevos en los mangas que sigues.</p>'}`
}
function bindNews() {
$('#checknow')?.addEventListener('click', async e => {
e.target.closest('button').disabled = true
await api('/api/follows/check', { method: 'POST' })
toast('Comprobando capítulos nuevos…')
// Refrescar la sección mientras dura la comprobación.
const poll = async () => {
const fol = await api('/api/follows').catch(() => null)
if (!fol || !$('#news')) return
$('#news').innerHTML = newsSection(fol)
bindNews()
if (fol.checking) setTimeout(poll, 3000)
else toast('Comprobación terminada')
}
setTimeout(poll, 2000)
})
}
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) })
}
// Portada sin internet en el servidor: lo descargado en el servidor, con buscador local.
async function viewHomeOffline(params) {
app.innerHTML = `
<section id="cont"></section>
<h2>${icon('download')} Descargado en el servidor</h2>
<form class="search"><input type="search" name="q" placeholder="Buscar en lo descargado…" value="${esc(params.get('q') || '')}" autocomplete="off"></form>
<div id="results" class="loading">Cargando…</div>`
const [list, hist] = await Promise.all([api('/api/search?q='), api('/api/history').catch(() => [])])
const have = new Set(list.map(m => m.id))
const cont = hist.filter(h => !h.finished && have.has(h.manga_id))
if (cont.length) $('#cont').innerHTML = `<h2>Continuar leyendo</h2><div class="continue">${cont.slice(0, 12).map(continueCard).join('')}</div>`
const res = $('#results')
const fold = t => String(t).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
const render = () => {
const words = fold($('[name=q]').value).split(/\s+/).filter(Boolean)
const shown = list.filter(m => words.every(w => fold(m.title).includes(w)))
res.className = shown.length ? 'grid' : 'loading'
res.innerHTML = shown.length ? shown.map(mangaCard).join('') : list.length ? 'Nada descargado con ese nombre.' : 'No hay nada descargado en el servidor todavía.'
}
$('form.search').addEventListener('submit', e => e.preventDefault())
$('[name=q]').addEventListener('input', render)
render()
}
// Portada sin conexión con el servidor: lo guardado en este dispositivo.
function viewHomeDevice() {
const dev = OFFLINE_OK ? offByManga() : []
app.innerHTML = `<h2>${icon('phone')} En este dispositivo</h2>` + (dev.length
? `<div class="grid">${dev.map(m => mangaCard({ id: m.mangaId, title: m.title, cover: m.cover, sub: `${plural(m.chapters.length, 'capítulo')} guardado${m.chapters.length === 1 ? '' : 's'}` })).join('')}</div>`
: `<p class="muted">${OFFLINE_OK ? 'No hay capítulos guardados en este dispositivo. Con conexión, usa «Guardar en el dispositivo» en la ficha o en el lector para leer sin internet.'
: 'Leer sin conexión necesita abrir la app por HTTPS.'}</p>`)
}
async function viewHome(params) {
if (!serverReachable) return viewHomeDevice()
if (!serverOnline) return viewHomeOffline(params)
const q = params.get('q') || ''
const tag = params.get('tag') || ''
const tagName = params.get('tagName') || tag
const anyLang = params.get('any') === '1'
const source = SOURCES[params.get('source')] ? params.get('source') : store.get('source', 'lc')
let mode = tag ? 'cats' : params.get('mode') || 'popular'
if (mode === 'cats' && NO_CATS.has(source)) mode = 'popular'
store.set('source', source)
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' : ''}" title="${esc(v)}">${esc(v.replace(/\s*\(.*\)$/, ''))}</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>
${q && !tag ? `<h2>Resultados para “${esc(q)}”</h2>` : `<div class="tabs" id="modes">
<button data-m="popular" class="${mode === 'popular' ? 'on' : ''}">${icon('flame')} Populares</button>
<button data-m="latest" class="${mode === 'latest' ? 'on' : ''}">${icon('clock')} Actualizados recientemente</button>
${NO_CATS.has(source) ? '' : `<button data-m="cats" class="${mode === 'cats' ? 'on' : ''}">${icon('grid')} Categorías</button>`}</div>`}
${tag ? `<h2>Categoría: <a class="tag on" href="#/?source=${source}&mode=cats" title="Quitar filtro">${esc(tagName)} ✕</a>${q ? ` · “${esc(q)}”` : ''}</h2>` : ''}
<div id="results" class="loading">Cargando…</div>
<div class="row" style="justify-content:center;margin:16px 0"><button class="btn" id="more" hidden>${icon('more')} Cargar más</button></div>`
const form = $('form', app)
const go = (extra = {}) => {
const p = new URLSearchParams({ source, ...(form.q.value ? { q: form.q.value } : { mode }), ...(tag ? { tag, tagName } : {}), ...($('[name=any]').checked ? { any: '1' } : {}), ...extra })
location.hash = `#/?${p}`
}
form.addEventListener('submit', e => { e.preventDefault(); go() })
$('[name=any]').addEventListener('change', () => go())
app.querySelectorAll('#modes button').forEach(b => b.addEventListener('click', () => { location.hash = `#/?source=${source}&mode=${b.dataset.m}${anyLang ? '&any=1' : ''}` }))
app.querySelectorAll('#sources button').forEach(b => b.addEventListener('click', () => { location.hash = `#/?source=${b.dataset.s}&mode=${mode}${anyLang ? '&any=1' : ''}` }))
if (!q && !tag && !showCats) {
Promise.all([api('/api/history'), api('/api/follows')]).then(([hist, fol]) => {
if (!$('#cont')) return
$('#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')
if (showCats) {
const cats = await api(`/api/categories?source=${source}`)
const groups = cats.reduce((m, c) => m.set(c.group, [...(m.get(c.group) || []), c]), new Map())
res.className = 'cats'
res.innerHTML = [...groups].map(([g, list]) => `<h3>${esc(g)}</h3><div class="tags">${list.map(t => `<a class="tag big" href="${tagHref(source, t)}">${esc(t.name)}</a>`).join('')}</div>`).join('')
return
}
// Paginación: MangaDex siempre; LeerCapitulo solo en categorías; M440 en "Actualizados" (sus otros listados no paginan).
const pageable = source === 'md' || !!tag || (source === 'mi' && mode === 'latest' && !q)
const seen = new Set()
let page = 1
const load = async () => {
const qs = new URLSearchParams({ q, mode, source, page, ...(tag ? { tag } : {}), ...(anyLang ? { anyLang: '1' } : {}) })
const list = (await api(`/api/search?${qs}`)).filter(m => !seen.has(m.id) && seen.add(m.id))
if (page === 1) {
res.className = list.length ? 'grid' : 'loading'
res.innerHTML = list.length ? '' : 'Sin resultados.'
}
res.insertAdjacentHTML('beforeend', list.map(mangaCard).join(''))
$('#more').hidden = !pageable || list.length < 20
}
$('#more').addEventListener('click', async e => {
e.target.disabled = true
page++
try { await load() } catch (err) { toast(err.message) }
e.target.disabled = false
})
await load()
}
// ---------- Ficha del manga ----------
// ---------- Info: datos de la obra de AniList y MangaUpdates ----------
const AL_FORMAT = { MANGA: 'Manga', NOVEL: 'Novela ligera', ONE_SHOT: 'Oneshot', Manga: 'Manga', Manhwa: 'Manhwa', Manhua: 'Manhua' }
const AL_STATUS = { FINISHED: 'Terminado', RELEASING: 'En publicación', NOT_YET_RELEASED: 'Sin estrenar', CANCELLED: 'Cancelado', HIATUS: 'En pausa' }
const AL_COUNTRY = { JP: 'Japón', KR: 'Corea del Sur', CN: 'China', TW: 'Taiwán' }
const AL_ROLE = r => r.replace(/\bchs?\b/g, 'caps.').replace(/^Story & Art/i, 'Historia y dibujo').replace(/^Original Creator/i, 'Creador original').replace(/^Original Story/i, 'Historia original').replace(/^Story/i, 'Historia').replace(/^Art/i, 'Dibujo')
const AL_REL = { SEQUEL: 'Secuela', PREQUEL: 'Precuela', SIDE_STORY: 'Historia paralela', SPIN_OFF: 'Spin-off', ALTERNATIVE: 'Versión alternativa', PARENT: 'Obra principal', ADAPTATION: 'Adaptación', SOURCE: 'Original', CHARACTER: 'Personajes', SUMMARY: 'Resumen', COMPILATION: 'Recopilación', CONTAINS: 'Contiene', OTHER: 'Relacionado' }
const AL_ANIME = { TV: 'Serie TV', TV_SHORT: 'Serie corta', MOVIE: 'Película', OVA: 'OVA', ONA: 'ONA', SPECIAL: 'Especial', MUSIC: 'Vídeo musical' }
const fmtFuzzy = d => (!d ? '' : d.length === 4 ? d : d.length === 7 ? new Date(`${d}-15T12:00:00`).toLocaleDateString('es', { month: 'long', year: 'numeric' }) : fmtDate(d))
async function viewInfo(id) {
if (cleanup) { cleanup(); cleanup = null }
app.innerHTML = '<div class="loading">Buscando información de la obra…</div>'
const d = await api(`/api/meta/${id}`)
const src = d.source || {}
const back = `<a class="btn small" href="#/manga/${id}">${icon('back')} Volver</a>`
if (!d.found) {
app.innerHTML = `<div class="info-page">${back}<h1>${esc(src.title || '')}</h1>
<p class="muted">No se encontró esta obra en AniList ni en MangaUpdates. Puede tener otro título allí: prueba a buscarla en
<a class="tag" href="https://anilist.co/search/manga?search=${encodeURIComponent(src.title || '')}" target="_blank" rel="noopener">AniList</a>.</p></div>`
return
}
const row = (k, v) => (v ? `<dt>${k}</dt><dd>${v}</dd>` : '')
const list = a => a.map(esc).join(', ')
const t = d.titles
const people = d.staff.reduce((m, p) => m.set(p.name, [...(m.get(p.name) || []), AL_ROLE(p.role)]), new Map())
const dates = d.start ? `${fmtFuzzy(d.start)}${d.end ? ` – ${fmtFuzzy(d.end)}` : d.status === 'RELEASING' ? ' – actualidad' : ''}` : ''
const size = [d.volumes ? `${d.volumes} tomos` : '', d.chapters ? `${d.chapters} capítulos` : d.latestChapter ? `${d.latestChapter} capítulos (hasta ahora)` : ''].filter(Boolean).join(' · ')
const ranks = d.rankings.map(r => `#${r.rank} ${r.type === 'RATED' ? 'mejor valorado' : 'más popular'}${r.allTime ? ' de siempre' : ` de ${r.year}`}`)
const search = title => `#/?q=${encodeURIComponent(title)}`
const cardOf = r => `<a class="card" href="${search(r.title)}" title="Buscar «${esc(r.title)}»">
<img class="cover" loading="lazy" src="${esc(r.cover || '/icon.svg')}" alt="" data-fallback>
<div class="t">${esc(r.title)}</div><div class="sub">${esc([r.relation ? AL_REL[r.relation] || r.relation : '', r.year || '', r.score ? `${r.score}%` : ''].filter(Boolean).join(' · '))}</div></a>`
const srcDesc = (src.description || '').trim()
app.innerHTML = `
<div class="info-page">
${d.banner ? `<div class="banner" style="background-image:url('${esc(d.banner)}')"></div>` : ''}
<div class="row info-top">${back}</div>
<div class="detail">
<img class="cover" src="${esc(d.cover || src.cover || '/icon.svg')}" alt="" data-fallback>
<div class="detail-body">
<h1>${esc(t.english || t.romaji || src.title)}</h1>
<div class="muted">${esc([t.romaji !== (t.english || t.romaji) ? t.romaji : '', t.native].filter(Boolean).join(' · '))}</div>
<div class="scores">
${d.score != null ? `<div class="score"><b>${d.score}%</b><span>AniList</span></div>` : ''}
${d.muScore != null ? `<div class="score"><b>${Number(d.muScore).toFixed(1)}</b><span>MangaUpdates${d.muVotes ? ` · ${fmtNum(d.muVotes)} votos` : ''}</span></div>` : ''}
${d.popularity ? `<div class="score"><b>${fmtNum(d.popularity)}</b><span>lectores en AniList</span></div>` : ''}
</div>
${ranks.length ? `<div class="tags">${ranks.map(r => `<span class="tag hot">${esc(r)}</span>`).join('')}</div>` : ''}
<dl class="kv">
${[...people].map(([name, roles]) => row(esc(roles.join(' y ')), esc(name))).join('')}
${row('Revista', d.magazines.map(m => esc(m.name) + (m.publisher ? ` <span class="muted">(${esc(m.publisher)})</span>` : '')).join(', '))}
${row('Editorial', d.publishers.filter(p => p.type === 'Original').map(p => esc(p.name)).join(', '))}
${row('En inglés', d.publishers.filter(p => p.type === 'English').map(p => esc(p.name)).join(', '))}
${row('Tipo', esc([AL_FORMAT[d.format] || d.format, AL_COUNTRY[d.country]].filter(Boolean).join(' · ')))}
${row('Publicación', esc(dates))}
${row('Estado', esc([AL_STATUS[d.status], d.statusDetail].filter(Boolean).join(' · ')))}
${row('Extensión', esc(size))}
${row('Anime', [...d.adaptations.map(a => esc(`${AL_ANIME[a.format] || a.format || 'Anime'}${a.year ? ` (${a.year})` : ''}`)), d.anime ? `<span class="muted">adapta: ${esc(d.anime.start)}${d.anime.end ? ` → ${esc(d.anime.end)}` : ''}</span>` : ''].filter(Boolean).join('<br>'))}
</dl>
<div class="tags">${d.genres.map(g => `<span class="tag">${esc(g)}</span>`).join('')}${d.tags.map(g => `<span class="tag soft">${esc(g)}</span>`).join('')}</div>
</div>
</div>
${srcDesc ? `<h2>Sinopsis</h2><p class="desc long">${esc(srcDesc)}</p>` : ''}
${d.description ? (srcDesc ? `<details class="more"><summary>Sinopsis de AniList (en inglés)</summary><p class="desc long">${esc(d.description)}</p></details>` : `<h2>Sinopsis <span class="muted">(en inglés)</span></h2><p class="desc long">${esc(d.description)}</p>`) : ''}
${t.other.length ? `<h2>Otros títulos</h2><p class="muted">${list(t.other)}</p>` : ''}
${d.related.length ? `<h2>Obras relacionadas</h2><div class="grid">${d.related.map(cardOf).join('')}</div>` : ''}
${d.recommendations.length ? `<h2>Si te gusta, prueba</h2><div class="grid">${d.recommendations.map(cardOf).join('')}</div>` : ''}
${d.links.length ? `<h2>Enlaces</h2><div class="tags">${d.links.map(l => `<a class="tag" href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.site)}</a>`).join('')}</div>` : ''}
<p class="muted note">Datos de AniList y MangaUpdates${d.matched?.title ? ` (identificado por título como «${esc(d.matched.title)}»)` : ''}. Las fechas de los capítulos son las de la primera traducción conocida.</p>
</div>`
}
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, 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
const readMap = new Map(reads.map(r => [r.chapter_id, r]))
const isLc = manga.source !== 'md' // sitios en español sin selector de idioma
const langOpts = [...new Set([lang, me.lang, ...chapterLangs, ...manga.languages])].filter(Boolean)
const altSearch = `#/?source=lc&q=${encodeURIComponent(manga.title)}`
const altSearchMi = `#/?source=mi&q=${encodeURIComponent(manga.title)}`
const first = chapters[0]
let mainBtn = ''
if (progress && chapters.some(c => c.id === progress.chapter_id)) {
mainBtn = `<a class="btn primary" href="${readUrl(id, progress.chapter_id, progress.page)}">${icon('play')} Continuar · ${progress.chapter_num ? `Cap. ${esc(progress.chapter_num)}` : 'Oneshot'}, pág. ${progress.page + 1}</a>`
} else if (progress) {
mainBtn = `<a class="btn primary" href="${readUrl(id, progress.chapter_id, progress.page)}">${icon('play')} Continuar · Cap. ${esc(progress.chapter_num ?? '?')} (${esc(LANGS[progress.lang] || progress.lang || '')}), pág. ${progress.page + 1}</a>`
}
if (first && !mainBtn) mainBtn = `<a class="btn primary" href="${readUrl(id, first.id)}">${icon('book')} Empezar a leer</a>`
const menuItems = [
manga.source === 'lc' ? '' : `<a href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
manga.source === 'mi' ? '' : `<a href="${altSearchMi}">${icon('search')} Buscar en M440</a>`,
progress || reads.length ? `<button id="drop" class="danger">${icon('stopRead')} Dejar de leer…</button>` : '',
].join('')
app.innerHTML = `
<div class="detail">
<img class="cover" src="${esc(manga.cover || '/icon.svg')}" alt="">
<div class="detail-body">
<h1>${esc(manga.title)}</h1>
<div class="muted">${esc(manga.authors.join(', '))}${manga.year ? ` · ${manga.year}` : ''}${manga.status ? ` · ${esc(manga.status)}` : ''}</div>
<div class="tags">${manga.tags.map(t => (t.id ? `<a class="tag" href="${tagHref(manga.source, t)}">${esc(t.name)}</a>` : `<span class="tag">${esc(t.name ?? t)}</span>`)).join('')}</div>
<p class="desc">${esc(manga.description)}</p>
<div class="actions">${mainBtn}
<button class="btn" id="follow"></button>
${offline ? '' : `<a class="btn" href="#/info/${id}" title="Autor, revista, puntuación, adaptaciones…">${icon('info')} Info</a>`}
${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 class="psec" id="off" hidden></div></div>
</div>
</div>
<div class="list-head">
<h2>Capítulos <span class="muted">(${chapters.length})</span>${newSet.size ? ` <span class="badge">${newSet.size} ${newSet.size === 1 ? 'nuevo' : 'nuevos'}</span>` : ''}</h2>
<div class="row">
${isLc ? '' : `<select id="lang" title="Idioma de los capítulos">${langOpts.map(l => `<option value="${esc(l)}" ${l === lang ? 'selected' : ''}>${esc(LANGS[l] || l)}</option>`).join('')}</select>`}
${chapters.length > 1 ? `<button class="btn small" id="order">${icon('sort')} Invertir orden</button>` : ''}
</div>
</div>
${chapters.length ? '<ul class="chapters"></ul>' : isLc ? '<p class="muted">Este manga no tiene capítulos.</p>' : `<p class="muted">No hay capítulos legibles en ${esc(LANGS[lang] || lang)} (MangaDex retira algunos por licencia).
Prueba a buscarlo en <a class="tag" href="${altSearch}">LeerCapitulo</a> o <a class="tag" href="${altSearchMi}">M440</a>.
${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `<a class="tag" href="#" data-lang="${esc(l)}">${esc(LANGS[l] || l)}</a>`).join(' ')}` : 'No hay capítulos en ningún idioma.'}</p>`}
<div id="selbar" hidden></div>`
const secHead = (ic, title, status) => `<div class="psec-head"><div class="psec-title">${icon(ic)} ${title}</div>${status ? `<div class="psec-status muted">${status}</div>` : ''}</div>`
// --- Selector de capítulos compartido por "Copia local" (kind 'dl') y "Reescalado HD" (kind 'hd'):
// todo lo pendiente (solo HD), un rango o una selección hecha en la lista de abajo.
let wantedSet = new Set(download?.wanted || [])
let hdSet = new Set(upscale?.chapters || []), hdJobs = upscale?.jobs || {}
let hdInfo = upscale
let sel = null // null | { kind: 'dl' | 'hd', mode: 'pending' | 'range' | 'pick' }
const picked = new Set()
let lastPick = null
const KINDS = {
dl: {
verb: 'Descargar', ic: 'download', modes: { range: 'Rango', pick: 'Elegir' },
// Sin descargar y sin estar ya en cola.
candidates: () => chapters.filter(c => !dlSet.has(c.id) && !wantedSet.has(c.id)),
rangeBase: () => chapters,
mark: c => (dlSet.has(c.id) ? ' (descargado)' : ''),
summary: ids => {
const pages = ids.reduce((n, cid) => n + (chapters.find(c => c.id === cid)?.pages || 0), 0)
return plural(ids.length, 'capítulo') + (pages ? ` · ${pages} págs.` : '')
},
offTitle: c => (dlSet.has(c.id) ? 'Ya descargado' : 'Ya está en cola'),
run: async ids => {
renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title, chapterIds: ids } }))
toast(`${plural(ids.length, 'capítulo')} en cola de descarga`)
},
},
hd: {
verb: 'Reescalar', ic: 'sparkles', modes: { pending: 'Pendientes', range: 'Rango', pick: 'Elegir' },
// Descargados, sin HD y sin estar ya en cola (los fallidos sí, para reintentar).
candidates: () => chapters.filter(c => dlSet.has(c.id) && !hdSet.has(c.id) && (!hdJobs[c.id] || hdJobs[c.id] === 'error')),
rangeBase: () => chapters.filter(c => dlSet.has(c.id)),
mark: c => (hdSet.has(c.id) ? ' (HD)' : ''),
summary: ids => {
const pages = ids.reduce((n, cid) => n + (hdInfo?.pages?.[cid] || 0), 0)
return `${plural(ids.length, 'capítulo')} · ${pages} págs. · ${fmtDur(pages * (hdInfo?.secPerPage || 13))}`
},
offTitle: c => (!dlSet.has(c.id) ? 'No está descargado' : 'Ya está en HD o en cola'),
run: async ids => {
renderHd(await api(`/api/upscale/${id}`, { method: 'POST', body: { chapterIds: ids } }))
toast(`${plural(ids.length, 'capítulo')} en cola para HD`)
},
},
}
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]
const cand = K.candidates()
if (sel.mode === 'pending') return cand.map(c => c.id)
if (sel.mode === 'pick') return cand.filter(c => picked.has(c.id)).map(c => c.id)
const base = K.rangeBase()
let a = base.findIndex(c => c.id === $('#selfrom')?.value), b = base.findIndex(c => c.id === $('#selto')?.value)
if (a < 0 || b < 0) return []
if (a > b) [a, b] = [b, a]
const inRange = new Set(base.slice(a, b + 1).map(c => c.id))
return cand.filter(c => inRange.has(c.id)).map(c => c.id)
}
const updateSummary = () => {
const ids = selIds()
const K = sel && KINDS[sel.kind]
if ($('#selsum')) $('#selsum').textContent = ids.length ? K.summary(ids) : sel?.mode === 'pick' ? 'Marca capítulos en la lista de abajo.' : 'No hay capítulos pendientes en esa selección.'
if ($('#selgo')) $('#selgo').disabled = !ids.length
const bar = $('#selbar')
if (!bar) return
bar.hidden = sel?.mode !== 'pick'
if (sel?.mode !== 'pick') return
bar.innerHTML = `<span>${ids.length ? K.summary(ids) : 'Ningún capítulo marcado'}</span>
<button class="btn small primary" id="selrun" ${ids.length ? '' : 'disabled'}>${icon(K.ic)} ${K.verb}</button>
<button class="btn small" id="selexit" title="Salir de la selección">${icon('x')}</button>`
$('#selrun').addEventListener('click', runSel)
$('#selexit').addEventListener('click', () => setSel(null))
}
async function runSel() {
const ids = selIds()
if (!ids.length) return
const K = KINDS[sel.kind]
try {
if (sel.kind === 'hd' && sel.mode === 'pending' && $('#hdautoform') && $('#hdautoform').checked !== !!hdInfo?.auto) {
await api(`/api/upscale/${id}/auto`, { method: 'POST', body: { auto: $('#hdautoform').checked } }).catch(() => {})
}
picked.clear()
setSel(null)
await K.run(ids)
} catch (err) { toast(err.message) }
}
function setSel(next) {
const wasPick = sel?.mode === 'pick', prevKind = sel?.kind
if (next?.kind !== prevKind) picked.clear()
sel = next
if (prevKind && prevKind !== next?.kind) renderSelForm(prevKind) // cerrar el otro formulario
if (next) renderSelForm(next.kind)
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) {
const box = $(`#${kind}form`)
if (!box) return
const open = sel?.kind === kind
box.hidden = !open
if (!open) { box.innerHTML = ''; return }
const K = KINDS[kind]
const base = K.rangeBase(), cand = K.candidates()
const opt = (c, on) => `<option value="${c.id}" ${on ? 'selected' : ''}>${esc(c.num ? `Cap. ${c.num}` : 'Oneshot')}${K.mark(c)}</option>`
const from = cand[0] || base[0], to = cand[Math.min(cand.length, 10) - 1] || base.at(-1)
box.innerHTML = `
<div class="seg" role="tablist">${Object.entries(K.modes).map(([m, label]) => `<button data-sm="${m}" class="${sel.mode === m ? 'on' : ''}">${label}</button>`).join('')}</div>
${sel.mode === 'range' ? `<div class="row range"><label>Desde <select id="selfrom">${base.map(c => opt(c, c === from)).join('')}</select></label>
<label>hasta <select id="selto">${base.map(c => opt(c, c === to)).join('')}</select></label></div>` : ''}
${sel.mode === 'pick' ? '<div class="note muted">Marca los capítulos en la lista de abajo. Mayús + clic marca un tramo entero.</div>' : ''}
${sel.mode === 'pending' ? `<div class="note muted">Todos los capítulos descargados que aún no están en HD.</div>
<label class="check"><input type="checkbox" id="hdautoform" ${hdInfo?.auto ? 'checked' : ''}> Reescalar también los que se descarguen en el futuro</label>` : ''}
<div class="row"><b id="selsum" class="sum"></b></div>
<div class="psec-actions"><button class="btn small primary" id="selgo">${icon(K.ic)} ${K.verb}</button><button class="btn small" id="selclose">Cerrar</button></div>`
box.querySelectorAll('[data-sm]').forEach(b => b.addEventListener('click', () => setSel({ kind, mode: b.dataset.sm })))
$('#selfrom')?.addEventListener('change', updateSummary)
$('#selto')?.addEventListener('change', updateSummary)
$('#selclose').addEventListener('click', () => setSel(null))
$('#selgo').addEventListener('click', runSel)
}
// Tras un sondeo: repintar lo que dependa de los conjuntos y cerrar el selector si ya no queda nada.
const refreshSel = () => {
renderList()
if (sel && sel.mode !== 'pick') renderSelForm(sel.kind)
if (sel && !KINDS[sel.kind].candidates().length) setSel(null)
else updateSummary()
}
// --- Copia local (descarga) con sondeo del progreso mientras está activa.
let dlTimer = null
const renderDl = d => {
const box = $('#dl')
if (!box) return
const active = d && DL_ACTIVE.includes(d.status)
const some = d?.scope === 'some'
const partial = d && d.done < d.total
const bar = d && (active || partial) ? `<div class="bar dlbar"><i style="width:${downloadPct(d).toFixed(1)}%"></i></div>` : ''
const canPick = !offline && chapters.some(c => !dlSet.has(c.id) && !wantedSet.has(c.id) && !(d?.wanted || []).includes(c.id))
const pickBtn = label => (canPick ? `<button class="btn small ${sel?.kind === 'dl' ? 'on' : ''}" id="dlopen">${icon('download')} ${label}</button>` : '')
const delBtn = d ? `<button class="btn small danger push" id="dldel">${icon('trash')} Eliminar copia${d.size ? ` · ${fmtSize(d.size)}` : ''}</button>` : ''
let status = '', body = '', actions = ''
if (!d) {
body = '<div class="note muted">Guarda capítulos en CBZ en el servidor para leerlos sin conexión (y poder reescalarlos a HD). Puedes bajar el manga entero o solo algunos capítulos.</div>'
actions = `<button class="btn small" id="dlall">${icon('download')} Descargar todo</button>${pickBtn('Elegir capítulos…')}`
} else if (active) {
status = `${downloadLabel(d)} · ${Math.floor(downloadPct(d))}%`
body = `${bar}<div class="note muted">${downloadDetail(d)}</div>`
actions = `${pickBtn('Añadir capítulos…')}<button class="btn small" id="dlcancel">${icon('stop')} Cancelar</button>${delBtn}`
} else {
const have = dlSet.size
status = d.status === 'done'
? `${icon('check')} ${some ? `${have} de ${chapters.length}` : have} capítulos${d.size ? ` · ${fmtSize(d.size)}` : ''}`
: downloadLabel(d)
body = bar + (d.status === 'error' && d.error ? `<div class="error note">${esc(d.error)}</div>` : '')
+ (some && d.status === 'done' ? '<div class="note muted">Solo capítulos elegidos: los nuevos no se descargan solos.</div>' : '')
const resume = d.status === 'error' ? 'Reintentar' : d.status === 'cancelled' ? 'Continuar descarga' : null
actions = some
? `${resume ? `<button class="btn small" id="dlgo">${icon('refresh')} ${resume}</button>` : ''}${pickBtn('Descargar más…')}<button class="btn small" id="dlall" title="Bajar también todos los capítulos que falten y, en adelante, los nuevos">${icon('download')} Descargar todo</button>${delBtn}`
: `<button class="btn small" id="dlgo" title="Descarga los capítulos que falten">${icon(resume ? 'download' : 'refresh')} ${resume || 'Buscar capítulos nuevos'}</button>${delBtn}`
}
if (!$('#dltop')) box.innerHTML = '<div id="dltop" class="psec-grid"></div><div id="dlform" class="hdform" hidden></div>'
$('#dltop').innerHTML = secHead('download', 'Copia local', status) + body + (offline ? '' : `<div class="psec-actions">${actions}</div>`)
$('#dlgo')?.addEventListener('click', async () => {
// Reanudar respeta el modo (todo o capítulos elegidos).
renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title, ...(some ? { chapterIds: d.wanted?.length ? d.wanted : [] } : {}) } }).catch(err => (toast(err.message), d)))
})
$('#dlall')?.addEventListener('click', async () => {
const missing = chapters.filter(c => !dlSet.has(c.id)).length
if (d && !confirm(`¿Descargar todo «${manga.title}»?\nSe bajarán ${plural(missing, 'capítulo')} que faltan y, en adelante, los nuevos.`)) return
renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title } }))
toast(`Descargando en mangas/${manga.title}`)
})
$('#dlopen')?.addEventListener('click', () => setSel(sel?.kind === 'dl' ? null : { kind: 'dl', mode: 'range' }))
$('#dlcancel')?.addEventListener('click', async () => renderDl(await api(`/api/download/${id}/cancel`, { method: 'POST' })))
$('#dldel')?.addEventListener('click', async () => {
if (!confirm(`¿Eliminar la copia local de «${manga.title}»?\nSe borrará la carpeta mangas/${d.folder}${d.size ? ` (${fmtSize(d.size)})` : ''}, incluida la versión HD.\nTu historial de lectura no se toca.`)) return
await api(`/api/download/${id}`, { method: 'DELETE' })
toast('Copia local eliminada')
viewManga(id, langOverride)
})
const wantedBefore = wantedSet.size
wantedSet = new Set(d?.wanted || [])
if (wantedSet.size !== wantedBefore) refreshSel()
clearTimeout(dlTimer)
if (active) dlTimer = setTimeout(async () => {
const next = await api(`/api/download/${id}`).catch(() => d)
if (!$('#dl')) return
if (next && next.done !== d.done) {
const fresh = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`).catch(() => null)
fresh?.downloaded.forEach(x => dlSet.add(x))
if (fresh?.download) next.size = fresh.download.size
if (fresh?.upscale) renderHd(fresh.upscale)
refreshSel()
}
renderDl(next)
}, 2000)
}
// --- Reescalado HD de lo descargado.
let hdTimer = null
const renderHd = u => {
const box = $('#hd')
if (!box) return
clearTimeout(hdTimer)
hdInfo = u
const show = !!(u?.available && u.downloaded && !offline)
box.hidden = !show
if (show) {
const queued = Object.values(u.jobs).filter(s => s !== 'error').length
const active = !!u.current || queued > 0
const c = u.current
const status = `${u.hd} de ${u.downloaded} capítulos${u.size ? ` · ${fmtSize(u.size)}` : ''}`
const remaining = Object.entries(u.jobs).filter(([, st]) => st !== 'error').map(([cid]) => cid)
let body = ''
if (active) {
const pct = c?.total ? (c.pages / c.total) * 100 : 0
body = `<div class="bar dlbar"><i style="width:${pct.toFixed(1)}%"></i></div>
<div class="note muted">${c ? `Cap. ${esc(c.num ?? '?')} · pág. ${c.pages}/${c.total}` : 'Esperando turno…'}${queued > 1 ? ` · ${queued - 1} más en cola` : ''} · faltan ${fmtDur(remaining.reduce((n, cid) => n + (u.pages?.[cid] || 0), 0) * u.secPerPage - (c?.pages || 0) * u.secPerPage)}</div>`
} else if (!u.hd) {
body = `<div class="note muted">Mejora la nitidez con IA (Real-ESRGAN ×2). Unos ${u.secPerPage} s por página; el original no se toca.</div>`
}
if (u.error) body += `<div class="error note">Falló algún capítulo: ${esc(u.error)}</div>`
const canAdd = KINDS.hd.candidates().length > 0
const actions = [
canAdd ? `<button class="btn small ${sel?.kind === 'hd' ? 'on' : ''}" id="hdopen">${icon('sparkles')} ${active ? 'Añadir capítulos…' : 'Reescalar…'}</button>` : '',
active ? `<button class="btn small" id="hdcancel">${icon('stop')} Detener</button>` : '',
u.hd ? `<button class="btn small danger push" id="hddel">${icon('trash')} Quitar HD</button>` : '',
].join('')
// Los formularios se conservan entre sondeos: solo se repinta la parte de arriba.
if (!$('#hdtop')) box.innerHTML = '<div id="hdtop" class="psec-grid"></div><div id="hdform" class="hdform" hidden></div>'
$('#hdtop').innerHTML = secHead('sparkles', 'Reescalado HD', status) + body + (actions ? `<div class="psec-actions">${actions}</div>` : '')
+ (u.auto ? '<div class="note muted">Los capítulos que se descarguen también se reescalan.</div>' : '')
$('#hdopen')?.addEventListener('click', () => setSel(sel?.kind === 'hd' ? null : { kind: 'hd', mode: 'pending' }))
$('#hdcancel')?.addEventListener('click', async () => {
if (!confirm('¿Detener el reescalado?\nSe vacía la cola de este manga; lo ya reescalado se queda.')) return
renderHd(await api(`/api/upscale/${id}/cancel`, { method: 'POST' }))
})
$('#hddel')?.addEventListener('click', async () => {
if (!confirm(`¿Quitar la versión HD de «${manga.title}»?\nSe borra mangas/${download?.folder ?? ''}/.hd${u.size ? ` (${fmtSize(u.size)})` : ''}; los capítulos originales se quedan.`)) return
renderHd(await api(`/api/upscale/${id}`, { method: 'DELETE' }))
toast('Versión HD eliminada')
})
if (active) hdTimer = setTimeout(async () => { const next = await api(`/api/upscale/${id}`).catch(() => null); if (next && $('#hd')) renderHd(next) }, 3000)
}
const changed = (u?.chapters?.length ?? 0) !== hdSet.size || JSON.stringify(u?.jobs || {}) !== JSON.stringify(hdJobs)
hdSet = new Set(u?.chapters || []); hdJobs = u?.jobs || {}
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`
b.classList.toggle('on', isFollowing)
b.title = isFollowing ? 'Recibes avisos de capítulos nuevos. Pulsa para dejar de seguir.' : 'Avisarme cuando salgan capítulos nuevos'
b.hidden = offline
}
renderFollow()
$('#follow').addEventListener('click', async () => {
const r = await api(`/api/follow/${id}`, isFollowing ? { method: 'DELETE' } : { method: 'POST', body: { title: manga.title, cover: manga.cover, lang } })
isFollowing = r.following
renderFollow()
toast(isFollowing ? 'Te avisaremos de los capítulos nuevos' : 'Ya no sigues este manga')
})
$('#drop')?.addEventListener('click', async () => {
if (!confirm(`¿Dejar de leer «${manga.title}»?\nSe quitará del historial, se borrará el progreso de sus capítulos y dejarás de seguirlo.`)) return
await api(`/api/history/${id}`, { method: 'DELETE' })
toast('Quitado del historial')
// Volver a donde se estaba (salvo el lector o la propia ficha, que lo volverían a marcar) o al inicio.
const back = prevHash && !prevHash.startsWith('#/read/') && !prevHash.startsWith(`#/manga/${id}`) && prevHash !== '#/login'
location.hash = back ? prevHash : '#/'
})
// Cerrar el menú ⋯ al pulsar fuera.
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); 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)
let desc = store.get('chapterOrderDesc', true)
const chapMeta = (c, r, done) => (newSet.has(String(c.num ?? c.id)) ? '<span class="badge">NUEVO</span> ' : '')
+ (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}`) : manga.source === 'md' ? esc(c.group) : '', chapDate(c)].filter(Boolean).join(' · ')
// Fecha de cada capítulo: la primera publicación conocida (MangaDex/MangaUpdates, se pide aparte) o, si
// no, la de la fuente, salvo que sea la de una subida masiva (la misma fecha en muchos capítulos).
let extDates = {}
const srcDatesOk = (() => {
const ds = chapters.map(c => c.publishAt?.slice(0, 10)).filter(Boolean)
const n = {}
for (const d of ds) n[d] = (n[d] || 0) + 1
return ds.length < 8 || Math.max(...Object.values(n)) / ds.length <= 0.25
})()
// Fechas por capítulo: { d, approx } por id. Un capítulo no puede ser posterior al siguiente (las
// traducciones a veces llegan desordenadas) y los huecos entre dos fechas conocidas se estiman.
let dateMap = new Map()
function buildDates() {
const DAY = 864e5
const known = chapters.map(c => {
const d = extDates[numKey(c.num)] || (srcDatesOk ? c.publishAt?.slice(0, 10) : null)
const t = d ? Date.parse(`${d}T12:00:00Z`) : NaN
return { c, n: parseFloat(c.num), t: Number.isNaN(t) ? null : t }
}).filter(x => Number.isFinite(x.n)).sort((a, b) => a.n - b.n)
let min = Infinity
for (let i = known.length - 1; i >= 0; i--) if (known[i].t != null) { min = Math.min(min, known[i].t); known[i].t = min }
dateMap = new Map()
let prev = null
known.forEach((x, i) => {
if (x.t == null) return
dateMap.set(x.c.id, { t: x.t, approx: false })
if (prev && i - prev.i > 1 && i - prev.i <= 150) {
for (let k = prev.i + 1; k < i; k++) {
const f = (known[k].n - prev.n) / (x.n - prev.n)
dateMap.set(known[k].c.id, { t: Math.round((prev.t + f * (x.t - prev.t)) / DAY) * DAY, approx: true })
}
}
prev = { i, n: x.n, t: x.t }
})
}
buildDates()
const chapDate = c => {
const e = dateMap.get(c.id)
if (!e) return ''
const d = new Date(e.t).toISOString().slice(0, 10)
return `<span class="cdate${e.approx ? ' approx' : ''}" title="${e.approx ? 'Fecha estimada (entre capítulos con fecha conocida)' : 'Primera publicación conocida'}">${e.approx ? '≈ ' : ''}${fmtDate(d)}</span>`
}
function renderList() {
if (!ul) return
const list = desc ? [...chapters].reverse() : chapters
const picking = sel?.mode === 'pick'
const pickable = new Set(picking ? KINDS[sel.kind].candidates().map(c => c.id) : [])
ul.classList.toggle('picking', picking)
ul.innerHTML = list.map((c, i) => {
const r = readMap.get(c.id)
const done = r && r.total_pages && r.page >= r.total_pages - 1
if (picking) {
const ok = pickable.has(c.id)
return `<li><label class="pick ${ok ? '' : 'off'}" title="${ok ? '' : KINDS[sel.kind].offTitle(c)}">
<input type="checkbox" data-i="${i}" data-cid="${c.id}" ${ok ? '' : 'disabled'} ${picked.has(c.id) ? 'checked' : ''}>
<span class="t">${esc(chapLabel(c))}</span><span class="meta">${chapMeta(c, r, done)}</span></label></li>`
}
const cls = progress?.chapter_id === c.id ? 'current' : done ? 'read' : ''
return `<li><a class="${cls}" href="${readUrl(id, c.id, r && !done ? r.page : (done ? 0 : null))}"><span>${esc(chapLabel(c))}</span><span class="meta">${chapMeta(c, r, done)}</span></a></li>`
}).join('')
}
// Selección en la lista (Mayús + clic marca el tramo desde el último marcado).
ul?.addEventListener('click', e => {
const box = e.target.closest('input[data-cid]')
if (!box) return
const i = Number(box.dataset.i)
if (e.shiftKey && lastPick != null) {
const [a, b] = [Math.min(lastPick, i), Math.max(lastPick, i)]
ul.querySelectorAll('input[data-cid]:not(:disabled)').forEach(x => {
const k = Number(x.dataset.i)
if (k >= a && k <= b) { x.checked = box.checked; box.checked ? picked.add(x.dataset.cid) : picked.delete(x.dataset.cid) }
})
} else box.checked ? picked.add(box.dataset.cid) : picked.delete(box.dataset.cid)
lastPick = i
updateSummary()
})
renderList()
if (!offline && serverOnline) api(`/api/meta/${id}/dates`).then(r => { extDates = r.dates || {}; if (Object.keys(extDates).length) { buildDates(); 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) })
}
// ---------- Lector ----------
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).
function defaultPrefs(manga) {
if (['Manhwa', 'Manhua', 'Webtoon'].includes(manga.type)) return { mode: 'vertical', fit: 'width', dir: 'ltr' }
if (manga.type === 'Manga') return { mode: 'paged', fit: 'height', dir: 'rtl' }
const legacy = store.get('readerSettings', {})
return { mode: READ_MODES[legacy.mode] ? legacy.mode : 'paged', fit: legacy.fit === 'width' ? 'width' : 'height', dir: legacy.dir === 'rtl' ? 'rtl' : 'ltr' }
}
async function viewReader(mangaId, chapterId, startPage) {
document.body.classList.add('reading')
app.innerHTML = '<div class="reader"><div class="loading">Cargando capítulo…</div></div>'
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).
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([
api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`),
api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}&hd=${globalSettings.hd === false ? 0 : 1}`),
])
const { manga, chapters } = info
const settings = { ...globalSettings, ...defaultPrefs(manga), ...(info.prefs || {}) }
const idx = chapters.findIndex(c => c.id === chapterId)
const chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' }
const prev = idx > 0 ? chapters[idx - 1] : null
const next = idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1] : null
const pages = chap.pages
const total = pages.length
let page = Math.min(Math.max(startPage >= 0 ? startPage : chap.savedPage || 0, 0), total - 1)
let shown = [page] // páginas visibles (2 en doble página)
const isPaged = () => settings.mode !== 'vertical'
const isDouble = () => settings.mode === 'double' && window.innerWidth >= 700 && window.innerWidth > window.innerHeight
const lastShown = () => (isPaged() ? shown.at(-1) : page)
// Guardado del progreso (con debounce y al salir de la página).
let saveTimer = null
const payload = () => ({
mangaId, chapterId, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapter.num ?? null,
chapterTitle: chapter.title, lang: manga.source !== 'md' ? '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; 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.onLine ? navigator.sendBeacon('/api/progress', JSON.stringify(payload())) : queueProgress(payload()) } }
const onVis = () => document.visibilityState === 'hidden' && beacon()
document.addEventListener('visibilitychange', onVis)
window.addEventListener('pagehide', beacon)
saveNow()
const chapOpts = chapters.map(c => `<option value="${c.id}" ${c.id === chapterId ? 'selected' : ''}>${esc(chapLabel(c))}</option>`).join('')
app.innerHTML = `
<div class="reader">
<div class="rbar top">
<a class="btn small" href="#/manga/${mangaId}" title="Volver a la ficha">${icon('back')}</a>
<div class="ttl"><b>${esc(manga.title)}</b></div>
<select id="chap" style="max-width:40vw">${chapOpts}</select>
<button class="btn small" id="clipbtn" title="Recortar un panel (se guarda en Recortes y se mejora a HD)">${icon('scissors')}</button>
<button class="btn small" id="modequick"></button>
<button class="btn small" id="ropts" title="Opciones de lectura">${icon('settings')}</button>
</div>
<div class="rpanel" hidden>
<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="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>
<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>'}
${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>
<div class="rbar bottom">
${prev ? `<a class="btn small" href="${readUrl(mangaId, prev.id)}">${icon('prev')} Cap. ant.</a>` : ''}
<span id="pnum" class="muted"></span>
<input type="range" id="slider" min="1" max="${total}" value="${page + 1}">
<button class="btn small" id="zoomreset" hidden title="Quitar zoom">${icon('zoomOut')}</button>
${next ? `<a class="btn small" href="${readUrl(mangaId, next.id)}">Cap. sig. ${icon('next')}</a>` : ''}
</div>
</div>`
const reader = $('.reader', app)
const box = $('#pages', app)
const slider = $('#slider', app)
$('#mode').value = settings.mode
$('#fit').value = settings.fit
$('#dir').value = settings.dir
// Botón rápido páginas ⇄ vertical (vuelve al último modo por páginas usado).
let lastPaged = settings.mode === 'double' ? 'double' : 'paged'
function renderModeQuick() {
const b = $('#modequick')
b.innerHTML = icon(isPaged() ? 'scroll' : 'pages')
b.title = isPaged() ? 'Leer en vertical (desplazamiento continuo)' : 'Leer por páginas'
$('#mode').value = settings.mode
}
$('#filter').value = FILTERS[settings.filter] ? settings.filter : 'none'
$('#bright').value = settings.brightness
const applyFilter = () => {
const f = [FILTERS[settings.filter]?.[1] || '', settings.brightness != 100 ? `brightness(${settings.brightness / 100})` : ''].join(' ').trim()
box.style.setProperty('--page-filter', f || 'none')
}
applyFilter()
if ($('#saver')) $('#saver').checked = settings.saver
if ($('#hd')) $('#hd').checked = settings.hd !== false
const endBlock = `<div class="chapter-end"><p class="muted">Fin del capítulo ${esc(chapter.num ?? '')}</p>
${next ? `<a class="btn primary" href="${readUrl(mangaId, next.id)}">${icon('next')} Siguiente: ${esc(chapLabel(next))}</a>` : '<p>No hay más capítulos disponibles.</p>'}
<a class="btn" href="#/manga/${mangaId}">${icon('back')} Volver a la ficha</a></div>`
if (!info.prefs && !store.get(`autoPrefs:${mangaId}`)) {
store.set(`autoPrefs:${mangaId}`, 1)
if (manga.type) toast(`${manga.type}: ${READ_MODES[settings.mode].toLowerCase()}${settings.mode === 'vertical' ? '' : `, ${settings.dir === 'rtl' ? 'der → izq' : 'izq → der'}`}. Lo que cambies se recordará.`)
}
const setPageLabel = () => {
$('#pnum').textContent = `${shown.length > 1 ? `${shown[0] + 1}-${shown[1] + 1}` : lastShown() + 1} / ${total}`
slider.value = lastShown() + 1
}
let observer = null
let atEnd = false
// --- Doble página: la 1.ª va sola (portada) y las apaisadas (ya dobles) también.
const wide = new Set()
const isWide = img => img.naturalWidth > img.naturalHeight * 1.1
function groupOf(i) {
if (!isDouble()) return [i]
for (let s = 0; s < total;) {
const g = s === 0 || wide.has(s) || wide.has(s + 1) || s + 1 >= total ? [s] : [s, s + 1]
if (g.includes(i)) return g
s += g.length
}
return [i]
}
function preload() {
for (let k = 1; k <= 4; k++) {
const i = shown.at(-1) + k
if (i >= total) break
const im = new Image()
im.onload = () => isWide(im) && wide.add(i)
im.src = pages[i]
}
}
// dir: 1 hacia delante, -1 hacia atrás, 0 sin animación (salto con la barra, cambio de modo…).
function showPaged(dir = 0) {
finishTurn()
const old = $('.stage', box)
const stage = document.createElement('div')
stage.className = 'stage'
stage.classList.toggle('two', shown.length === 2)
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', () => {
const i = Number(img.dataset.i)
if (isWide(img) && !wide.has(i)) {
wide.add(i)
if (shown.length === 2 && shown.includes(i)) goTo(shown[0], false, true) // reagrupar
}
}, { once: true }))
resetZoom()
window.scrollTo(0, 0)
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() {
finishTurn()
atEnd = true
resetZoom()
box.innerHTML = endBlock
reader.classList.remove('hide-ui')
}
function goTo(n, fromScroll = false, force = false, dir = 0) {
if (isPaged()) {
if (n >= total) return showEnd()
n = Math.min(Math.max(n, 0), total - 1)
const g = groupOf(n)
const changed = atEnd || g[0] !== shown[0] || g.length !== shown.length
const wasEnd = atEnd
atEnd = false
shown = g
page = g[0]
setPageLabel()
if (changed || force || !$('.stage img', box)) showPaged(changed && !wasEnd ? dir : 0)
if (changed) scheduleSave()
return
}
n = Math.min(Math.max(n, 0), total - 1)
const changed = n !== page
page = n
shown = [n]
setPageLabel()
if (!fromScroll) box.querySelectorAll('img')[page]?.scrollIntoView()
if (changed) scheduleSave()
}
const forward = () => { if (!atEnd) goTo(shown.at(-1) + 1, false, false, 1) }
const backward = () => {
if (atEnd) { box.innerHTML = ''; goTo(total - 1, false, true); return }
if (page > 0) goTo(groupOf(page - 1)[0], false, false, -1)
}
function render() {
observer?.disconnect()
const keep = lastShown()
resetZoom()
box.className = isPaged() ? `paged ${settings.fit === 'width' ? 'fit-width' : ''} ${isDouble() ? 'double' : ''}` : `vertical fit-${settings.fit}`
renderModeQuick()
box.dataset.dir = settings.dir
// En der → izq la barra de progreso también avanza de derecha a izquierda (en vertical no aplica).
slider.dir = isPaged() && settings.dir === 'rtl' ? 'rtl' : 'ltr'
$('#dir').hidden = !isPaged()
if (isPaged()) {
box.innerHTML = ''
atEnd = false
shown = []
goTo(keep)
return
}
page = keep
shown = [keep]
box.innerHTML = pages.map((src, i) => `<img data-i="${i}" ${i < page + 3 ? `src="${src}"` : `data-src="${src}"`} alt="">`).join('') + endBlock
box.querySelectorAll('img').forEach(img => img.addEventListener('load', () => img.setAttribute('data-loaded', ''), { once: true }))
const lazy = new IntersectionObserver(entries => entries.forEach(e => {
if (e.isIntersecting && e.target.dataset.src) { e.target.src = e.target.dataset.src; delete e.target.dataset.src }
}), { rootMargin: '2000px 0px' })
// No seguir el scroll hasta haber saltado a la página guardada, para no pisarla con la 0.
let ready = false
observer = new IntersectionObserver(entries => {
if (!ready) return
for (const e of entries) if (e.isIntersecting) goTo(Number(e.target.dataset.i), true)
}, { rootMargin: '-45% 0px -45% 0px' })
const imgs = [...box.querySelectorAll('img')]
imgs.forEach(img => { lazy.observe(img); observer.observe(img) })
const lazyDisconnect = observer.disconnect.bind(observer)
observer.disconnect = () => { lazyDisconnect(); lazy.disconnect() }
const before = imgs.slice(0, page + 1).map(img => img.complete ? null : new Promise(r => { img.addEventListener('load', r, { once: true }); img.addEventListener('error', r, { once: true }) }))
Promise.race([Promise.all(before), new Promise(r => setTimeout(r, 8000))]).then(() => {
imgs[page]?.scrollIntoView()
lastY = window.scrollY
setTimeout(() => (ready = true), 300)
})
setPageLabel()
}
// --- 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 stageEl = () => $('.stage:not(.leaving)', box)
function applyZoom() {
const st = stageEl()
if (st) st.style.transform = zoom.s === 1 ? '' : `translate(${zoom.x}px, ${zoom.y}px) scale(${zoom.s})`
box.classList.toggle('zoomed', zoom.s > 1)
$('#zoomreset').hidden = zoom.s === 1
}
function clampPan() {
const st = stageEl()
if (!st) return
const W = st.offsetWidth, H = st.offsetHeight
zoom.x = Math.min(0, Math.max(W - W * zoom.s, zoom.x))
zoom.y = Math.min(0, Math.max(H - H * zoom.s, zoom.y))
}
// (px, py): punto en coordenadas del contenedor que queda fijo al cambiar la escala.
function zoomAt(s, px, py) {
s = Math.min(5, Math.max(1, s))
const cx = (px - zoom.x) / zoom.s, cy = (py - zoom.y) / zoom.s
zoom.s = s
zoom.x = px - cx * s
zoom.y = py - cy * s
if (s === 1) { zoom.x = 0; zoom.y = 0 }
clampPan()
applyZoom()
}
function resetZoom() { zoom.s = 1; zoom.x = 0; zoom.y = 0; applyZoom() }
const local = (cx, cy) => { const r = box.getBoundingClientRect(); return [cx - r.left, cy - r.top] }
const toggleUi = () => { reader.classList.toggle('hide-ui'); if (reader.classList.contains('hide-ui')) $('.rpanel', reader).hidden = true }
const pts = new Map()
let gesture = null, tap = null, lastTap = { t: 0, x: 0, y: 0 }, uiTimer = null
function onTap(cx, cy) {
const now = Date.now()
const dbl = now - lastTap.t < 300 && Math.hypot(cx - lastTap.x, cy - lastTap.y) < 40
lastTap = dbl ? { t: 0, x: 0, y: 0 } : { t: now, x: cx, y: cy }
const xr = cx / window.innerWidth
const center = xr > 0.3 && xr < 0.7
if (dbl && (center || zoom.s > 1)) {
clearTimeout(uiTimer)
if (zoom.s > 1) resetZoom()
else zoomAt(2.5, ...local(cx, cy))
return
}
if (zoom.s > 1) return // con zoom, un toque no pasa página
if (center) { clearTimeout(uiTimer); uiTimer = setTimeout(toggleUi, 280); return }
;(xr >= 0.7) === (settings.dir === 'ltr') ? forward() : backward()
}
box.addEventListener('pointerdown', e => {
if (!isPaged() || atEnd || e.target.closest('a,button,select,input')) return
if (e.pointerType === 'mouse' && e.button !== 0) return
pts.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (pts.size === 1) {
tap = { x: e.clientX, y: e.clientY, t: Date.now(), moved: false }
gesture = zoom.s > 1 ? { type: 'pan', x: e.clientX, y: e.clientY } : null
if (gesture) box.setPointerCapture?.(e.pointerId)
} else if (pts.size === 2) {
const [a, b] = [...pts.values()]
gesture = { type: 'pinch', d: Math.hypot(a.x - b.x, a.y - b.y), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 }
tap = null
}
})
box.addEventListener('pointermove', e => {
if (!pts.has(e.pointerId)) return
pts.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (tap && Math.hypot(e.clientX - tap.x, e.clientY - tap.y) > 10) tap.moved = true
if (gesture?.type === 'pinch' && pts.size >= 2) {
const [a, b] = [...pts.values()]
const d = Math.hypot(a.x - b.x, a.y - b.y), mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2
zoom.x += mx - gesture.mx
zoom.y += my - gesture.my
zoomAt(zoom.s * (d / gesture.d), ...local(mx, my))
Object.assign(gesture, { d, mx, my })
} else if (gesture?.type === 'pan') {
zoom.x += e.clientX - gesture.x
zoom.y += e.clientY - gesture.y
gesture.x = e.clientX
gesture.y = e.clientY
clampPan()
applyZoom()
}
})
const endPointer = e => {
if (!pts.delete(e.pointerId)) return
if (pts.size === 1 && zoom.s > 1) {
const [p] = [...pts.values()]
gesture = { type: 'pan', x: p.x, y: p.y }
} else if (pts.size === 0) {
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
tap = null
}
}
box.addEventListener('pointerup', endPointer)
box.addEventListener('pointercancel', endPointer)
box.addEventListener('wheel', e => {
if (!isPaged() || atEnd || !e.ctrlKey) return // Ctrl+rueda o pellizco del trackpad
e.preventDefault()
zoomAt(zoom.s * Math.exp(-e.deltaY / 200), ...local(e.clientX, e.clientY))
}, { passive: false })
// En modo vertical el zoom es el nativo del navegador; un toque muestra/oculta las barras.
box.addEventListener('click', e => {
if (isPaged() || e.target.closest('a,button')) return
reader.classList.toggle('hide-ui')
})
$('#zoomreset').addEventListener('click', resetZoom)
$('#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, anim: settings.anim })
const savePrefs = () => {
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))
}
$('#mode').addEventListener('change', e => { settings.mode = e.target.value; if (isPaged()) lastPaged = settings.mode; savePrefs(); render() })
$('#modequick').addEventListener('click', () => {
settings.mode = isPaged() ? 'vertical' : lastPaged
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() })
$('#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('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() })
$('#hd')?.addEventListener('change', e => { settings.hd = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); router() })
$('#hdone')?.addEventListener('click', async e => {
e.target.closest('button').disabled = true
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)
// y el servidor lo reescala aparte. El rectángulo se guarda en coordenadas del documento para
// poder desplazarse (rueda) en vertical mientras se recorta.
let crop = null
function endCrop() {
if (!crop) return
crop.ov.remove()
window.removeEventListener('scroll', crop.draw)
crop = null
$('#clipbtn')?.classList.remove('on')
}
function startCrop() {
if (crop) return endCrop()
if (atEnd || !box.querySelector('img')) return toast('Vuelve a una página para recortar')
reader.classList.remove('hide-ui')
$('.rpanel', reader).hidden = true
const ov = document.createElement('div')
ov.className = 'cropov'
ov.innerHTML = `<div class="crop-bar"><span>${icon('scissors')} Arrastra sobre el panel que quieres guardar</span>
<button class="btn small" data-a="cancel">${icon('x')} Cancelar</button>
<button class="btn small primary" data-a="save" disabled>${icon('check')} Guardar</button></div>
<div class="crop-rect" hidden></div>`
reader.append(ov)
$('#clipbtn').classList.add('on')
const rectEl = $('.crop-rect', ov)
crop = { ov, r: null, start: null }
crop.draw = () => {
const r = crop?.r
if (!r) return
Object.assign(rectEl.style, { left: `${r.l - scrollX}px`, top: `${r.t - scrollY}px`, width: `${r.r - r.l}px`, height: `${r.b - r.t}px` })
rectEl.hidden = false
}
window.addEventListener('scroll', crop.draw, { passive: true })
ov.addEventListener('wheel', e => { e.preventDefault(); scrollBy(0, e.deltaY) }, { passive: false })
ov.addEventListener('pointerdown', e => {
if (e.target.closest('.crop-bar') || (e.pointerType === 'mouse' && e.button !== 0)) return
ov.setPointerCapture(e.pointerId)
crop.start = { x: e.clientX + scrollX, y: e.clientY + scrollY }
crop.r = null
rectEl.hidden = true
$('[data-a=save]', ov).disabled = true
})
ov.addEventListener('pointermove', e => {
if (!crop.start) return
const x = e.clientX + scrollX, y = e.clientY + scrollY
crop.r = { l: Math.min(x, crop.start.x), t: Math.min(y, crop.start.y), r: Math.max(x, crop.start.x), b: Math.max(y, crop.start.y) }
crop.draw()
})
const up = () => {
if (!crop?.start) return
crop.start = null
const ok = crop.r && crop.r.r - crop.r.l >= 12 && crop.r.b - crop.r.t >= 12
if (!ok) { crop.r = null; rectEl.hidden = true }
$('[data-a=save]', ov).disabled = !ok
}
ov.addEventListener('pointerup', up)
ov.addEventListener('pointercancel', up)
$('[data-a=cancel]', ov).addEventListener('click', endCrop)
$('[data-a=save]', ov).addEventListener('click', e => saveCrop(e.target.closest('button')))
}
// La imagen bajo el rectángulo (la de mayor intersección) y la zona en fracciones de esa imagen.
function cropTarget(r) {
const c = { l: r.l - scrollX, t: r.t - scrollY, r: r.r - scrollX, b: r.b - scrollY }
let best = null
for (const img of box.querySelectorAll('img')) {
if (!img.complete || !img.naturalWidth) continue
const b = img.getBoundingClientRect()
const w = Math.min(c.r, b.right) - Math.max(c.l, b.left), h = Math.min(c.b, b.bottom) - Math.max(c.t, b.top)
if (w > 0 && h > 0 && (!best || w * h > best.area)) best = { img, b, area: w * h }
}
if (!best) return null
const { img, b } = best
const x0 = Math.max(c.l, b.left) - b.left, y0 = Math.max(c.t, b.top) - b.top
const x1 = Math.min(c.r, b.right) - b.left, y1 = Math.min(c.b, b.bottom) - b.top
return { img, page: Number(img.dataset.i), fx: x0 / b.width, fy: y0 / b.height, fw: (x1 - x0) / b.width, fh: (y1 - y0) / b.height }
}
async function saveCrop(btn) {
const t = crop?.r && cropTarget(crop.r)
if (!t) return toast('Marca una zona sobre la página')
btn.disabled = true
try {
const u = new URL(t.img.currentSrc || t.img.src, location.href)
u.searchParams.delete('hd') // recortar del original: el servidor lo reescala él mismo
const src = await new Promise((resolve, reject) => {
const im = new Image()
im.onload = () => resolve(im)
im.onerror = () => reject(new Error('No se pudo cargar la página'))
im.src = u.href
})
const sx = Math.round(t.fx * src.naturalWidth), sy = Math.round(t.fy * src.naturalHeight)
const sw = Math.max(1, Math.round(t.fw * src.naturalWidth)), sh = Math.max(1, Math.round(t.fh * src.naturalHeight))
if (sw < 16 || sh < 16) throw new Error('La zona es demasiado pequeña')
const k = Math.min(1, 4000 / Math.max(sw, sh))
const cv = document.createElement('canvas')
cv.width = Math.round(sw * k); cv.height = Math.round(sh * k)
cv.getContext('2d').drawImage(src, sx, sy, sw, sh, 0, 0, cv.width, cv.height)
const blob = await new Promise(r => cv.toBlob(r, 'image/png'))
const qs = new URLSearchParams({ mangaId, chapterId, page: t.page, mangaTitle: manga.title, chapterLabel: chapLabel(chapter), cover: manga.cover || '' })
const res = await fetch(`/api/clips?${qs}`, { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob })
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || `Error ${res.status}`)
endCrop()
toast(data.status === 'off' ? 'Recorte guardado' : 'Recorte guardado: se está mejorando a HD')
} catch (err) {
toast(err.message)
btn.disabled = false
}
}
$('#clipbtn').addEventListener('click', startCrop)
const onKey = e => {
if (crop) { if (e.key === 'Escape') endCrop(); return }
if (!isPaged() || e.target.matches('input,select')) return
const fwdKey = settings.dir === 'ltr' ? 'ArrowRight' : 'ArrowLeft'
const backKey = settings.dir === 'ltr' ? 'ArrowLeft' : 'ArrowRight'
const [cx, cy] = [box.clientWidth / 2, Math.min(box.clientHeight, window.innerHeight) / 2]
if (e.key === fwdKey || e.key === ' ') { e.preventDefault(); forward() }
else if (e.key === backKey) backward()
else if (e.key === '+' || e.key === '=') zoomAt(zoom.s * 1.25, cx, cy)
else if (e.key === '-') zoomAt(zoom.s / 1.25, cx, cy)
else if (e.key === '0') resetZoom()
}
document.addEventListener('keydown', onKey)
let lastY = 0
const onScroll = () => {
if (settings.mode !== 'vertical') return
const y = window.scrollY
if (Math.abs(y - lastY) > 30) { reader.classList.toggle('hide-ui', y > lastY); lastY = y }
}
window.addEventListener('scroll', onScroll, { passive: true })
// Al girar la tablet o redimensionar, rehacer la agrupación de doble página.
let resizeTimer = null
const onResize = () => {
clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => {
if (!isPaged() || atEnd) return
box.classList.toggle('double', isDouble())
goTo(lastShown(), false, true)
}, 200)
}
window.addEventListener('resize', onResize)
render()
cleanup = () => {
endCrop()
if (saveTimer) saveNow()
observer?.disconnect()
clearTimeout(uiTimer)
document.removeEventListener('visibilitychange', onVis)
window.removeEventListener('pagehide', beacon)
document.removeEventListener('keydown', onKey)
window.removeEventListener('scroll', onScroll)
window.removeEventListener('resize', onResize)
}
}
// ---------- Recortes ----------
const clipStatus = c => c.status === 'done' ? `<span class="hd" title="Reescalado ×${c.scale}">HD</span>`
: c.status === 'error' ? '<span class="hd pending" title="Falló el reescalado">HD!</span>'
: c.status === 'off' ? '' : '<span class="hd pending" title="Reescalando…">HD…</span>'
async function viewClips(params) {
const mangaId = params.get('manga')
const here = () => location.hash.split('?')[0] === '#/clips'
app.innerHTML = '<div class="loading">Cargando…</div>'
let data = await api(`/api/clips${mangaId ? `?manga=${encodeURIComponent(mangaId)}` : ''}`)
if (!here()) return
const total = data.mangas.reduce((a, m) => a + m.n, 0)
app.innerHTML = `<h1>${icon('scissors')} Recortes</h1>
${data.mangas.length > 1 || mangaId ? `<div class="tabs">
<button data-m="" class="${mangaId ? '' : 'on'}">Todos (${total})</button>
${data.mangas.map(m => `<button data-m="${esc(m.id)}" class="${m.id === mangaId ? 'on' : ''}">${esc(m.title)} (${m.n})</button>`).join('')}</div>` : ''}
<div id="cgrid" class="clips"></div>
<div id="lb" class="lightbox" hidden></div>`
app.querySelectorAll('.tabs [data-m]').forEach(b => b.addEventListener('click', () => { location.hash = b.dataset.m ? `#/clips?manga=${encodeURIComponent(b.dataset.m)}` : '#/clips' }))
const grid = $('#cgrid'), lb = $('#lb')
let open = -1, showHd = true
function renderGrid() {
grid.innerHTML = data.clips.length ? data.clips.map((c, i) => `
<figure class="clip" data-i="${i}">
<img src="${c.hd || c.img}" alt="" loading="lazy" style="aspect-ratio:${c.width}/${c.height}">
<figcaption><b>${esc(c.mangaTitle)}</b><span>${esc((c.chapterLabel || '').split(' — ')[0])} · p. ${c.page + 1} ${clipStatus(c)}</span>
${c.note ? `<i>${esc(c.note)}</i>` : ''}</figcaption>
</figure>`).join('')
: `<p class="muted">Aún no tienes recortes. En el lector, toca ${icon('scissors')} y arrastra sobre un panel para guardarlo aquí; se mejora a HD automáticamente.</p>`
grid.querySelectorAll('.clip').forEach(f => f.addEventListener('click', () => openClip(Number(f.dataset.i))))
}
function openClip(i) {
open = i
const c = data.clips[i]
if (!c) return closeClip()
const src = c.hd && showHd ? c.hd : c.img
const size = c.scale ? `${c.width}×${c.height} → ${c.width * c.scale}×${c.height * c.scale} (×${c.scale})` : `${c.width}×${c.height}`
lb.innerHTML = `
<div class="lb-img"><img src="${src}" alt=""></div>
<div class="lb-side">
<div class="row" style="justify-content:space-between">
<span class="muted">${i + 1} / ${data.clips.length}</span>
<span class="row" style="gap:6px">
<button class="btn small" data-a="prev" ${i ? '' : 'disabled'} title="Anterior">${icon('prev')}</button>
<button class="btn small" data-a="next" ${i < data.clips.length - 1 ? '' : 'disabled'} title="Siguiente">${icon('next')}</button>
<button class="btn small" data-a="close" title="Cerrar">${icon('x')}</button>
</span>
</div>
<h2 style="margin:4px 0 0"><a href="#/manga/${c.mangaId}">${esc(c.mangaTitle)}</a></h2>
<div class="muted">${esc(c.chapterLabel || '')} · página ${c.page + 1}</div>
<div class="muted" style="font-size:.85rem">${size} · ${timeAgo(c.createdAt)} ${clipStatus(c)}</div>
${c.status === 'error' ? `<p class="error" style="font-size:.85rem">${esc(c.error || 'Error al reescalar')}</p>` : ''}
${c.status === 'off' ? '<p class="muted" style="font-size:.85rem">Reescalado no disponible en este servidor.</p>' : ''}
<input id="note" placeholder="Nota (opcional)" maxlength="1000" value="${esc(c.note || '')}">
<a class="btn primary" href="${readUrl(c.mangaId, c.chapterId, c.page)}">${icon('book')} Ir a la página</a>
${c.hd ? `<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="showhd" ${showHd ? 'checked' : ''}> Ver versión HD</label>` : ''}
<div class="row" style="gap:6px;flex-wrap:wrap">
<a class="btn small" href="${(c.hd && showHd ? c.hd + '&' : c.img + '?')}dl=1" download>${icon('download')} Descargar</a>
${c.status === 'error' || (c.status === 'off' && data.available) ? `<button class="btn small" data-a="retry">${icon('refresh')} Reintentar HD</button>` : ''}
<button class="btn small danger" data-a="del">${icon('trash')} Eliminar</button>
</div>
</div>`
lb.hidden = false
document.body.classList.add('noscroll')
lb.querySelector('[data-a=prev]').addEventListener('click', () => openClip(open - 1))
lb.querySelector('[data-a=next]').addEventListener('click', () => openClip(open + 1))
lb.querySelector('[data-a=close]').addEventListener('click', closeClip)
$('#showhd', lb)?.addEventListener('change', e => { showHd = e.target.checked; openClip(open) })
$('#note', lb).addEventListener('change', async e => {
try { Object.assign(c, await api(`/api/clips/${c.id}`, { method: 'PATCH', body: { note: e.target.value } })); renderGrid(); toast('Nota guardada') } catch (err) { toast(err.message) }
})
lb.querySelector('[data-a=retry]')?.addEventListener('click', async () => {
try { Object.assign(c, await api(`/api/clips/${c.id}/retry`, { method: 'POST' })); renderGrid(); openClip(open); poll() } catch (err) { toast(err.message) }
})
lb.querySelector('[data-a=del]').addEventListener('click', async () => {
if (!confirm('¿Eliminar este recorte?')) return
try {
await api(`/api/clips/${c.id}`, { method: 'DELETE' })
data.clips.splice(open, 1)
renderGrid()
data.clips.length ? openClip(Math.min(open, data.clips.length - 1)) : closeClip()
} catch (err) { toast(err.message) }
})
}
function closeClip() { open = -1; lb.hidden = true; lb.innerHTML = ''; document.body.classList.remove('noscroll') }
lb.addEventListener('click', e => { if (e.target === lb || e.target.classList.contains('lb-img')) closeClip() })
const onKey = e => {
if (open < 0 || e.target.matches('input')) return
if (e.key === 'Escape') closeClip()
else if (e.key === 'ArrowLeft' && open > 0) openClip(open - 1)
else if (e.key === 'ArrowRight' && open < data.clips.length - 1) openClip(open + 1)
}
document.addEventListener('keydown', onKey)
// Mientras haya recortes en cola, refrescar su estado.
let timer = null
function poll() {
clearTimeout(timer)
if (!data.clips.some(c => c.status === 'queued' || c.status === 'running')) return
timer = setTimeout(async () => {
if (!here()) return
const fresh = await api(`/api/clips${mangaId ? `?manga=${encodeURIComponent(mangaId)}` : ''}`).catch(() => null)
if (!fresh || !here()) return
const key = d => d.clips.map(c => `${c.id}:${c.status}`).join()
if (key(fresh) !== key(data)) {
const openId = data.clips[open]?.id
data = fresh
renderGrid()
if (openId != null) { const j = data.clips.findIndex(c => c.id === openId); j >= 0 ? openClip(j) : closeClip() }
}
poll()
}, 3000)
}
renderGrid()
poll()
cleanup = () => { clearTimeout(timer); document.removeEventListener('keydown', onKey); document.body.classList.remove('noscroll') }
}
// ---------- 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, fol, lists] = serverReachable
? await Promise.all([api('/api/history').catch(() => null), api('/api/follows').catch(() => null), api('/api/lists').catch(() => [])])
: [null, null, []]
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()
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 ----------
// Actualiza `from` para que quede igual que `to` tocando solo lo que difiere. Los hijos con data-key se
// emparejan por clave (una tarjeta que cambia de posición conserva su <img> y no recarga la portada).
function morph(from, to, childrenOnly = false) {
if (!childrenOnly) {
if (from.nodeType !== to.nodeType || from.nodeName !== to.nodeName) { from.replaceWith(to); return to }
if (from.nodeType !== 1) { if (from.nodeValue !== to.nodeValue) from.nodeValue = to.nodeValue; return from }
for (const { name } of [...from.attributes]) if (!to.hasAttribute(name) && name !== 'data-failed') from.removeAttribute(name)
for (const { name, value } of [...to.attributes]) {
if (name === 'src' && from.dataset.failed) continue // portada que no cargó: se queda el icono
if (from.getAttribute(name) !== value) from.setAttribute(name, value)
}
}
const byKey = new Map([...from.children].filter(n => n.dataset.key).map(n => [n.dataset.key, n]))
const kids = [...to.childNodes]
kids.forEach((nk, i) => {
const key = nk.nodeType === 1 ? nk.dataset.key : null
const at = from.childNodes[i]
let old = key ? byKey.get(key) : at && !(at.nodeType === 1 && at.dataset.key) ? at : null
if (key) byKey.delete(key)
const node = old ? morph(old, nk) : nk
if (from.childNodes[i] !== node) from.insertBefore(node, from.childNodes[i] || null)
})
while (from.childNodes.length > kids.length) from.lastChild.remove()
return from
}
async function viewQueues() {
if (cleanup) { cleanup(); cleanup = null }
const here = () => location.hash === '#/queues'
if (!$('#qwrap')) app.innerHTML = '<div class="loading">Cargando…</div>'
let q, list
try {
[q, list] = await Promise.all([api('/api/queues'), api('/api/downloads')])
} catch (err) {
// Sin servidor: al menos la cola de este dispositivo (que sigue por su cuenta si hay red a medias).
if (!OFFLINE_OK || !here()) throw err
q = { downloads: [], upscale: { available: true, groups: [], current: null }, clips: { queued: 0, running: 0, errors: 0 }, covers: 0, offline: err.message }
list = []
}
if (!here()) return
const up = q.upscale
const library = list.filter(d => !DL_ACTIVE.includes(d.status))
const hdQueued = up.groups.reduce((n, g) => n + g.queued, 0)
const total = list.reduce((n, d) => n + (d.size || 0), 0)
const extra = [
q.clips.queued + q.clips.running ? `<a href="#/clips">${icon('scissors')} ${plural(q.clips.queued + q.clips.running, 'recorte')} reescalándose</a>` : '',
q.clips.errors ? `<a href="#/clips" class="error">${plural(q.clips.errors, 'recorte')} con error</a>` : '',
q.covers ? `<span>${icon('image')} ${plural(q.covers, 'portada')} en cola</span>` : '',
].filter(Boolean)
// 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="" 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>
<div class="sub">${lines}</div>
${actions ? `<div class="qact">${actions}</div>` : ''}
</div>`
// Guardado en este dispositivo: lo hace el navegador, no el servidor (offState).
const devHtml = () => {
if (!OFFLINE_OK) return ''
const items = [...(offState.running ? [offState.running] : []), ...offState.queue]
const groups = new Map()
for (const it of items) {
const g = groups.get(it.mangaId) || { ...it, chapters: [] }
g.chapters.push(it)
groups.set(it.mangaId, g)
}
const saved = Object.values(offIdx())
const bytes = saved.reduce((n, c) => n + (c.bytes || 0), 0)
return `<h2>${icon('phone')} En este dispositivo ${items.length ? `<span class="badge">${items.length}</span>` : ''}</h2>
<p class="muted small">Capítulos que se guardan en este ${/Mobi|Android|iPhone/i.test(navigator.userAgent) ? 'móvil' : 'navegador'} para leer sin conexión; la cola sigue mientras la app esté abierta.
${saved.length ? `Guardados: <a href="#/library?tab=device">${plural(saved.length, 'capítulo')} · ${fmtSize(bytes)}</a>.` : ''}</p>
${groups.size ? `<div class="grid qgrid">${[...groups.values()].map((g, i) => {
const cur = offState.running?.mangaId === g.mangaId ? offState.running : null
const rest = g.chapters.length - (cur ? 1 : 0)
return qcard({
key: `dev:${g.mangaId}`, id: g.mangaId, cover: g.cover, title: g.mangaTitle,
pct: cur?.total ? (cur.done / cur.total) * 100 : null,
badge: cur ? icon('phone') : i + 1,
lines: `${cur ? `<div>${esc(cur.label)} · pág. ${cur.done}/${cur.total || '?'}</div>` : ''}
${rest ? `<div>${cur ? `${rest} más` : plural(rest, 'capítulo')} en cola</div>` : ''}`,
actions: `<button class="btn small" data-devcancel="${esc(g.mangaId)}">${icon('stop')} Cancelar</button>`,
})
}).join('')}</div>` : '<p class="muted">No se está guardando nada. Se añade desde la ficha («En este dispositivo») o desde el lector.</p>'}`
}
const html = q.offline ? `<h1>Colas</h1><p class="error">${esc(q.offline)}: no se pueden ver las colas del servidor.</p>
<section class="qsec" id="devq">${devHtml()}</section>` : `<h1>Colas</h1>
<section class="qsec">
<h2>${icon('download')} Descargas ${q.downloads.length ? `<span class="badge">${q.downloads.length}</span>` : ''}</h2>
${q.downloads.length ? `<div class="grid qgrid">${q.downloads.map((d, i) => qcard({
key: `dl:${d.manga_id}`, id: d.manga_id, cover: d.cover, title: d.title, pct: downloadPct(d),
badge: d.status === 'downloading' ? icon('download') : i + 1,
lines: `<div>${esc(downloadLabel(d))} · ${Math.floor(downloadPct(d))}%</div>
${d.status === 'downloading' ? `<div>${downloadDetail(d)}</div>` : ''}${d.scope === 'some' ? '<div>Capítulos elegidos</div>' : ''}`,
actions: `<button class="btn small" data-cancel="${esc(d.manga_id)}">${icon('stop')} Cancelar</button>`,
})).join('')}</div>` : '<p class="muted">No hay descargas en curso. Se añaden desde la ficha de un manga.</p>'}
</section>
${OFFLINE_OK ? `<section class="qsec" id="devq">${devHtml()}</section>` : ''}
<section class="qsec">
<h2>${icon('sparkles')} Reescalado HD ${hdQueued ? `<span class="badge">${hdQueued}</span>` : ''}</h2>
${up.available ? '' : '<p class="muted">El reescalado no está disponible en este servidor (falta Real-ESRGAN o la GPU).</p>'}
${up.groups.length ? `${hdQueued ? `<p class="muted small">Un capítulo cada vez, en orden de llegada · ${up.secPerPage} s por página · quedan ${fmtDur(up.eta)}</p>` : ''}
<div class="grid qgrid">${up.groups.map((g, i) => {
const cur = up.current?.mangaId === g.mangaId ? up.current : null
const rest = g.queued - (cur ? 1 : 0)
const next = g.next.length ? ` (sig.: ${g.next.map(n => esc(n ?? '?')).join(', ')}${rest > g.next.length ? '…' : ''})` : ''
return qcard({
key: `hd:${g.mangaId}`, id: g.mangaId, cover: g.cover, title: g.title,
pct: cur?.total ? (cur.pages / cur.total) * 100 : null,
badge: cur ? icon('sparkles') : g.queued ? i + 1 : '!',
lines: `${cur ? `<div>Cap. ${esc(cur.num ?? '?')} · pág. ${cur.pages}/${cur.total}</div>` : ''}
${g.queued ? `<div>${rest ? `${cur ? `${rest} más` : plural(rest, 'capítulo')} en cola${next}` : 'Último de la cola'}</div><div>${fmtDur(g.eta)}</div>` : ''}
${g.errors.length ? `<div class="error" title="${esc(g.errors.map(e => `Cap. ${e.num ?? '?'}: ${e.error || 'error'}`).join('\n'))}">${g.errors.length === 1 ? `Cap. ${esc(g.errors[0].num ?? '?')}: ${esc(g.errors[0].error || 'error')}` : `${g.errors.length} con error (caps. ${g.errors.slice(0, 6).map(e => esc(e.num ?? '?')).join(', ')}${g.errors.length > 6 ? '…' : ''})`}</div>` : ''}`,
actions: `${g.errors.length ? `<button class="btn small" data-retry="${esc(g.mangaId)}" data-ids="${esc(g.errors.map(e => e.chapterId).join(' '))}">${icon('refresh')} Reintentar</button>` : ''}
${g.queued ? `<button class="btn small" data-hdstop="${esc(g.mangaId)}" title="Vacía su cola y desactiva el reescalado automático">${icon('stop')} Detener</button>` : ''}`,
})
}).join('')}</div>` : up.available ? '<p class="muted">No hay capítulos esperando. Se reescalan desde la ficha de un manga descargado o desde el lector.</p>' : ''}
${extra.length ? `<div class="facts muted">${extra.join('')}</div>` : ''}
</section>
<section class="qsec">
<h2>${icon('book')} Biblioteca descargada</h2>
<p class="muted small">CBZ en <code>/zdata/media/mangas</code> (compatibles con Kavita, Komga, Mihon…); se leen desde el disco.${list.length ? ` Total: <b>${fmtSize(total)}</b>.` : ''}</p>
${library.length ? `<div class="grid">${library.map(d => qcard({
key: `lib:${d.manga_id}`, id: d.manga_id, cover: d.cover, title: d.title,
pct: d.total ? Math.min(100, (d.done / d.total) * 100) : null,
lines: `<div>${esc(downloadLabel(d))}</div><div>${fmtSize(d.size)}</div>
${d.status === 'error' ? `<div class="error" title="${esc(d.error || '')}">Con errores</div>` : ''}`,
del: `<button class="hdel" data-del="${esc(d.manga_id)}" data-title="${esc(d.title)}" data-size="${d.size || 0}" title="Eliminar la copia local (mangas/${esc(d.folder)})" aria-label="Eliminar la copia local">${icon('trash')}</button>`,
})).join('')}</div>` : `<p class="muted">${list.length ? 'Todo lo descargado está aún en la cola.' : 'Aún no has descargado nada. Usa “Descargar” en la ficha de un manga.'}</p>`}
</section>`
// Refresco sin parpadeo: se actualiza solo lo que cambia (las portadas no se vuelven a cargar).
let wrap = $('#qwrap')
if (!wrap) {
app.innerHTML = `<div id="qwrap">${html}</div>`
wrap = $('#qwrap')
// Delegación: los botones se crean y actualizan en cada refresco.
const act = async (fn, msg) => { try { await fn(); if (msg) toast(msg) } catch (err) { toast(err.message) } viewQueues() }
wrap.addEventListener('click', e => {
const b = e.target.closest('button')
if (!b) return
if (b.dataset.cancel) act(() => api(`/api/download/${b.dataset.cancel}/cancel`, { method: 'POST' }), 'Descarga cancelada')
else if (b.dataset.hdstop) act(() => api(`/api/upscale/${b.dataset.hdstop}/cancel`, { method: 'POST' }), 'Reescalado detenido')
else if (b.dataset.devcancel) { offCancel(b.dataset.devcancel); toast('Guardado cancelado') }
else if (b.dataset.retry) act(() => api(`/api/upscale/${b.dataset.retry}`, { method: 'POST', body: { chapterIds: b.dataset.ids.split(' ') } }), 'Vuelto a la cola')
else if (b.dataset.del) {
if (!confirm(`¿Eliminar la copia local de «${b.dataset.title}» (${fmtSize(Number(b.dataset.size))})?\nTu historial de lectura no se toca.`)) return
act(() => api(`/api/download/${b.dataset.del}`, { method: 'DELETE' }), 'Copia local eliminada')
}
})
} else {
const tpl = document.createElement('div')
tpl.innerHTML = html
morph(wrap, tpl, true)
}
// Refresco: rápido mientras algo avanza, lento si no (por si llega algo nuevo).
const busy = q.downloads.length || up.current || up.groups.some(g => g.queued) || q.clips.queued + q.clips.running || q.covers
const t = setTimeout(() => here() && viewQueues(), q.offline ? 10000 : busy ? 2000 : 15000)
// El guardado en el dispositivo avanza en el navegador: su sección se redibuja sola en cada página.
let raf = 0
const onDev = () => {
if (raf) return
raf = requestAnimationFrame(() => {
raf = 0
const box = $('#devq')
if (!box || !here()) return
const tpl = document.createElement('section')
tpl.innerHTML = devHtml()
morph(box, tpl, true)
})
}
offState.listeners.add(onDev)
cleanup = () => { clearTimeout(t); offState.listeners.delete(onDev); cancelAnimationFrame(raf) }
}
// ---------- Estadísticas ----------
const fmtNum = n => n.toLocaleString('es')
const fmtDay = d => new Date(`${d}T12:00:00`).toLocaleDateString('es', { day: 'numeric', month: 'short' })
const niceMax = v => { const p = 10 ** Math.floor(Math.log10(v)); return [1, 2, 2.5, 5, 10].map(m => m * p).find(m => m >= v) }
// Barras verticales de una sola serie; cada columna tiene una zona de hover más grande que la barra.
function barChart(data, { value, tip, label }, W = 560) {
const H = 190, L = 34, B = 24, T = 8
const max = niceMax(Math.max(1, ...data.map(value)))
const cw = (W - L) / data.length
const bw = Math.max(4, Math.min(28, cw - 6))
const y = v => T + (H - T - B) * (1 - v / max)
const ticks = Number.isInteger(max / 2) ? [0, max / 2, max] : [0, max]
const grid = ticks.map(v => `<line x1="${L}" x2="${W}" y1="${y(v)}" y2="${y(v)}" class="grid"/><text x="${L - 6}" y="${y(v) + 4}" text-anchor="end" class="axis">${fmtNum(Math.round(v))}</text>`).join('')
const bars = data.map((d, i) => {
const v = value(d)
const x = L + i * cw + (cw - bw) / 2
const top = y(v), base = y(0), r = Math.min(4, bw / 2, base - top)
const bar = v > 0 ? `<path class="mark" d="M${x},${base}V${top + r}Q${x},${top} ${x + r},${top}H${x + bw - r}Q${x + bw},${top} ${x + bw},${top + r}V${base}Z"/>` : ''
const every = W < 420 ? 3 : 2
const lab = (data.length - 1 - i) % every ? '' : `<text x="${i === data.length - 1 ? x + bw : x + bw / 2}" y="${H - 6}" text-anchor="${i === data.length - 1 ? 'end' : 'middle'}" class="axis">${label(d)}</text>`
return `<g class="col" data-tip="${esc(tip(d))}"><rect x="${L + i * cw}" y="${T}" width="${cw}" height="${H - T - B}" class="hit"/>${bar}${lab}</g>`
}).join('')
return `<svg viewBox="0 0 ${W} ${H}" class="chart" role="img">${grid}${bars}</svg>`
}
async function viewStats() {
app.innerHTML = '<div class="loading">Cargando…</div>'
const st = await api('/api/stats')
const t = st.totals
if (!t.pages) {
app.innerHTML = '<h1>Estadísticas</h1><p class="muted">Aún no hay lecturas registradas. Lee algún capítulo y vuelve por aquí.</p>'
return
}
const maxDay = Math.max(1, ...st.calendar.map(d => d.pages))
const level = p => (p === 0 ? 0 : Math.min(5, Math.ceil((p / maxDay) * 5)))
const weekLabel = w => fmtDay(w.start)
const tile = (value, label, sub = '') => `<div class="tile"><div class="tv">${value}</div><div class="tl">${label}</div>${sub ? `<div class="ts">${sub}</div>` : ''}</div>`
// Calendario: columnas = semanas (lunes arriba).
const firstDow = (new Date(`${st.calendar[0].day}T12:00:00`).getDay() + 6) % 7
const cells = [...Array(firstDow).fill('<i class="cell empty"></i>'), ...st.calendar.map(d =>
`<i class="cell l${level(d.pages)}" data-tip="${esc(`${fmtDay(d.day)}: ${d.pages ? `${fmtNum(d.pages)} págs.` : 'sin lectura'}${d.chapters ? ` · ${d.chapters} caps.` : ''}`)}"></i>`)].join('')
const gmax = Math.max(1, ...st.genres.map(g => g.pages))
app.innerHTML = `<h1>${icon('chart')} Estadísticas</h1>
<div class="tiles">
${tile(`${st.streak.current} ${st.streak.current === 1 ? 'día' : 'días'}`, 'Racha actual', st.streak.current && !st.streak.readToday ? 'Lee hoy para mantenerla' : st.streak.readToday ? 'Hoy ya leíste ✓' : '')}
${tile(`${st.streak.best} ${st.streak.best === 1 ? 'día' : 'días'}`, 'Mejor racha')}
${tile(fmtNum(t.thisWeekChapters), 'Capítulos esta semana', `${fmtNum(t.thisWeekPages)} páginas`)}
${tile(fmtNum(t.chapters), 'Capítulos terminados')}
${tile(fmtNum(t.pages), 'Páginas leídas')}
${tile(fmtNum(t.mangas), 'Mangas', `${t.activeDays} ${t.activeDays === 1 ? 'día' : 'días'} con lectura`)}
</div>
<div class="charts">
<section class="panel"><h3>Capítulos terminados por semana</h3><div class="chart-slot" data-chart="chapters"></div></section>
<section class="panel"><h3>Páginas leídas por semana</h3><div class="chart-slot" data-chart="pages"></div></section>
</div>
<section class="panel"><h3>Actividad (últimas 18 semanas)</h3>
<div class="heat-wrap"><div class="heat">${cells}</div></div>
<div class="heat-legend muted">Menos <i class="cell l0"></i><i class="cell l1"></i><i class="cell l2"></i><i class="cell l3"></i><i class="cell l4"></i><i class="cell l5"></i> Más páginas</div>
</section>
<div class="charts">
<section class="panel"><h3>Géneros favoritos</h3>
${st.genres.length ? `<div class="hbars">${st.genres.map(g => `<div class="hbar" data-tip="${esc(`${g.name}: ${fmtNum(g.pages)} págs. (${g.pct}% de lo leído)`)}">
<span class="hl">${esc(g.name)}</span><span class="ht"><i style="width:${(g.pages / gmax) * 100}%"></i></span><span class="hv">${g.pct}%</span></div>`).join('')}</div>
<p class="muted" style="font-size:.8rem">Porcentaje de páginas leídas de mangas con ese género (un manga cuenta en todos sus géneros).</p>`
: '<p class="muted">Abre la ficha de los mangas que lees para registrar sus géneros.</p>'}
</section>
<section class="panel"><h3>Lo más leído</h3>
<div class="toplist">${st.topMangas.map((m, i) => `<a class="cont" href="#/manga/${m.id}"><span class="rank">${i + 1}</span>
<img src="${esc(m.cover || '/icon.svg')}" alt="" loading="lazy">
<div class="info"><b>${esc(m.title)}</b><div class="muted">${fmtNum(m.pages)} págs. · ${m.chapters} caps.</div></div></a>`).join('')}</div>
</section>
</div>
<details class="panel"><summary>Ver datos por semana</summary>
<table class="data"><thead><tr><th>Semana del</th><th>Capítulos</th><th>Páginas</th></tr></thead>
<tbody>${[...st.weeks].reverse().map(w => `<tr><td>${fmtDay(w.start)}</td><td>${w.chapters}</td><td>${fmtNum(w.pages)}</td></tr>`).join('')}</tbody></table>
</details>
<div id="tip" hidden></div>`
// Las gráficas se dibujan al ancho real del panel para que el texto no encoja en el móvil.
const charts = {
chapters: { value: w => w.chapters, label: weekLabel, tip: w => `Semana del ${fmtDay(w.start)}: ${w.chapters} caps.` },
pages: { value: w => w.pages, label: weekLabel, tip: w => `Semana del ${fmtDay(w.start)}: ${fmtNum(w.pages)} págs.` },
}
const drawCharts = () => app.querySelectorAll('.chart-slot').forEach(el => { el.innerHTML = barChart(st.weeks, charts[el.dataset.chart], Math.max(260, el.clientWidth)) })
drawCharts()
let rt = null
const onResize = () => { clearTimeout(rt); rt = setTimeout(drawCharts, 150) }
window.addEventListener('resize', onResize)
// Tooltip compartido para barras, celdas y géneros.
const tipEl = $('#tip')
const move = e => {
const target = e.target.closest('[data-tip]')
if (!target) { tipEl.hidden = true; return }
tipEl.textContent = target.dataset.tip
tipEl.hidden = false
const r = tipEl.getBoundingClientRect()
tipEl.style.left = `${Math.min(window.innerWidth - r.width - 8, Math.max(8, e.clientX - r.width / 2))}px`
tipEl.style.top = `${Math.max(8, e.clientY - r.height - 12)}px`
}
app.addEventListener('pointermove', move)
app.addEventListener('pointerdown', move)
app.addEventListener('pointerleave', () => (tipEl.hidden = true))
cleanup = () => { app.removeEventListener('pointermove', move); app.removeEventListener('pointerdown', move); window.removeEventListener('resize', onResize) }
}
// ---------- Ajustes ----------
function viewSettings() {
app.innerHTML = `<h1>Ajustes</h1>
<div class="settings">
<section><b>Cuenta</b><div class="muted">Sesión iniciada como <b>${esc(me.username)}</b></div>
<button class="btn" id="logout">${icon('logout')} Cerrar sesión</button></section>
<section><b>Idioma preferido de los capítulos</b>
<select id="lang">${Object.entries(LANGS).map(([k, v]) => `<option value="${k}" ${k === me.lang ? 'selected' : ''}>${v}</option>`).join('')}</select></section>
<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="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>
<form id="notify" style="display:grid;gap:8px">
<b style="font-size:.9rem">ntfy</b>
<input type="text" name="ntfyUrl" placeholder="Servidor, p. ej. http://192.168.1.141:4546" autocomplete="off">
<input type="text" name="ntfyTopic" placeholder="Tema, p. ej. mangas" autocomplete="off">
<input type="password" name="ntfyToken" placeholder="Token (tk_…) o usuario:contraseña" autocomplete="new-password">
<b style="font-size:.9rem">Telegram <span class="muted" style="font-weight:normal">(opcional)</span></b>
<input type="password" name="tgToken" placeholder="Token del bot (de @BotFather)" autocomplete="new-password">
<input type="text" name="tgChat" placeholder="Chat ID" autocomplete="off">
<p class="muted" style="margin:0;font-size:.8rem">Los tokens guardados no se muestran: deja el campo vacío para conservarlo o escribe «-» para borrarlo.</p>
<div class="row"><button class="btn primary">${icon('check')} Guardar</button><button type="button" class="btn" id="ntest">${icon('send')} Enviar prueba</button></div>
</form></section>
</div>`
api('/api/notify').then(n => {
const f = $('#notify')
if (!f) return
f.ntfyUrl.value = n.ntfyUrl
f.ntfyTopic.value = n.ntfyTopic
f.tgChat.value = n.tgChat
if (n.hasNtfyToken) f.ntfyToken.placeholder = 'Token guardado ✓ (vacío = conservar, «-» = borrar)'
if (n.hasTgToken) f.tgToken.placeholder = 'Token guardado ✓ (vacío = conservar, «-» = borrar)'
}).catch(() => {})
$('#notify').addEventListener('submit', async e => {
e.preventDefault()
const f = e.target
try {
await api('/api/notify', { method: 'POST', body: Object.fromEntries(['ntfyUrl', 'ntfyTopic', 'ntfyToken', 'tgToken', 'tgChat'].map(k => [k, f[k].value])) })
toast('Avisos guardados')
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) }
})
$('#logout').addEventListener('click', async () => { await api('/api/logout', { method: 'POST' }); me = null; location.hash = '#/login'; router() })
$('#lang').addEventListener('change', async e => { await api('/api/settings', { method: 'POST', body: { lang: e.target.value } }); me.lang = e.target.value; toast('Idioma guardado') })
$('#pw').addEventListener('submit', async e => {
e.preventDefault()
try {
await api('/api/settings', { method: 'POST', body: { oldPassword: e.target.old.value, newPassword: e.target.new.value } })
e.target.reset(); toast('Contraseña cambiada')
} catch (err) { toast(err.message) }
})
}