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: '', bell: '', bellOff: '', chart: '', zoomOut: '', send: '', dots: '', x: '', scroll: '', pages: '', sparkles: '', } const icon = 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] === 'stats') return await viewStats() 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.

${signup ? `` : ''}
` 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 // Terminado con capítulos nuevos: a la ficha, para elegir el siguiente. return `
${esc(h.manga_title)}
${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · pág. ${h.page + 1}/${h.total_pages || '?'}
${h.finished ? '
Terminado
' : h.hasNew ? '
Caps. nuevos
' : ''}
${timeAgo(h.updated_at)}
` } // 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 `

${icon('sparkles')} Novedades ${fresh.length ? `${fresh.reduce((n, f) => n + f.newCount, 0)}` : ''}

Sigues ${fol.follows.length} manga${fol.follows.length === 1 ? '' : 's'} · ${status}
${fresh.length ? `` : '

No hay capítulos nuevos en los mangas que sigues.

'}` } 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 = `
${Object.entries(SOURCES).map(([k, v]) => ``).join('')}
${q && !tag ? `

Resultados para “${esc(q)}”

` : `
`} ${tag ? `

Categoría: ${esc(tagName)} ✕${q ? ` · “${esc(q)}”` : ''}

` : ''}
Cargando…
` 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 = `
${newsSection(fol)}
` + (hist = hist.filter(h => !h.finished), hist.length ? `

Continuar leyendo

${hist.slice(0, 12).map(continueCard).join('')}
` : '') 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]) => `

${esc(g)}

${list.map(t => `${esc(t.name)}`).join('')}
`).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, upscale, following, newKeys = [] } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`) const dlSet = new Set(downloaded) let hdSet = new Set(upscale?.chapters || []), hdJobs = upscale?.jobs || {} 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 = `${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` const menuItems = [ isLc ? '' : `${icon('search')} Buscar en LeerCapitulo`, progress || reads.length ? `` : '', ].join('') app.innerHTML = `

${esc(manga.title)}

${esc(manga.authors.join(', '))}${manga.year ? ` · ${manga.year}` : ''}${manga.status ? ` · ${esc(manga.status)}` : ''}
${manga.tags.map(t => (t.id ? `${esc(t.name)}` : `${esc(t.name ?? t)}`)).join('')}

${esc(manga.description)}

${mainBtn} ${menuItems ? `` : ''}
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})${newSet.size ? ` ${newSet.size} ${newSet.size === 1 ? 'nuevo' : 'nuevos'}` : ''}

${isLc ? '' : ``} ${chapters.length > 1 ? `` : ''}
${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.'}

    `} ` // --- Copia local (descarga) con sondeo del progreso mientras está activa. let dlTimer = null const secHead = (ic, title, status) => `
    ${icon(ic)} ${title}
    ${status ? `
    ${status}
    ` : ''}
    ` const renderDl = d => { const box = $('#dl') if (!box) return const active = d && DL_ACTIVE.includes(d.status) const partial = d && d.done < d.total const bar = d && (active || partial) ? `
    ` : '' const delBtn = d ? `` : '' let status = '', body = '', actions = '' if (!d) { body = '
    Guarda los capítulos en CBZ en el servidor para leerlos sin conexión (y poder reescalarlos a HD).
    ' actions = `` } else if (active) { status = `${downloadLabel(d)} · ${Math.floor(downloadPct(d))}%` body = `${bar}
    ${downloadDetail(d)}
    ` actions = `${delBtn}` } else { status = d.status === 'done' ? `${icon('check')} ${d.done} capítulos${d.size ? ` · ${fmtSize(d.size)}` : ''}` : downloadLabel(d) body = bar + (d.status === 'error' && d.error ? `
    ${esc(d.error)}
    ` : '') actions = `${delBtn}` } box.innerHTML = secHead('download', 'Copia local', status) + body + (offline ? '' : `
    ${actions}
    `) $('#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)})` : ''}, 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) }) 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) renderList() } renderDl(next) }, 2000) } // --- Reescalado HD: todo lo pendiente, un rango o una selección hecha en la lista. let hdTimer = null let hdInfo = upscale let hdForm = null // null (cerrado) | 'pending' | 'range' | 'pick' const picked = new Set() let lastPick = null 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` // Descargados, sin HD y sin estar ya en cola (los fallidos sí, para reintentar). En orden de lectura. const hdCandidates = () => chapters.filter(c => dlSet.has(c.id) && !hdSet.has(c.id) && (!hdJobs[c.id] || hdJobs[c.id] === 'error')) const estimate = ids => { const pages = ids.reduce((n, cid) => n + (hdInfo?.pages?.[cid] || 0), 0) return `${ids.length} capítulo${ids.length === 1 ? '' : 's'} · ${pages} págs. · ${fmtDur(pages * (hdInfo?.secPerPage || 13))}` } const formIds = () => { const cand = hdCandidates() if (hdForm === 'pending') return cand.map(c => c.id) if (hdForm === 'pick') return cand.filter(c => picked.has(c.id)).map(c => c.id) if (hdForm === 'range') { const dls = chapters.filter(c => dlSet.has(c.id)) let a = dls.findIndex(c => c.id === $('#hdfrom')?.value), b = dls.findIndex(c => c.id === $('#hdto')?.value) if (a < 0 || b < 0) return [] if (a > b) [a, b] = [b, a] const inRange = new Set(dls.slice(a, b + 1).map(c => c.id)) return cand.filter(c => inRange.has(c.id)).map(c => c.id) } return [] } const updateSummary = () => { const ids = formIds() const sum = $('#hdsum') if (sum) sum.textContent = ids.length ? estimate(ids) : hdForm === 'pick' ? 'Marca capítulos en la lista de abajo.' : 'No hay capítulos pendientes en esa selección.' if ($('#hdrun')) $('#hdrun').disabled = !ids.length const bar = $('#selbar') if (bar) { bar.hidden = hdForm !== 'pick' if (hdForm === 'pick') bar.innerHTML = `${ids.length ? estimate(ids) : 'Ningún capítulo marcado'} ` $('#selrun')?.addEventListener('click', runHd) $('#selexit')?.addEventListener('click', () => setForm(null)) } } async function runHd() { const ids = formIds() if (!ids.length) return try { const u = await api(`/api/upscale/${id}`, { method: 'POST', body: { chapterIds: ids } }) toast(`${ids.length} capítulo${ids.length === 1 ? '' : 's'} en cola para HD`) picked.clear() setForm(null) renderHd(u) } catch (err) { toast(err.message) } } function setForm(mode) { const wasPick = hdForm === 'pick' hdForm = mode renderHdForm() if (wasPick !== (mode === 'pick')) renderList() updateSummary() } function renderHdForm() { const box = $('#hdform') if (!box) return box.hidden = !hdForm if (!hdForm) { box.innerHTML = ''; return } const dls = chapters.filter(c => dlSet.has(c.id)) const cand = hdCandidates() const opt = (c, sel) => `` const from = cand[0] || dls[0], to = cand[Math.min(cand.length, 10) - 1] || dls.at(-1) box.innerHTML = `
    ${hdForm === 'range' ? `
    ` : ''} ${hdForm === 'pick' ? '
    Marca los capítulos en la lista de abajo. Mayús + clic marca un tramo entero.
    ' : ''} ${hdForm === 'pending' ? `
    Todos los capítulos descargados que aún no están en HD.
    ` : ''}
    ` box.querySelectorAll('[data-hm]').forEach(b => b.addEventListener('click', () => setForm(b.dataset.hm))) $('#hdfrom')?.addEventListener('change', updateSummary) $('#hdto')?.addEventListener('change', updateSummary) $('#hdclose').addEventListener('click', () => setForm(null)) $('#hdrun').addEventListener('click', async () => { if (hdForm === 'pending' && $('#hdautoform')?.checked !== !!hdInfo?.auto) await api(`/api/upscale/${id}/auto`, { method: 'POST', body: { auto: $('#hdautoform').checked } }).catch(() => {}) runHd() }) } 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 = `
    ${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)}
    ` } else if (!u.hd) { body = `
    Mejora la nitidez con IA (Real-ESRGAN ×2). Unos ${u.secPerPage} s por página; el original no se toca.
    ` } if (u.error) body += `
    Falló algún capítulo: ${esc(u.error)}
    ` const canAdd = hdCandidates().length > 0 const actions = [ canAdd ? `` : '', active ? `` : '', u.hd ? `` : '', ].join('') // El formulario se conserva entre sondeos: solo se repinta la parte de arriba. if (!$('#hdtop')) box.innerHTML = '
    ' $('#hdtop').innerHTML = secHead('sparkles', 'Reescalado HD', status) + body + (actions ? `
    ${actions}
    ` : '') + (u.auto ? '
    Los capítulos que se descarguen también se reescalan.
    ' : '') $('#hdopen')?.addEventListener('click', () => setForm(hdForm ? null : '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) { renderList(); if (hdForm && hdForm !== 'pick') renderHdForm(); updateSummary() } if (hdForm && !hdCandidates().length) setForm(null) } cleanup = () => { clearTimeout(dlTimer); clearTimeout(hdTimer) } 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) }) // Cerrar el menú ⋯ al pulsar fuera. const menu = $('.menu', app) const closeMenu = e => { if (menu && !menu.contains(e.target)) menu.open = false } document.addEventListener('click', closeMenu) cleanup = () => { clearTimeout(dlTimer); clearTimeout(hdTimer); document.removeEventListener('click', closeMenu) } 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)) ? 'NUEVO ' : '') + (dlSet.has(c.id) ? `${icon('download')} ` : '') + (hdSet.has(c.id) ? 'HD ' : hdJobs[c.id] ? `HD${hdJobs[c.id] === 'error' ? '!' : '…'} ` : '') + (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group)) function renderList() { if (!ul) return const list = desc ? [...chapters].reverse() : chapters const pickable = new Set(hdForm === 'pick' ? hdCandidates().map(c => c.id) : []) ul.classList.toggle('picking', hdForm === 'pick') 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 (hdForm === 'pick') { const ok = pickable.has(c.id) return `
  • ` } const cls = progress?.chapter_id === c.id ? 'current' : done ? 'read' : '' return `
  • ${esc(chapLabel(c))}${chapMeta(c, r, done)}
  • ` }).join('') } // Selección para HD (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() }) renderDl(download) renderList() renderHd(upscale) $('#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' } // Ajustes por defecto según el tipo de obra (se usan hasta que el usuario cambie algo en ese manga). function defaultPrefs(manga) { // gap: separación entre páginas en vertical (los webtoon son una tira continua; los mangas, páginas sueltas). if (['Manhwa', 'Manhua', 'Webtoon'].includes(manga.type)) return { mode: 'vertical', fit: 'width', dir: 'ltr', gap: false } if (manga.type === 'Manga') return { mode: 'paged', fit: 'height', dir: 'rtl', gap: true } 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', gap: true } } async function viewReader(mangaId, chapterId, startPage) { document.body.classList.add('reading') app.innerHTML = '
    Cargando capítulo…
    ' 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}&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 === '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 => ``).join('') app.innerHTML = `
    ${icon('back')}
    ${esc(manga.title)}
    ${prev ? `${icon('prev')} Cap. ant.` : ''} ${next ? `Cap. sig. ${icon('next')}` : ''}
    ` const reader = $('.reader', app) const box = $('#pages', app) const slider = $('#slider', app) $('#mode').value = settings.mode $('#fit').value = settings.fit $('#dir').value = settings.dir $('#gap').checked = !!settings.gap // 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 = `

    Fin del capítulo ${esc(chapter.num ?? '')}

    ${next ? `${icon('next')} Siguiente: ${esc(chapLabel(next))}` : '

    No hay más capítulos disponibles.

    '} ${icon('back')} Volver a la ficha
    ` 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 = '
    '; stage = $('.stage', box) } stage.classList.toggle('two', shown.length === 2) stage.innerHTML = shown.map(i => ``).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 fit-${settings.fit} ${settings.gap ? 'gap' : ''}` 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() $('#gapwrap').hidden = isPaged() if (isPaged()) { box.innerHTML = '' atEnd = false shown = [] goTo(keep) return } page = keep shown = [keep] 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() } // --- 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, hd: settings.hd !== false }) const savePrefs = () => { api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir, gap: !!settings.gap } }) .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() }) $('#gap').addEventListener('change', e => { settings.gap = e.target.checked; 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() }) $('#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) }) 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 = '
    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"')}
    ${icon('book')} Ficha
    `).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.

    '} ` 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 => `${fmtNum(Math.round(v))}`).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 ? `` : '' const every = W < 420 ? 3 : 2 const lab = (data.length - 1 - i) % every ? '' : `${label(d)}` return `${bar}${lab}` }).join('') return `${grid}${bars}` } async function viewStats() { app.innerHTML = '
    Cargando…
    ' const st = await api('/api/stats') const t = st.totals if (!t.pages) { app.innerHTML = '

    Estadísticas

    Aún no hay lecturas registradas. Lee algún capítulo y vuelve por aquí.

    ' 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 = '') => `
    ${value}
    ${label}
    ${sub ? `
    ${sub}
    ` : ''}
    ` // Calendario: columnas = semanas (lunes arriba). const firstDow = (new Date(`${st.calendar[0].day}T12:00:00`).getDay() + 6) % 7 const cells = [...Array(firstDow).fill(''), ...st.calendar.map(d => ``)].join('') const gmax = Math.max(1, ...st.genres.map(g => g.pages)) app.innerHTML = `

    ${icon('chart')} Estadísticas

    ${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`)}

    Capítulos terminados por semana

    Páginas leídas por semana

    Actividad (últimas 18 semanas)

    ${cells}
    Menos Más páginas

    Géneros favoritos

    ${st.genres.length ? `
    ${st.genres.map(g => `
    ${esc(g.name)}${g.pct}%
    `).join('')}

    Porcentaje de páginas leídas de mangas con ese género (un manga cuenta en todos sus géneros).

    ` : '

    Abre la ficha de los mangas que lees para registrar sus géneros.

    '}

    Lo más leído

    Ver datos por semana ${[...st.weeks].reverse().map(w => ``).join('')}
    Semana delCapítulosPáginas
    ${fmtDay(w.start)}${w.chapters}${fmtNum(w.pages)}
    ` // 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 = `

    Ajustes

    Cuenta
    Sesión iniciada como ${esc(me.username)}
    Idioma preferido de los capítulos
    Cambiar contraseña
    ${icon('bell')} Avisos de capítulos nuevos

    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:

    ntfy Telegram (opcional)

    Los tokens guardados no se muestran: deja el campo vacío para conservarlo o escribe «-» para borrarlo.

    ` 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) } }) }