const app = document.getElementById('app') 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.

${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 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]) => ``).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' : ''}` })) 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)}

${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 } = 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 = `Continuar · ${progress.chapter_num ? `Cap. ${esc(progress.chapter_num)}` : 'Oneshot'}, pág. ${progress.page + 1}` } else if (progress) { mainBtn = `Continuar · Cap. ${esc(progress.chapter_num ?? '?')} (${esc(LANGS[progress.lang] || progress.lang || '')}), pág. ${progress.page + 1}` } if (first && !mainBtn) mainBtn = `Empezar a leer` 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} ${progress || reads.length ? '' : ''} ${isLc ? '' : ` Buscar en LeerCapitulo`}
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 ? '' : ''}

${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 ? `` : '' const bar = d && (active || d.done < d.total) ? `
` : '' let html if (!d || d.status === 'cancelled') html = `
${delBtn}
${bar}` else if (active) html = `
${downloadLabel(d)} · ${Math.floor(downloadPct(d))}%${delBtn}
${bar}
${downloadDetail(d)}
` else html = `
✓ ${downloadLabel(d)}${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) ? '⬇ ' : '') + (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 = '
    Cargando capítulo…
    ' 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 => ``).join('') app.innerHTML = `
    ←
    ${esc(manga.title)}
    ${manga.source === 'lc' ? '' : ''}
    ${prev ? `« Cap. ant.` : ''} ${next ? `Cap. sig. »` : ''}
    ` 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 = `

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

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

    No hay más capítulos disponibles.

    '} Volver a la ficha
    ` 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"')}
    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.

    '}
    ${list.map(d => { const active = DL_ACTIVE.includes(d.status) return `
    ${esc(d.title)}
    ${esc(downloadLabel(d))}${active ? ` · ${Math.floor(downloadPct(d))}%` : ''} · ${fmtSize(d.size)} · mangas/${esc(d.folder)}
    ${active ? `
    ${downloadDetail(d)}
    ` : ''} ${d.status === 'error' ? `
    ${esc(d.error || '')}
    ` : ''}
    ${active ? `` : ''}
    ` }).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)}
    Idioma preferido de los capítulos
    Cambiar contraseña
    ` $('#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) } }) }