Files
manga-reader/app/public/app.js
T

1028 lines
59 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"/>',
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"/>',
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)', md: 'MangaDex (multi-idioma)' }
// 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 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)
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)
}
async function api(path, opts = {}) {
const res = await fetch(path, {
...opts,
headers: opts.body ? { 'Content-Type': 'application/json' } : undefined,
body: opts.body ? JSON.stringify(opts.body) : undefined,
})
const data = await res.json().catch(() => ({}))
if (res.status === 401 && !path.startsWith('/api/login')) { me = null; location.hash = '#/login'; throw new Error('No autenticado') }
if (!res.ok) throw new Error(data.error || `Error ${res.status}`)
return data
}
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}` : ''}`
// ---------- Router ----------
async function router() {
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 api('/api/me').catch(() => ({}))
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] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1)
if (parts[0] === 'history') return await viewHistory()
if (parts[0] === 'downloads') return await viewDownloads()
if (parts[0] === 'stats') return await viewStats()
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="">
<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">
<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)}`
async function viewHome(params) {
const q = params.get('q') || ''
const tag = params.get('tag') || ''
const tagName = params.get('tagName') || tag
const mode = tag ? 'cats' : params.get('mode') || 'popular'
const anyLang = params.get('any') === '1'
const source = SOURCES[params.get('source')] ? params.get('source') : store.get('source', 'lc')
store.set('source', source)
const showCats = mode === 'cats' && !tag
app.innerHTML = `
<section id="cont"></section>
<div class="tabs" id="sources">${Object.entries(SOURCES).map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}">${v}</button>`).join('')}</div>
<form class="search"><input type="search" name="q" placeholder="Buscar ${tag ? `en «${esc(tagName)}»` : `en ${esc(SOURCES[source])}`}…" value="${esc(q)}"><button class="btn primary">${icon('search')}<span class="lbl">Buscar</span></button></form>
<label class="muted row" style="font-size:.88rem" ${source === 'md' ? '' : 'hidden'}><input type="checkbox" name="any" ${anyLang ? 'checked' : ''}> Incluir mangas sin traducción a ${esc(LANGS[me.lang] || me.lang)}</label>
${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>
<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(() => {})
}
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 (sus otros listados no paginan).
const pageable = source === 'md' || !!tag
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 ----------
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, following, 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 === 'lc'
const langOpts = [...new Set([lang, me.lang, ...chapterLangs, ...manga.languages])].filter(Boolean)
const altSearch = `#/?source=lc&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>`
app.innerHTML = `
<div class="detail">
<img class="cover" src="${esc(manga.cover || '/icon.svg')}" alt="">
<div>
<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="row">${mainBtn}
<button class="btn" id="follow"></button>
${progress || reads.length ? `<button class="btn danger" id="drop">${icon('stopRead')} Dejar de leer</button>` : ''}
${isLc ? '' : `<select id="lang">${langOpts.map(l => `<option value="${esc(l)}" ${l === lang ? 'selected' : ''}>${esc(LANGS[l] || l)}</option>`).join('')}</select>
<a class="btn" href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`}
</div>
<div class="muted" style="font-size:.85rem;margin-top:6px">Fuente: ${esc(SOURCES[manga.source])}${download ? ` · Carpeta: mangas/${esc(download.folder)}` : ''}</div>
${offline ? '<p class="error">Sin conexión con la fuente: mostrando solo los capítulos descargados.</p>' : ''}
<div id="dl" class="dlbox"></div>
<p class="error" id="dlerr" style="font-size:.85rem"></p>
</div>
</div>
<h2>Capítulos <span class="muted">(${chapters.length})</span>${newSet.size ? ` <span class="badge">${newSet.size} ${newSet.size === 1 ? 'nuevo' : 'nuevos'}</span>` : ''}
${chapters.length > 1 ? `<button class="btn small" id="order" style="float:right">${icon('sort')} Invertir orden</button>` : ''}</h2>
${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 <a class="tag" href="${altSearch}">buscarlo en LeerCapitulo</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>`}`
// Botón de 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 delBtn = d ? `<button class="btn small danger" id="dldel">${icon('trash')} Eliminar copia local${d.size ? ` (${fmtSize(d.size)})` : ''}</button>` : ''
const bar = d && (active || d.done < d.total) ? `<div class="bar dlbar"><i style="width:${downloadPct(d).toFixed(1)}%"></i></div>` : ''
let html
if (!d || d.status === 'cancelled') html = `<div class="row"><button class="btn" id="dlgo">${icon('download')} Descargar${d ? ` (llevas ${d.done}/${d.total})` : ''}</button>${delBtn}</div>${bar}`
else if (active) html = `<div class="row"><span>${downloadLabel(d)} · ${Math.floor(downloadPct(d))}%</span><button class="btn small" id="dlcancel">${icon('stop')} Cancelar</button>${delBtn}</div>${bar}<div class="muted" style="font-size:.8rem">${downloadDetail(d)}</div>`
else html = `<div class="row"><span class="muted row" style="gap:4px">${icon('check')} ${downloadLabel(d)}</span><button class="btn small" id="dlgo" title="Descarga los capítulos que falten">${icon('refresh')} ${d.status === 'error' ? 'Reintentar' : 'Buscar nuevos'}</button>${delBtn}</div>${bar}`
box.innerHTML = offline ? (d ? `<span class="muted">${downloadLabel(d)}</span>` : '') : html
$('#dlerr').textContent = d?.status === 'error' ? d.error || '' : ''
$('#dlgo')?.addEventListener('click', async () => {
renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title } }))
toast(`Descargando en mangas/${manga.title}`)
})
$('#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)})` : ''}.\nTu historial de lectura no se toca.`)) return
await api(`/api/download/${id}`, { method: 'DELETE' })
toast('Copia local eliminada')
viewManga(id, langOverride)
})
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)); renderList(); if (fresh?.download) next.size = fresh.download.size }
renderDl(next)
}, 2000)
}
renderDl(download)
cleanup = () => clearTimeout(dlTimer)
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')
viewManga(id, langOverride)
})
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 renderList = () => {
if (!ul) return
const list = desc ? [...chapters].reverse() : chapters
ul.innerHTML = list.map(c => {
const r = readMap.get(c.id)
const done = r && r.total_pages && r.page >= r.total_pages - 1
const cls = progress?.chapter_id === c.id ? 'current' : done ? 'read' : ''
const meta = (newSet.has(String(c.num ?? c.id)) ? '<span class="badge">NUEVO</span> ' : '') + (dlSet.has(c.id) ? `<span title="Descargado">${icon('download')}</span> ` : '') + (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group))
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">${meta}</span></a></li>`
}).join('')
}
renderList()
$('#order')?.addEventListener('click', () => { desc = !desc; 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' }
// 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 globalSettings = { saver: false, filter: 'none', brightness: 100, ...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}`),
])
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 === 'lc' ? 'es' : chapter.lang || lang, page: lastShown(), totalPages: total,
isLast: idx >= 0 && !next, // al terminarlo, el manga sale de "Continuar leyendo"
})
const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; api('/api/progress', { method: 'POST', body: payload() }).catch(() => {}) }
const scheduleSave = () => { clearTimeout(saveTimer); saveTimer = setTimeout(saveNow, 700) }
const beacon = () => { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; navigator.sendBeacon('/api/progress', JSON.stringify(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="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="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 === 'lc' ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
<p class="muted">Modo, ajuste y dirección se recuerdan para este manga. 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
$('#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
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]
}
}
function showPaged() {
let stage = $('.stage', box)
if (!stage) { box.innerHTML = '<div class="stage"></div>'; stage = $('.stage', box) }
stage.classList.toggle('two', shown.length === 2)
stage.innerHTML = shown.map(i => `<img data-i="${i}" src="${pages[i]}" alt="" draggable="false">`).join('')
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()
}
function showEnd() {
atEnd = true
resetZoom()
box.innerHTML = endBlock
reader.classList.remove('hide-ui')
}
function goTo(n, fromScroll = false, force = false) {
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
atEnd = false
shown = g
page = g[0]
setPageLabel()
if (changed || force || !$('.stage img', box)) showPaged()
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) }
const backward = () => {
if (atEnd) { box.innerHTML = ''; goTo(total - 1, false, true); return }
if (page > 0) goTo(groupOf(page - 1)[0])
}
function render() {
observer?.disconnect()
const keep = lastShown()
resetZoom()
box.className = isPaged() ? `paged ${settings.fit === 'width' ? 'fit-width' : ''} ${isDouble() ? 'double' : ''}` : 'vertical'
box.dataset.dir = settings.dir
$('#fit').hidden = !isPaged()
$('#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', 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)
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 })
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; 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; savePrefs() })
$('#filter').addEventListener('change', e => { settings.filter = e.target.value; saveGlobal(); applyFilter() })
$('#bright').addEventListener('input', e => { settings.brightness = Number(e.target.value); saveGlobal(); applyFilter() })
$('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; saveGlobal(); applyFilter() })
$('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); router() })
$('#chap').addEventListener('change', e => { location.hash = readUrl(mangaId, e.target.value) })
slider.addEventListener('input', () => goTo(Number(slider.value) - 1))
const onKey = e => {
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 = () => {
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)
}
}
// ---------- Historial ----------
async function viewHistory() {
app.innerHTML = '<div class="loading">Cargando…</div>'
const hist = await api('/api/history')
app.innerHTML = `<h1>Historial de lectura</h1>
${hist.length ? '' : '<p class="muted">Aún no has leído nada.</p>'}
<div class="history">${hist.map(h => `
<div class="cont">
${continueCard(h).replace('class="cont"', 'style="display:flex;gap:12px;flex:1;min-width:0;align-items:center"')}
<div class="row" style="flex:none">
<a class="btn small" href="#/manga/${h.manga_id}">${icon('book')} Ficha</a>
<button class="btn small danger" data-del="${h.manga_id}">${icon('trash')} Borrar</button>
</div>
</div>`).join('')}</div>`
app.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', async () => {
if (!confirm('¿Borrar el historial de este manga?')) return
await api(`/api/history/${b.dataset.del}`, { method: 'DELETE' })
viewHistory()
}))
}
// ---------- Descargas ----------
async function viewDownloads() {
if (cleanup) { cleanup(); cleanup = null }
const list = await api('/api/downloads')
if (location.hash !== '#/downloads') return
const total = list.reduce((n, d) => n + (d.size || 0), 0)
app.innerHTML = `<h1>Descargas</h1>
<p class="muted">Se guardan en <code>/zdata/media/mangas</code> como CBZ (compatibles con Kavita, Komga, Mihon…). Los capítulos descargados se leen desde el disco.${list.length ? ` Total: <b>${fmtSize(total)}</b>.` : ''}</p>
${list.length ? '' : '<p class="muted">Aún no has descargado nada. Usa “⬇ Descargar” en la ficha de un manga.</p>'}
<div class="history">${list.map(d => {
const active = DL_ACTIVE.includes(d.status)
return `<div class="cont">
<a class="info" href="#/manga/${d.manga_id}"><b>${esc(d.title)}</b>
<div class="muted">${esc(downloadLabel(d))}${active ? ` · ${Math.floor(downloadPct(d))}%` : ''} · ${fmtSize(d.size)} · mangas/${esc(d.folder)}</div>
${active ? `<div class="muted" style="font-size:.8rem">${downloadDetail(d)}</div>` : ''}
${d.status === 'error' ? `<div class="error" style="font-size:.8rem">${esc(d.error || '')}</div>` : ''}
<div class="bar dlbar"><i style="width:${downloadPct(d).toFixed(1)}%"></i></div></a>
<div class="row" style="flex:none">
${active ? `<button class="btn small" data-cancel="${esc(d.manga_id)}">${icon('stop')} Cancelar</button>` : ''}
<button class="btn small danger" data-del="${esc(d.manga_id)}" data-title="${esc(d.title)}" data-size="${d.size || 0}">${icon('trash')} Eliminar</button>
</div></div>`
}).join('')}</div>`
app.querySelectorAll('[data-cancel]').forEach(b => b.addEventListener('click', async () => { await api(`/api/download/${b.dataset.cancel}/cancel`, { method: 'POST' }); viewDownloads() }))
app.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', async () => {
if (!confirm(`¿Eliminar la copia local de «${b.dataset.title}» (${fmtSize(Number(b.dataset.size))})?\nTu historial de lectura no se toca.`)) return
await api(`/api/download/${b.dataset.del}`, { method: 'DELETE' })
toast('Copia local eliminada')
viewDownloads()
}))
if (list.some(d => DL_ACTIVE.includes(d.status))) {
const t = setTimeout(() => location.hash === '#/downloads' && viewDownloads(), 2000)
cleanup = () => clearTimeout(t)
}
}
// ---------- 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="6">
<button class="btn primary">${icon('key')} Guardar</button></form></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) }
})
$('#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) }
})
}