Portadas HD de lo seguido o leído; sección Colas con descargas, reescalado y biblioteca en tarjetas
- Portadas: se reescalan (×4/×2) solo las de mangas en historial o seguidos; caché en data/covers/. - Colas (antes Descargas): cola de descargas y de reescalado HD con progreso, tiempo restante, reintentar/detener, recortes y portadas pendientes; biblioteca descargada en tarjetas verticales. - Refresco de Colas sin parpadeo: solo se actualiza lo que cambia. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+125
-34
@@ -37,6 +37,7 @@ const ICONS = {
|
||||
pages: '<rect x="2" y="5" width="9" height="14" rx="1.5"/><rect x="13" y="5" width="9" height="14" rx="1.5"/>',
|
||||
scissors: '<circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M20 4 8.1 15.9M14.5 14.5 20 20M8.1 8.1 12 12"/>',
|
||||
image: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-5-5L5 21"/>',
|
||||
queue: '<path d="M3 6h13M3 12h13M3 18h9"/><path d="m17 15 3 3-3 3"/><path d="M20 18h-6"/>',
|
||||
sparkles: '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>',
|
||||
}
|
||||
const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`
|
||||
@@ -56,6 +57,8 @@ const FILTERS = {
|
||||
vivid: ['Colores vivos', 'saturate(1.45) contrast(1.05)'],
|
||||
}
|
||||
const DL_ACTIVE = ['queued', 'downloading']
|
||||
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
|
||||
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`
|
||||
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) {
|
||||
@@ -140,7 +143,8 @@ async function router() {
|
||||
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] === 'downloads') return (location.hash = '#/queues') // antiguo nombre
|
||||
if (parts[0] === 'queues') return await viewQueues()
|
||||
if (parts[0] === 'stats') return await viewStats()
|
||||
if (parts[0] === 'clips') return await viewClips(params)
|
||||
if (parts[0] === 'settings') return viewSettings()
|
||||
@@ -359,8 +363,6 @@ async function viewManga(id, langOverride) {
|
||||
<div id="selbar" hidden></div>`
|
||||
|
||||
const secHead = (ic, title, status) => `<div class="psec-head"><div class="psec-title">${icon(ic)} ${title}</div>${status ? `<div class="psec-status muted">${status}</div>` : ''}</div>`
|
||||
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
|
||||
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`
|
||||
|
||||
// --- Selector de capítulos compartido por "Copia local" (kind 'dl') y "Reescalado HD" (kind 'hd'):
|
||||
// todo lo pendiente (solo HD), un rango o una selección hecha en la lista de abajo.
|
||||
@@ -1356,38 +1358,127 @@ async function viewHistory() {
|
||||
}
|
||||
|
||||
// ---------- Descargas ----------
|
||||
async function viewDownloads() {
|
||||
if (cleanup) { cleanup(); cleanup = null }
|
||||
const list = await api('/api/downloads')
|
||||
if (location.hash !== '#/downloads') return
|
||||
const total = list.reduce((n, d) => n + (d.size || 0), 0)
|
||||
app.innerHTML = `<h1>Descargas</h1>
|
||||
<p class="muted">Se guardan en <code>/zdata/media/mangas</code> como CBZ (compatibles con Kavita, Komga, Mihon…). Los capítulos descargados se leen desde el disco.${list.length ? ` Total: <b>${fmtSize(total)}</b>.` : ''}</p>
|
||||
${list.length ? '' : '<p class="muted">Aún no has descargado nada. Usa “⬇ Descargar” en la ficha de un manga.</p>'}
|
||||
<div class="history">${list.map(d => {
|
||||
const active = DL_ACTIVE.includes(d.status)
|
||||
return `<div class="cont">
|
||||
<a class="info" href="#/manga/${d.manga_id}"><b>${esc(d.title)}</b>
|
||||
<div class="muted">${esc(downloadLabel(d))}${active ? ` · ${Math.floor(downloadPct(d))}%` : ''} · ${fmtSize(d.size)} · mangas/${esc(d.folder)}</div>
|
||||
${active ? `<div class="muted" style="font-size:.8rem">${downloadDetail(d)}</div>` : ''}
|
||||
${d.status === 'error' ? `<div class="error" style="font-size:.8rem">${esc(d.error || '')}</div>` : ''}
|
||||
<div class="bar dlbar"><i style="width:${downloadPct(d).toFixed(1)}%"></i></div></a>
|
||||
<div class="row" style="flex:none">
|
||||
${active ? `<button class="btn small" data-cancel="${esc(d.manga_id)}">${icon('stop')} Cancelar</button>` : ''}
|
||||
<button class="btn small danger" data-del="${esc(d.manga_id)}" data-title="${esc(d.title)}" data-size="${d.size || 0}">${icon('trash')} Eliminar</button>
|
||||
</div></div>`
|
||||
}).join('')}</div>`
|
||||
app.querySelectorAll('[data-cancel]').forEach(b => b.addEventListener('click', async () => { await api(`/api/download/${b.dataset.cancel}/cancel`, { method: 'POST' }); viewDownloads() }))
|
||||
app.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', async () => {
|
||||
if (!confirm(`¿Eliminar la copia local de «${b.dataset.title}» (${fmtSize(Number(b.dataset.size))})?\nTu historial de lectura no se toca.`)) return
|
||||
await api(`/api/download/${b.dataset.del}`, { method: 'DELETE' })
|
||||
toast('Copia local eliminada')
|
||||
viewDownloads()
|
||||
}))
|
||||
if (list.some(d => DL_ACTIVE.includes(d.status))) {
|
||||
const t = setTimeout(() => location.hash === '#/downloads' && viewDownloads(), 2000)
|
||||
cleanup = () => clearTimeout(t)
|
||||
// Actualiza `from` para que quede igual que `to` tocando solo lo que difiere. Los hijos con data-key se
|
||||
// emparejan por clave (una tarjeta que cambia de posición conserva su <img> y no recarga la portada).
|
||||
function morph(from, to, childrenOnly = false) {
|
||||
if (!childrenOnly) {
|
||||
if (from.nodeType !== to.nodeType || from.nodeName !== to.nodeName) { from.replaceWith(to); return to }
|
||||
if (from.nodeType !== 1) { if (from.nodeValue !== to.nodeValue) from.nodeValue = to.nodeValue; return from }
|
||||
for (const { name } of [...from.attributes]) if (!to.hasAttribute(name) && name !== 'data-failed') from.removeAttribute(name)
|
||||
for (const { name, value } of [...to.attributes]) {
|
||||
if (name === 'src' && from.dataset.failed) continue // portada que no cargó: se queda el icono
|
||||
if (from.getAttribute(name) !== value) from.setAttribute(name, value)
|
||||
}
|
||||
}
|
||||
const byKey = new Map([...from.children].filter(n => n.dataset.key).map(n => [n.dataset.key, n]))
|
||||
const kids = [...to.childNodes]
|
||||
kids.forEach((nk, i) => {
|
||||
const key = nk.nodeType === 1 ? nk.dataset.key : null
|
||||
const at = from.childNodes[i]
|
||||
let old = key ? byKey.get(key) : at && !(at.nodeType === 1 && at.dataset.key) ? at : null
|
||||
if (key) byKey.delete(key)
|
||||
const node = old ? morph(old, nk) : nk
|
||||
if (from.childNodes[i] !== node) from.insertBefore(node, from.childNodes[i] || null)
|
||||
})
|
||||
while (from.childNodes.length > kids.length) from.lastChild.remove()
|
||||
return from
|
||||
}
|
||||
|
||||
async function viewQueues() {
|
||||
if (cleanup) { cleanup(); cleanup = null }
|
||||
const here = () => location.hash === '#/queues'
|
||||
if (!$('#qwrap')) app.innerHTML = '<div class="loading">Cargando…</div>'
|
||||
const [q, list] = await Promise.all([api('/api/queues'), api('/api/downloads')])
|
||||
if (!here()) return
|
||||
const up = q.upscale
|
||||
const library = list.filter(d => !DL_ACTIVE.includes(d.status))
|
||||
const hdQueued = up.groups.reduce((n, g) => n + g.queued, 0)
|
||||
const total = list.reduce((n, d) => n + (d.size || 0), 0)
|
||||
const extra = [
|
||||
q.clips.queued + q.clips.running ? `<a href="#/clips">${icon('scissors')} ${plural(q.clips.queued + q.clips.running, 'recorte')} reescalándose</a>` : '',
|
||||
q.clips.errors ? `<a href="#/clips" class="error">${plural(q.clips.errors, 'recorte')} con error</a>` : '',
|
||||
q.covers ? `<span>${icon('image')} ${plural(q.covers, 'portada')} en cola</span>` : '',
|
||||
].filter(Boolean)
|
||||
// Tarjeta vertical (como en Historial): portada, barra de progreso, título, detalles y acciones.
|
||||
const qcard = ({ key, id, cover, badge, pct, title, lines, actions = '', del = '' }) => `
|
||||
<div class="card hcard qcard" data-key="${esc(key)}">
|
||||
<a href="#/manga/${id}"><img class="cover" loading="lazy" src="${esc(cover || '/icon.svg')}" alt="" onerror="this.onerror=null;this.dataset.failed=1;this.src='/icon.svg'"></a>
|
||||
${badge ? `<span class="qpos corner">${badge}</span>` : ''}${del}
|
||||
<div class="bar" ${pct == null ? 'style="visibility:hidden"' : ''}><i style="width:${(pct || 0).toFixed(1)}%"></i></div>
|
||||
<a class="t" href="#/manga/${id}">${esc(title)}</a>
|
||||
<div class="sub">${lines}</div>
|
||||
${actions ? `<div class="qact">${actions}</div>` : ''}
|
||||
</div>`
|
||||
const html = `<h1>Colas</h1>
|
||||
<section class="qsec">
|
||||
<h2>${icon('download')} Descargas ${q.downloads.length ? `<span class="badge">${q.downloads.length}</span>` : ''}</h2>
|
||||
${q.downloads.length ? `<div class="grid qgrid">${q.downloads.map((d, i) => qcard({
|
||||
key: `dl:${d.manga_id}`, id: d.manga_id, cover: d.cover, title: d.title, pct: downloadPct(d),
|
||||
badge: d.status === 'downloading' ? icon('download') : i + 1,
|
||||
lines: `<div>${esc(downloadLabel(d))} · ${Math.floor(downloadPct(d))}%</div>
|
||||
${d.status === 'downloading' ? `<div>${downloadDetail(d)}</div>` : ''}${d.scope === 'some' ? '<div>Capítulos elegidos</div>' : ''}`,
|
||||
actions: `<button class="btn small" data-cancel="${esc(d.manga_id)}">${icon('stop')} Cancelar</button>`,
|
||||
})).join('')}</div>` : '<p class="muted">No hay descargas en curso. Se añaden desde la ficha de un manga.</p>'}
|
||||
</section>
|
||||
<section class="qsec">
|
||||
<h2>${icon('sparkles')} Reescalado HD ${hdQueued ? `<span class="badge">${hdQueued}</span>` : ''}</h2>
|
||||
${up.available ? '' : '<p class="muted">El reescalado no está disponible en este servidor (falta Real-ESRGAN o la GPU).</p>'}
|
||||
${up.groups.length ? `${hdQueued ? `<p class="muted small">Un capítulo cada vez, en orden de llegada · ${up.secPerPage} s por página · quedan ${fmtDur(up.eta)}</p>` : ''}
|
||||
<div class="grid qgrid">${up.groups.map((g, i) => {
|
||||
const cur = up.current?.mangaId === g.mangaId ? up.current : null
|
||||
const rest = g.queued - (cur ? 1 : 0)
|
||||
const next = g.next.length ? ` (sig.: ${g.next.map(n => esc(n ?? '?')).join(', ')}${rest > g.next.length ? '…' : ''})` : ''
|
||||
return qcard({
|
||||
key: `hd:${g.mangaId}`, id: g.mangaId, cover: g.cover, title: g.title,
|
||||
pct: cur?.total ? (cur.pages / cur.total) * 100 : null,
|
||||
badge: cur ? icon('sparkles') : g.queued ? i + 1 : '!',
|
||||
lines: `${cur ? `<div>Cap. ${esc(cur.num ?? '?')} · pág. ${cur.pages}/${cur.total}</div>` : ''}
|
||||
${g.queued ? `<div>${rest ? `${cur ? `${rest} más` : plural(rest, 'capítulo')} en cola${next}` : 'Último de la cola'}</div><div>${fmtDur(g.eta)}</div>` : ''}
|
||||
${g.errors.length ? `<div class="error" title="${esc(g.errors.map(e => `Cap. ${e.num ?? '?'}: ${e.error || 'error'}`).join('\n'))}">${g.errors.length === 1 ? `Cap. ${esc(g.errors[0].num ?? '?')}: ${esc(g.errors[0].error || 'error')}` : `${g.errors.length} con error (caps. ${g.errors.slice(0, 6).map(e => esc(e.num ?? '?')).join(', ')}${g.errors.length > 6 ? '…' : ''})`}</div>` : ''}`,
|
||||
actions: `${g.errors.length ? `<button class="btn small" data-retry="${esc(g.mangaId)}" data-ids="${esc(g.errors.map(e => e.chapterId).join(' '))}">${icon('refresh')} Reintentar</button>` : ''}
|
||||
${g.queued ? `<button class="btn small" data-hdstop="${esc(g.mangaId)}" title="Vacía su cola y desactiva el reescalado automático">${icon('stop')} Detener</button>` : ''}`,
|
||||
})
|
||||
}).join('')}</div>` : up.available ? '<p class="muted">No hay capítulos esperando. Se reescalan desde la ficha de un manga descargado o desde el lector.</p>' : ''}
|
||||
${extra.length ? `<div class="facts muted">${extra.join('')}</div>` : ''}
|
||||
</section>
|
||||
<section class="qsec">
|
||||
<h2>${icon('book')} Biblioteca descargada</h2>
|
||||
<p class="muted small">CBZ en <code>/zdata/media/mangas</code> (compatibles con Kavita, Komga, Mihon…); se leen desde el disco.${list.length ? ` Total: <b>${fmtSize(total)}</b>.` : ''}</p>
|
||||
${library.length ? `<div class="grid">${library.map(d => qcard({
|
||||
key: `lib:${d.manga_id}`, id: d.manga_id, cover: d.cover, title: d.title,
|
||||
pct: d.total ? Math.min(100, (d.done / d.total) * 100) : null,
|
||||
lines: `<div>${esc(downloadLabel(d))}</div><div>${fmtSize(d.size)}</div>
|
||||
${d.status === 'error' ? `<div class="error" title="${esc(d.error || '')}">Con errores</div>` : ''}`,
|
||||
del: `<button class="hdel" data-del="${esc(d.manga_id)}" data-title="${esc(d.title)}" data-size="${d.size || 0}" title="Eliminar la copia local (mangas/${esc(d.folder)})" aria-label="Eliminar la copia local">${icon('trash')}</button>`,
|
||||
})).join('')}</div>` : `<p class="muted">${list.length ? 'Todo lo descargado está aún en la cola.' : 'Aún no has descargado nada. Usa “Descargar” en la ficha de un manga.'}</p>`}
|
||||
</section>`
|
||||
// Refresco sin parpadeo: se actualiza solo lo que cambia (las portadas no se vuelven a cargar).
|
||||
let wrap = $('#qwrap')
|
||||
if (!wrap) {
|
||||
app.innerHTML = `<div id="qwrap">${html}</div>`
|
||||
wrap = $('#qwrap')
|
||||
// Delegación: los botones se crean y actualizan en cada refresco.
|
||||
const act = async (fn, msg) => { try { await fn(); if (msg) toast(msg) } catch (err) { toast(err.message) } viewQueues() }
|
||||
wrap.addEventListener('click', e => {
|
||||
const b = e.target.closest('button')
|
||||
if (!b) return
|
||||
if (b.dataset.cancel) act(() => api(`/api/download/${b.dataset.cancel}/cancel`, { method: 'POST' }), 'Descarga cancelada')
|
||||
else if (b.dataset.hdstop) act(() => api(`/api/upscale/${b.dataset.hdstop}/cancel`, { method: 'POST' }), 'Reescalado detenido')
|
||||
else if (b.dataset.retry) act(() => api(`/api/upscale/${b.dataset.retry}`, { method: 'POST', body: { chapterIds: b.dataset.ids.split(' ') } }), 'Vuelto a la cola')
|
||||
else if (b.dataset.del) {
|
||||
if (!confirm(`¿Eliminar la copia local de «${b.dataset.title}» (${fmtSize(Number(b.dataset.size))})?\nTu historial de lectura no se toca.`)) return
|
||||
act(() => api(`/api/download/${b.dataset.del}`, { method: 'DELETE' }), 'Copia local eliminada')
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const tpl = document.createElement('div')
|
||||
tpl.innerHTML = html
|
||||
morph(wrap, tpl, true)
|
||||
}
|
||||
// Refresco: rápido mientras algo avanza, lento si no (por si llega algo nuevo).
|
||||
const busy = q.downloads.length || up.current || up.groups.some(g => g.queued) || q.clips.queued + q.clips.running || q.covers
|
||||
const t = setTimeout(() => here() && viewQueues(), busy ? 2000 : 15000)
|
||||
cleanup = () => clearTimeout(t)
|
||||
}
|
||||
|
||||
// ---------- Estadísticas ----------
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<nav>
|
||||
<a href="#/" data-icon="home">Inicio</a>
|
||||
<a href="#/history" data-icon="history">Historial</a>
|
||||
<a href="#/downloads" data-icon="download">Descargas</a>
|
||||
<a href="#/queues" data-icon="queue">Colas</a>
|
||||
<a href="#/clips" data-icon="scissors">Recortes</a>
|
||||
<a href="#/stats" data-icon="chart">Estadísticas</a>
|
||||
<a href="#/settings" data-icon="settings">Ajustes</a>
|
||||
|
||||
@@ -314,3 +314,17 @@ body.noscroll { overflow: hidden; }
|
||||
.hcard:hover .hdel, .hdel:focus-visible { opacity: 1; }
|
||||
.hdel:hover { color: var(--danger); }
|
||||
@media (hover: none) { .hdel { opacity: 1; } }
|
||||
|
||||
/* Colas: tarjetas verticales como en Historial */
|
||||
.qsec { margin-bottom: 26px; }
|
||||
.qsec h2 { display: flex; align-items: center; gap: 8px; }
|
||||
.qcard { display: flex; flex-direction: column; }
|
||||
.qcard > a { color: inherit; }
|
||||
.qcard .sub { display: grid; gap: 1px; padding-bottom: 8px; }
|
||||
.qcard .sub .error { font-size: .78rem; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.qact { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 10px 10px; margin-top: auto; }
|
||||
.qact .btn { flex: 1; padding: 6px 8px; font-size: .8rem; }
|
||||
.qpos { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; background: rgba(20, 18, 26, .85); color: var(--text); font-size: .8rem; font-weight: 700; }
|
||||
.qpos .ic { color: var(--accent); width: 15px; height: 15px; }
|
||||
.small { font-size: .82rem; }
|
||||
@media (max-width: 640px) { .qgrid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } }
|
||||
|
||||
Reference in New Issue
Block a user