Categorías, tags clicables, progreso de descarga y eliminar copia local
- Pestaña "Categorías" (géneros/temáticas de LeerCapitulo, tags de MangaDex) con búsqueda dentro de la categoría y "Cargar más". - Los tags de la ficha llevan a su categoría. - Barra de progreso de descarga con el capítulo y la página en curso. - Eliminar la copia local (también a mitad de descarga) desde la ficha y desde la página Descargas, mostrando el espacio ocupado. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+94
-26
@@ -12,6 +12,18 @@ const FILTERS = {
|
||||
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 || '?'}`
|
||||
@@ -135,39 +147,73 @@ function continueCard(h) {
|
||||
<div class="muted" style="font-size:.8rem">${timeAgo(h.updated_at)}</div>
|
||||
<div class="bar"><i style="width:${pct}%"></i></div></div></a>`
|
||||
}
|
||||
const tagHref = (source, t) => `#/?source=${source}&tag=${encodeURIComponent(t.id)}&tagName=${encodeURIComponent(t.name)}`
|
||||
async function viewHome(params) {
|
||||
const q = params.get('q') || ''
|
||||
const mode = params.get('mode') || 'popular'
|
||||
const tag = params.get('tag') || ''
|
||||
const tagName = params.get('tagName') || tag
|
||||
const mode = tag ? 'cats' : params.get('mode') || 'popular'
|
||||
const anyLang = params.get('any') === '1'
|
||||
const source = SOURCES[params.get('source')] ? params.get('source') : store.get('source', 'lc')
|
||||
store.set('source', source)
|
||||
const showCats = mode === 'cats' && !tag
|
||||
app.innerHTML = `
|
||||
<section id="cont"></section>
|
||||
<div class="tabs" id="sources">${Object.entries(SOURCES).map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}">${v}</button>`).join('')}</div>
|
||||
<form class="search"><input type="search" name="q" placeholder="Buscar en ${esc(SOURCES[source])}…" value="${esc(q)}"><button class="btn primary">Buscar</button></form>
|
||||
<form class="search"><input type="search" name="q" placeholder="Buscar ${tag ? `en «${esc(tagName)}»` : `en ${esc(SOURCES[source])}`}…" value="${esc(q)}"><button class="btn primary">Buscar</button></form>
|
||||
<label class="muted row" style="font-size:.88rem" ${source === 'md' ? '' : 'hidden'}><input type="checkbox" name="any" ${anyLang ? 'checked' : ''}> Incluir mangas sin traducción a ${esc(LANGS[me.lang] || me.lang)}</label>
|
||||
${q ? `<h2>Resultados para “${esc(q)}”</h2>` : `<div class="tabs" id="modes"><button data-m="popular" class="${mode === 'popular' ? 'on' : ''}">Populares</button><button data-m="latest" class="${mode === 'latest' ? 'on' : ''}">Actualizados recientemente</button></div>`}
|
||||
<div id="results" class="loading">Cargando…</div>`
|
||||
${q && !tag ? `<h2>Resultados para “${esc(q)}”</h2>` : `<div class="tabs" id="modes">
|
||||
<button data-m="popular" class="${mode === 'popular' ? 'on' : ''}">Populares</button>
|
||||
<button data-m="latest" class="${mode === 'latest' ? 'on' : ''}">Actualizados recientemente</button>
|
||||
<button data-m="cats" class="${mode === 'cats' ? 'on' : ''}">Categorías</button></div>`}
|
||||
${tag ? `<h2>Categoría: <a class="tag on" href="#/?source=${source}&mode=cats" title="Quitar filtro">${esc(tagName)} ✕</a>${q ? ` · “${esc(q)}”` : ''}</h2>` : ''}
|
||||
<div id="results" class="loading">Cargando…</div>
|
||||
<div class="row" style="justify-content:center;margin:16px 0"><button class="btn" id="more" hidden>Cargar más</button></div>`
|
||||
const form = $('form', app)
|
||||
const go = (extra = {}) => {
|
||||
const p = new URLSearchParams({ source, ...(form.q.value ? { q: form.q.value } : { mode }), ...($('[name=any]').checked ? { any: '1' } : {}), ...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', () => go({ source: b.dataset.s })))
|
||||
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) return
|
||||
if (!hist.length || q || tag || showCats) return
|
||||
$('#cont').innerHTML = `<h2>Continuar leyendo</h2><div class="continue">${hist.slice(0, 12).map(continueCard).join('')}</div>`
|
||||
}).catch(() => {})
|
||||
|
||||
const qs = new URLSearchParams({ q, mode, source, ...(anyLang ? { anyLang: '1' } : {}) })
|
||||
const list = await api(`/api/search?${qs}`)
|
||||
const res = $('#results')
|
||||
res.className = list.length ? 'grid' : 'loading'
|
||||
res.innerHTML = list.length ? list.map(mangaCard).join('') : 'Sin resultados.'
|
||||
if (showCats) {
|
||||
const cats = await api(`/api/categories?source=${source}`)
|
||||
const groups = cats.reduce((m, c) => m.set(c.group, [...(m.get(c.group) || []), c]), new Map())
|
||||
res.className = 'cats'
|
||||
res.innerHTML = [...groups].map(([g, list]) => `<h3>${esc(g)}</h3><div class="tags">${list.map(t => `<a class="tag big" href="${tagHref(source, t)}">${esc(t.name)}</a>`).join('')}</div>`).join('')
|
||||
return
|
||||
}
|
||||
|
||||
// Paginación: MangaDex siempre; LeerCapitulo solo en categorías (sus otros listados no paginan).
|
||||
const pageable = source === 'md' || !!tag
|
||||
const seen = new Set()
|
||||
let page = 1
|
||||
const load = async () => {
|
||||
const qs = new URLSearchParams({ q, mode, source, page, ...(tag ? { tag } : {}), ...(anyLang ? { anyLang: '1' } : {}) })
|
||||
const list = (await api(`/api/search?${qs}`)).filter(m => !seen.has(m.id) && seen.add(m.id))
|
||||
if (page === 1) {
|
||||
res.className = list.length ? 'grid' : 'loading'
|
||||
res.innerHTML = list.length ? '' : 'Sin resultados.'
|
||||
}
|
||||
res.insertAdjacentHTML('beforeend', list.map(mangaCard).join(''))
|
||||
$('#more').hidden = !pageable || list.length < 20
|
||||
}
|
||||
$('#more').addEventListener('click', async e => {
|
||||
e.target.disabled = true
|
||||
page++
|
||||
try { await load() } catch (err) { toast(err.message) }
|
||||
e.target.disabled = false
|
||||
})
|
||||
await load()
|
||||
}
|
||||
|
||||
// ---------- Ficha del manga ----------
|
||||
@@ -196,16 +242,16 @@ async function viewManga(id, langOverride) {
|
||||
<div>
|
||||
<h1>${esc(manga.title)}</h1>
|
||||
<div class="muted">${esc(manga.authors.join(', '))}${manga.year ? ` · ${manga.year}` : ''}${manga.status ? ` · ${esc(manga.status)}` : ''}</div>
|
||||
<div class="tags">${manga.tags.map(t => `<span class="tag">${esc(t)}</span>`).join('')}</div>
|
||||
<div class="tags">${manga.tags.map(t => (t.id ? `<a class="tag" href="${tagHref(manga.source, t)}">${esc(t.name)}</a>` : `<span class="tag">${esc(t.name ?? t)}</span>`)).join('')}</div>
|
||||
<p class="desc">${esc(manga.description)}</p>
|
||||
<div class="row">${mainBtn}
|
||||
<span id="dl" class="row"></span>
|
||||
${progress || reads.length ? '<button class="btn danger" id="drop">Dejar de leer</button>' : ''}
|
||||
${isLc ? '' : `<select id="lang">${langOpts.map(l => `<option value="${esc(l)}" ${l === lang ? 'selected' : ''}>${esc(LANGS[l] || l)}</option>`).join('')}</select>
|
||||
<a class="btn" href="${altSearch}">Buscar en LeerCapitulo</a>`}
|
||||
</div>
|
||||
<div class="muted" style="font-size:.85rem;margin-top:6px">Fuente: ${esc(SOURCES[manga.source])}${download ? ` · Carpeta: mangas/${esc(download.folder)}` : ''}</div>
|
||||
${offline ? '<p class="error">Sin conexión con la fuente: mostrando solo los capítulos descargados.</p>' : ''}
|
||||
<div id="dl" class="dlbox"></div>
|
||||
<p class="error" id="dlerr" style="font-size:.85rem"></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,10 +266,12 @@ async function viewManga(id, langOverride) {
|
||||
const box = $('#dl')
|
||||
if (!box) return
|
||||
const active = d && DL_ACTIVE.includes(d.status)
|
||||
const delBtn = d ? `<button class="btn small danger" id="dldel">Eliminar copia local${d.size ? ` (${fmtSize(d.size)})` : ''}</button>` : ''
|
||||
const bar = d && (active || d.done < d.total) ? `<div class="bar dlbar"><i style="width:${downloadPct(d).toFixed(1)}%"></i></div>` : ''
|
||||
let html
|
||||
if (!d || d.status === 'cancelled') html = `<button class="btn" id="dlgo">⬇ Descargar${d ? ` (llevas ${d.done}/${d.total})` : ''}</button>`
|
||||
else if (active) html = `<span class="muted">${downloadLabel(d)}</span><button class="btn small" id="dlcancel">Cancelar</button>`
|
||||
else html = `<span class="muted">✓ ${downloadLabel(d)}</span><button class="btn small" id="dlgo" title="Descarga los capítulos que falten">${d.status === 'error' ? 'Reintentar' : 'Buscar nuevos'}</button>`
|
||||
if (!d || d.status === 'cancelled') html = `<div class="row"><button class="btn" id="dlgo">⬇ Descargar${d ? ` (llevas ${d.done}/${d.total})` : ''}</button>${delBtn}</div>${bar}`
|
||||
else if (active) html = `<div class="row"><span>${downloadLabel(d)} · ${Math.floor(downloadPct(d))}%</span><button class="btn small" id="dlcancel">Cancelar</button>${delBtn}</div>${bar}<div class="muted" style="font-size:.8rem">${downloadDetail(d)}</div>`
|
||||
else html = `<div class="row"><span class="muted">✓ ${downloadLabel(d)}</span><button class="btn small" id="dlgo" title="Descarga los capítulos que falten">${d.status === 'error' ? 'Reintentar' : 'Buscar nuevos'}</button>${delBtn}</div>${bar}`
|
||||
box.innerHTML = offline ? (d ? `<span class="muted">${downloadLabel(d)}</span>` : '') : html
|
||||
$('#dlerr').textContent = d?.status === 'error' ? d.error || '' : ''
|
||||
$('#dlgo')?.addEventListener('click', async () => {
|
||||
@@ -231,13 +279,19 @@ async function viewManga(id, langOverride) {
|
||||
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 (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)
|
||||
}, 3000)
|
||||
}, 2000)
|
||||
}
|
||||
renderDl(download)
|
||||
cleanup = () => clearTimeout(dlTimer)
|
||||
@@ -468,21 +522,35 @@ async function viewHistory() {
|
||||
|
||||
// ---------- Descargas ----------
|
||||
async function viewDownloads() {
|
||||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||||
if (cleanup) { cleanup(); cleanup = null }
|
||||
const list = await api('/api/downloads')
|
||||
if (location.hash !== '#/downloads') return
|
||||
const total = list.reduce((n, d) => n + (d.size || 0), 0)
|
||||
app.innerHTML = `<h1>Descargas</h1>
|
||||
<p class="muted">Se guardan en <code>/zdata/media/mangas</code> como CBZ (compatibles con Kavita, Komga, Mihon…). Los capítulos descargados se leen desde el disco.</p>
|
||||
<p class="muted">Se guardan en <code>/zdata/media/mangas</code> como CBZ (compatibles con Kavita, Komga, Mihon…). Los capítulos descargados se leen desde el disco.${list.length ? ` Total: <b>${fmtSize(total)}</b>.` : ''}</p>
|
||||
${list.length ? '' : '<p class="muted">Aún no has descargado nada. Usa “⬇ Descargar” en la ficha de un manga.</p>'}
|
||||
<div class="history">${list.map(d => {
|
||||
const pct = d.total ? Math.round((d.done / d.total) * 100) : 0
|
||||
return `<a class="cont" href="#/manga/${d.manga_id}">
|
||||
<div class="info"><b>${esc(d.title)}</b>
|
||||
<div class="muted">${esc(downloadLabel(d))} · mangas/${esc(d.folder)}</div>
|
||||
const active = DL_ACTIVE.includes(d.status)
|
||||
return `<div class="cont">
|
||||
<a class="info" href="#/manga/${d.manga_id}"><b>${esc(d.title)}</b>
|
||||
<div class="muted">${esc(downloadLabel(d))}${active ? ` · ${Math.floor(downloadPct(d))}%` : ''} · ${fmtSize(d.size)} · mangas/${esc(d.folder)}</div>
|
||||
${active ? `<div class="muted" style="font-size:.8rem">${downloadDetail(d)}</div>` : ''}
|
||||
${d.status === 'error' ? `<div class="error" style="font-size:.8rem">${esc(d.error || '')}</div>` : ''}
|
||||
<div class="bar"><i style="width:${pct}%"></i></div></div></a>`
|
||||
<div class="bar dlbar"><i style="width:${downloadPct(d).toFixed(1)}%"></i></div></a>
|
||||
<div class="row" style="flex:none">
|
||||
${active ? `<button class="btn small" data-cancel="${esc(d.manga_id)}">Cancelar</button>` : ''}
|
||||
<button class="btn small danger" data-del="${esc(d.manga_id)}" data-title="${esc(d.title)}" data-size="${d.size || 0}">Eliminar</button>
|
||||
</div></div>`
|
||||
}).join('')}</div>`
|
||||
app.querySelectorAll('[data-cancel]').forEach(b => b.addEventListener('click', async () => { await api(`/api/download/${b.dataset.cancel}/cancel`, { method: 'POST' }); viewDownloads() }))
|
||||
app.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', async () => {
|
||||
if (!confirm(`¿Eliminar la copia local de «${b.dataset.title}» (${fmtSize(Number(b.dataset.size))})?\nTu historial de lectura no se toca.`)) return
|
||||
await api(`/api/download/${b.dataset.del}`, { method: 'DELETE' })
|
||||
toast('Copia local eliminada')
|
||||
viewDownloads()
|
||||
}))
|
||||
if (list.some(d => DL_ACTIVE.includes(d.status))) {
|
||||
const t = setTimeout(() => location.hash === '#/downloads' && viewDownloads(), 4000)
|
||||
const t = setTimeout(() => location.hash === '#/downloads' && viewDownloads(), 2000)
|
||||
cleanup = () => clearTimeout(t)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user