const app = document.getElementById('app')
// Iconos SVG en línea (trazo estilo Lucide), heredan el color del texto.
const ICONS = {
home: ' ',
history: ' ',
download: ' ',
settings: ' ',
play: ' ',
book: ' ',
stopRead: ' ',
stop: ' ',
trash: ' ',
refresh: ' ',
search: ' ',
sort: ' ',
back: ' ',
prev: ' ',
next: ' ',
more: ' ',
check: ' ',
login: ' ',
logout: ' ',
userPlus: ' ',
flame: ' ',
clock: ' ',
grid: ' ',
globe: ' ',
key: ' ',
}
const icon = name => `${ICONS[name]} `
// Iconos del menú superior (index.html).
document.querySelectorAll('#topbar nav a[data-icon]').forEach(a => { a.innerHTML = `${icon(a.dataset.icon)}${a.textContent} ` })
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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] === 'settings') return viewSettings()
return await viewHome(params)
} catch (e) {
if (e.message !== 'No autenticado') app.innerHTML = `
${esc(e.message)}
`
}
}
window.addEventListener('hashchange', router)
router()
// ---------- Login / registro ----------
function viewAuth(signup) {
app.innerHTML = `
Manga Reader
Inicia sesión para leer y guardar tu progreso.
`
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 `
${esc(m.title)}
${m.sub ? `${esc(m.sub)}
` : ''} `
}
function continueCard(h) {
const pct = h.total_pages ? Math.round(((h.page + 1) / h.total_pages) * 100) : 0
return `
${esc(h.manga_title)}
${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · pág. ${h.page + 1}/${h.total_pages || '?'}
${timeAgo(h.updated_at)}
`
}
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 = `
${Object.entries(SOURCES).map(([k, v]) => `${v} `).join('')}
Incluir mangas sin traducción a ${esc(LANGS[me.lang] || me.lang)}
${q && !tag ? `Resultados para “${esc(q)}” ` : `
${icon('flame')} Populares
${icon('clock')} Actualizados recientemente
${icon('grid')} Categorías
`}
${tag ? `Categoría: ${esc(tagName)} ✕ ${q ? ` · “${esc(q)}”` : ''} ` : ''}
Cargando…
${icon('more')} Cargar más
`
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' : ''}` }))
api('/api/history').then(hist => {
if (!hist.length || q || tag || showCats) return
$('#cont').innerHTML = `Continuar leyendo ${hist.slice(0, 12).map(continueCard).join('')}
`
}).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]) => `${esc(g)} `).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 = 'Cargando…
'
const lang = langOverride || store.get(`lang:${id}`, me.lang)
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
const dlSet = new Set(downloaded)
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 = `${icon('play')} Continuar · ${progress.chapter_num ? `Cap. ${esc(progress.chapter_num)}` : 'Oneshot'}, pág. ${progress.page + 1} `
} else if (progress) {
mainBtn = `${icon('play')} Continuar · Cap. ${esc(progress.chapter_num ?? '?')} (${esc(LANGS[progress.lang] || progress.lang || '')}), pág. ${progress.page + 1} `
}
if (first && !mainBtn) mainBtn = `${icon('book')} Empezar a leer `
app.innerHTML = `
${esc(manga.title)}
${esc(manga.authors.join(', '))}${manga.year ? ` · ${manga.year}` : ''}${manga.status ? ` · ${esc(manga.status)}` : ''}
${esc(manga.description)}
Fuente: ${esc(SOURCES[manga.source])}${download ? ` · Carpeta: mangas/${esc(download.folder)}` : ''}
${offline ? '
Sin conexión con la fuente: mostrando solo los capítulos descargados.
' : ''}
Capítulos (${chapters.length})
${chapters.length > 1 ? `${icon('sort')} Invertir orden ` : ''}
${chapters.length ? '' : isLc ? 'Este manga no tiene capítulos.
' : `No hay capítulos legibles en ${esc(LANGS[lang] || lang)} (MangaDex retira algunos por licencia).
Prueba a buscarlo en LeerCapitulo .
${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `${esc(LANGS[l] || l)} `).join(' ')}` : 'No hay capítulos en ningún idioma.'}
`}`
// 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 ? `${icon('trash')} Eliminar copia local${d.size ? ` (${fmtSize(d.size)})` : ''} ` : ''
const bar = d && (active || d.done < d.total) ? `
` : ''
let html
if (!d || d.status === 'cancelled') html = `${icon('download')} Descargar${d ? ` (llevas ${d.done}/${d.total})` : ''} ${delBtn}
${bar}`
else if (active) html = `${downloadLabel(d)} · ${Math.floor(downloadPct(d))}% ${icon('stop')} Cancelar ${delBtn}
${bar}${downloadDetail(d)}
`
else html = `${icon('check')} ${downloadLabel(d)} ${icon('refresh')} ${d.status === 'error' ? 'Reintentar' : 'Buscar nuevos'} ${delBtn}
${bar}`
box.innerHTML = offline ? (d ? `${downloadLabel(d)} ` : '') : 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)
$('#drop')?.addEventListener('click', async () => {
if (!confirm(`¿Dejar de leer «${manga.title}»?\nSe quitará del historial y se borrará el progreso de sus capítulos.`)) 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 = (dlSet.has(c.id) ? `${icon('download')} ` : '') + (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group))
return `${esc(chapLabel(c))} ${meta} `
}).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 ----------
async function viewReader(mangaId, chapterId, startPage) {
document.body.classList.add('reading')
app.innerHTML = ''
const lang = store.get(`lang:${mangaId}`, me.lang)
const settings = { mode: 'paged', fit: 'height', dir: 'ltr', 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=${settings.saver ? 1 : 0}`),
])
const { manga, chapters } = info
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)
// 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, totalPages: total,
})
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 => `${esc(chapLabel(c))} `).join('')
app.innerHTML = `
`
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 = ``
const setPageLabel = () => { $('#pnum').textContent = `${page + 1} / ${total}`; slider.value = page + 1 }
let observer = null
let atEnd = false
function goTo(n, fromScroll = false) {
if (n >= total && settings.mode === 'paged') { atEnd = true; box.innerHTML = endBlock; reader.classList.remove('hide-ui'); return }
n = Math.min(Math.max(n, 0), total - 1)
const changed = n !== page || atEnd
atEnd = false
page = n
setPageLabel()
if (settings.mode === 'paged') {
const img = $('img', box) || Object.assign(document.createElement('img'), { alt: '' })
if (!img.isConnected) { box.innerHTML = ''; box.append(img) }
img.src = pages[page]
window.scrollTo(0, 0)
for (let k = 1; k <= 3; k++) if (pages[page + k]) new Image().src = pages[page + k]
} else if (!fromScroll) {
box.querySelectorAll('img')[page]?.scrollIntoView()
}
if (changed) scheduleSave()
}
function render() {
observer?.disconnect()
box.className = settings.mode === 'paged' ? `paged ${settings.fit === 'width' ? 'fit-width' : ''}` : 'vertical'
$('#fit').hidden = settings.mode !== 'paged'
$('#dir').hidden = settings.mode !== 'paged'
if (settings.mode === 'paged') {
box.innerHTML = ''
goTo(page)
return
}
box.innerHTML = pages.map((src, i) => ` `).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()
}
const saveSettings = () => store.set('readerSettings', settings)
$('#mode').addEventListener('change', e => { settings.mode = e.target.value; saveSettings(); render() })
$('#fit').addEventListener('change', e => { settings.fit = e.target.value; saveSettings(); render() })
$('#dir').addEventListener('change', e => { settings.dir = e.target.value; saveSettings() })
$('#filter').addEventListener('change', e => { settings.filter = e.target.value; saveSettings(); applyFilter() })
$('#bright').addEventListener('input', e => { settings.brightness = Number(e.target.value); saveSettings(); applyFilter() })
$('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; saveSettings(); applyFilter() })
$('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveSettings(); history.replaceState(null, '', readUrl(mangaId, chapterId, page)); router() })
$('#chap').addEventListener('change', e => { location.hash = readUrl(mangaId, e.target.value) })
slider.addEventListener('input', () => goTo(Number(slider.value) - 1))
// Toques: izquierda/derecha cambian de página (según dirección), centro muestra/oculta barras.
box.addEventListener('click', e => {
if (e.target.closest('a,button')) return
if (settings.mode !== 'paged') { reader.classList.toggle('hide-ui'); return }
const x = e.clientX / window.innerWidth
if (x > 0.35 && x < 0.65) { reader.classList.toggle('hide-ui'); return }
const forward = (x >= 0.65) === (settings.dir === 'ltr')
if (atEnd && !forward) { atEnd = false; box.innerHTML = ''; goTo(total - 1); return }
if (!atEnd) goTo(page + (forward ? 1 : -1))
})
const onKey = e => {
if (settings.mode !== 'paged' || e.target.matches('input,select')) return
const fwdKey = settings.dir === 'ltr' ? 'ArrowRight' : 'ArrowLeft'
const backKey = settings.dir === 'ltr' ? 'ArrowLeft' : 'ArrowRight'
if (e.key === fwdKey || e.key === ' ') { e.preventDefault(); if (!atEnd) goTo(page + 1) }
else if (e.key === backKey) { if (atEnd) { atEnd = false; box.innerHTML = ''; goTo(total - 1) } else goTo(page - 1) }
}
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 })
render()
cleanup = () => {
if (saveTimer) saveNow()
observer?.disconnect()
document.removeEventListener('visibilitychange', onVis)
window.removeEventListener('pagehide', beacon)
document.removeEventListener('keydown', onKey)
window.removeEventListener('scroll', onScroll)
}
}
// ---------- Historial ----------
async function viewHistory() {
app.innerHTML = 'Cargando…
'
const hist = await api('/api/history')
app.innerHTML = `Historial de lectura
${hist.length ? '' : 'Aún no has leído nada.
'}
${hist.map(h => `
${continueCard(h).replace('class="cont"', 'style="display:flex;gap:12px;flex:1;min-width:0;align-items:center"')}
`).join('')}
`
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 = `Descargas
Se guardan en /zdata/media/mangas como CBZ (compatibles con Kavita, Komga, Mihon…). Los capítulos descargados se leen desde el disco.${list.length ? ` Total: ${fmtSize(total)} .` : ''}
${list.length ? '' : 'Aún no has descargado nada. Usa “⬇ Descargar” en la ficha de un manga.
'}
${list.map(d => {
const active = DL_ACTIVE.includes(d.status)
return `
`
}).join('')}
`
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)
}
}
// ---------- Ajustes ----------
function viewSettings() {
app.innerHTML = `Ajustes
Cuenta Sesión iniciada como ${esc(me.username)}
${icon('logout')} Cerrar sesión
Idioma preferido de los capítulos
${Object.entries(LANGS).map(([k, v]) => `${v} `).join('')}
`
$('#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) }
})
}