Reescalado HD, ficha reorganizada y mejoras del lector
- Reescalado HD con Real-ESRGAN (ncnn-vulkan, GPU por /dev/dri): cola persistente, todo lo pendiente, un rango o capítulos elegidos en la lista, con estimación de tiempo. La versión HD va a <carpeta>/.hd/ sin tocar el original; marca HD en la lista de capítulos y casilla "Versión HD" en el lector. - Imagen Debian slim (glibc + Mesa Vulkan + cwebp); compose con /dev/dri y RENDER_GID. - Ficha: acciones principales + menú ⋯, panel "Biblioteca local" (copia local y HD), idioma junto a la lista de capítulos. - Lector: la barra de progreso avanza de derecha a izquierda en der → izq; botón rápido páginas ⇄ vertical; en vertical "Ajustar alto" y "Separar páginas" para mangas. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+276
-42
@@ -31,6 +31,10 @@ const ICONS = {
|
||||
chart: '<path d="M3 3v18h18"/><path d="M7 16v-4M12 16V8M17 16v-7"/>',
|
||||
zoomOut: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5M8 11h6"/>',
|
||||
send: '<path d="M22 2 11 13"/><path d="M22 2 15 22l-4-9-9-4z"/>',
|
||||
dots: '<circle cx="5" cy="12" r="1.2"/><circle cx="12" cy="12" r="1.2"/><circle cx="19" cy="12" r="1.2"/>',
|
||||
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||
scroll: '<rect x="6" y="2" width="12" height="9" rx="1.5"/><rect x="6" y="13" width="12" height="9" rx="1.5"/>',
|
||||
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"/>',
|
||||
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>`
|
||||
@@ -298,8 +302,9 @@ async function viewManga(id, langOverride) {
|
||||
if (cleanup) { cleanup(); cleanup = null }
|
||||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||||
const lang = langOverride || store.get(`lang:${id}`, me.lang)
|
||||
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, following, newKeys = [] } = await api(`/api/manga/${id}?lang=${encodeURIComponent(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]))
|
||||
@@ -315,52 +320,71 @@ async function viewManga(id, langOverride) {
|
||||
}
|
||||
if (first && !mainBtn) mainBtn = `<a class="btn primary" href="${readUrl(id, first.id)}">${icon('book')} Empezar a leer</a>`
|
||||
|
||||
const menuItems = [
|
||||
isLc ? '' : `<a href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
|
||||
progress || reads.length ? `<button id="drop" class="danger">${icon('stopRead')} Dejar de leer…</button>` : '',
|
||||
].join('')
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="detail">
|
||||
<img class="cover" src="${esc(manga.cover || '/icon.svg')}" alt="">
|
||||
<div>
|
||||
<div class="detail-body">
|
||||
<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 => (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}
|
||||
<div class="actions">${mainBtn}
|
||||
<button class="btn" id="follow"></button>
|
||||
${progress || reads.length ? `<button class="btn danger" id="drop">${icon('stopRead')} 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}">${icon('search')} Buscar en LeerCapitulo</a>`}
|
||||
${menuItems ? `<details class="menu"><summary class="btn" title="Más opciones" aria-label="Más opciones">${icon('dots')}</summary><div class="menu-list">${menuItems}</div></details>` : ''}
|
||||
</div>
|
||||
<div class="muted" style="font-size:.85rem;margin-top:6px">Fuente: ${esc(SOURCES[manga.source])}${download ? ` · Carpeta: mangas/${esc(download.folder)}` : ''}</div>
|
||||
<div class="facts muted"><span>Fuente: ${esc(SOURCES[manga.source])}</span>${download ? `<span>Carpeta: mangas/${esc(download.folder)}</span>` : ''}</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 class="libpanel"><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-head">
|
||||
<h2>Capítulos <span class="muted">(${chapters.length})</span>${newSet.size ? ` <span class="badge">${newSet.size} ${newSet.size === 1 ? 'nuevo' : 'nuevos'}</span>` : ''}</h2>
|
||||
<div class="row">
|
||||
${isLc ? '' : `<select id="lang" title="Idioma de los capítulos">${langOpts.map(l => `<option value="${esc(l)}" ${l === lang ? 'selected' : ''}>${esc(LANGS[l] || l)}</option>`).join('')}</select>`}
|
||||
${chapters.length > 1 ? `<button class="btn small" id="order">${icon('sort')} Invertir orden</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<h2>Capítulos <span class="muted">(${chapters.length})</span>${newSet.size ? ` <span class="badge">${newSet.size} ${newSet.size === 1 ? 'nuevo' : 'nuevos'}</span>` : ''}
|
||||
${chapters.length > 1 ? `<button class="btn small" id="order" style="float:right">${icon('sort')} Invertir orden</button>` : ''}</h2>
|
||||
${chapters.length ? '<ul class="chapters"></ul>' : isLc ? '<p class="muted">Este manga no tiene capítulos.</p>' : `<p class="muted">No hay capítulos legibles en ${esc(LANGS[lang] || lang)} (MangaDex retira algunos por licencia).
|
||||
Prueba a <a class="tag" href="${altSearch}">buscarlo en LeerCapitulo</a>.
|
||||
${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `<a class="tag" href="#" data-lang="${esc(l)}">${esc(LANGS[l] || l)}</a>`).join(' ')}` : 'No hay capítulos en ningún idioma.'}</p>`}`
|
||||
// Botón de descarga con sondeo del progreso mientras está activa.
|
||||
${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `<a class="tag" href="#" data-lang="${esc(l)}">${esc(LANGS[l] || l)}</a>`).join(' ')}` : 'No hay capítulos en ningún idioma.'}</p>`}
|
||||
<div id="selbar" hidden></div>`
|
||||
|
||||
// --- Copia local (descarga) con sondeo del progreso mientras está activa.
|
||||
let dlTimer = null
|
||||
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 renderDl = d => {
|
||||
const box = $('#dl')
|
||||
if (!box) return
|
||||
const active = d && DL_ACTIVE.includes(d.status)
|
||||
const delBtn = d ? `<button class="btn small danger" id="dldel">${icon('trash')} 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 = `<div class="row"><button class="btn" id="dlgo">${icon('download')} 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">${icon('stop')} Cancelar</button>${delBtn}</div>${bar}<div class="muted" style="font-size:.8rem">${downloadDetail(d)}</div>`
|
||||
else html = `<div class="row"><span class="muted row" style="gap:4px">${icon('check')} ${downloadLabel(d)}</span><button class="btn small" id="dlgo" title="Descarga los capítulos que falten">${icon('refresh')} ${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 || '' : ''
|
||||
const partial = d && d.done < d.total
|
||||
const bar = d && (active || partial) ? `<div class="bar dlbar"><i style="width:${downloadPct(d).toFixed(1)}%"></i></div>` : ''
|
||||
const delBtn = d ? `<button class="btn small danger push" id="dldel">${icon('trash')} Eliminar copia${d.size ? ` · ${fmtSize(d.size)}` : ''}</button>` : ''
|
||||
let status = '', body = '', actions = ''
|
||||
if (!d) {
|
||||
body = '<div class="note muted">Guarda los capítulos en CBZ en el servidor para leerlos sin conexión (y poder reescalarlos a HD).</div>'
|
||||
actions = `<button class="btn small" id="dlgo">${icon('download')} Descargar manga</button>`
|
||||
} else if (active) {
|
||||
status = `${downloadLabel(d)} · ${Math.floor(downloadPct(d))}%`
|
||||
body = `${bar}<div class="note muted">${downloadDetail(d)}</div>`
|
||||
actions = `<button class="btn small" id="dlcancel">${icon('stop')} Cancelar</button>${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 ? `<div class="error note">${esc(d.error)}</div>` : '')
|
||||
actions = `<button class="btn small" id="dlgo" title="Descarga los capítulos que falten">${icon(d.status === 'cancelled' ? 'download' : 'refresh')} ${d.status === 'error' ? 'Reintentar' : d.status === 'cancelled' ? 'Continuar descarga' : 'Buscar capítulos nuevos'}</button>${delBtn}`
|
||||
}
|
||||
box.innerHTML = secHead('download', 'Copia local', status) + body + (offline ? '' : `<div class="psec-actions">${actions}</div>`)
|
||||
$('#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
|
||||
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)
|
||||
@@ -369,12 +393,159 @@ async function viewManga(id, langOverride) {
|
||||
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 }
|
||||
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)
|
||||
}
|
||||
renderDl(download)
|
||||
cleanup = () => clearTimeout(dlTimer)
|
||||
|
||||
// --- 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 = `<span>${ids.length ? estimate(ids) : 'Ningún capítulo marcado'}</span>
|
||||
<button class="btn small primary" id="selrun" ${ids.length ? '' : 'disabled'}>${icon('sparkles')} Reescalar</button>
|
||||
<button class="btn small" id="selexit" title="Salir de la selección">${icon('x')}</button>`
|
||||
$('#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) => `<option value="${c.id}" ${sel ? 'selected' : ''}>${esc(c.num ? `Cap. ${c.num}` : 'Oneshot')}${hdSet.has(c.id) ? ' (HD)' : ''}</option>`
|
||||
const from = cand[0] || dls[0], to = cand[Math.min(cand.length, 10) - 1] || dls.at(-1)
|
||||
box.innerHTML = `
|
||||
<div class="seg" role="tablist">
|
||||
<button data-hm="pending" class="${hdForm === 'pending' ? 'on' : ''}">Pendientes</button>
|
||||
<button data-hm="range" class="${hdForm === 'range' ? 'on' : ''}">Rango</button>
|
||||
<button data-hm="pick" class="${hdForm === 'pick' ? 'on' : ''}">Elegir</button>
|
||||
</div>
|
||||
${hdForm === 'range' ? `<div class="row range"><label>Desde <select id="hdfrom">${dls.map(c => opt(c, c === from)).join('')}</select></label>
|
||||
<label>hasta <select id="hdto">${dls.map(c => opt(c, c === to)).join('')}</select></label></div>` : ''}
|
||||
${hdForm === 'pick' ? '<div class="note muted">Marca los capítulos en la lista de abajo. Mayús + clic marca un tramo entero.</div>' : ''}
|
||||
${hdForm === 'pending' ? `<div class="note muted">Todos los capítulos descargados que aún no están en HD.</div>
|
||||
<label class="check"><input type="checkbox" id="hdautoform" ${hdInfo?.auto ? 'checked' : ''}> Reescalar también los que se descarguen en el futuro</label>` : ''}
|
||||
<div class="row"><b id="hdsum" class="sum"></b></div>
|
||||
<div class="psec-actions"><button class="btn small primary" id="hdrun">${icon('sparkles')} Reescalar</button><button class="btn small" id="hdclose">Cerrar</button></div>`
|
||||
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 = `<div class="bar dlbar"><i style="width:${pct.toFixed(1)}%"></i></div>
|
||||
<div class="note muted">${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)}</div>`
|
||||
} else if (!u.hd) {
|
||||
body = `<div class="note muted">Mejora la nitidez con IA (Real-ESRGAN ×2). Unos ${u.secPerPage} s por página; el original no se toca.</div>`
|
||||
}
|
||||
if (u.error) body += `<div class="error note">Falló algún capítulo: ${esc(u.error)}</div>`
|
||||
const canAdd = hdCandidates().length > 0
|
||||
const actions = [
|
||||
canAdd ? `<button class="btn small ${hdForm ? 'on' : ''}" id="hdopen" aria-expanded="${!!hdForm}">${icon('sparkles')} ${active ? 'Añadir capítulos…' : 'Reescalar…'}</button>` : '',
|
||||
active ? `<button class="btn small" id="hdcancel">${icon('stop')} Detener</button>` : '',
|
||||
u.hd ? `<button class="btn small danger push" id="hddel">${icon('trash')} Quitar HD</button>` : '',
|
||||
].join('')
|
||||
// El formulario se conserva entre sondeos: solo se repinta la parte de arriba.
|
||||
if (!$('#hdtop')) box.innerHTML = '<div id="hdtop" class="psec-grid"></div><div id="hdform" class="hdform" hidden></div>'
|
||||
$('#hdtop').innerHTML = secHead('sparkles', 'Reescalado HD', status) + body + (actions ? `<div class="psec-actions">${actions}</div>` : '')
|
||||
+ (u.auto ? '<div class="note muted">Los capítulos que se descarguen también se reescalan.</div>' : '')
|
||||
$('#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`
|
||||
@@ -395,23 +566,57 @@ async function viewManga(id, langOverride) {
|
||||
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 renderList = () => {
|
||||
const chapMeta = (c, r, done) => (newSet.has(String(c.num ?? c.id)) ? '<span class="badge">NUEVO</span> ' : '')
|
||||
+ (dlSet.has(c.id) ? `<span title="Descargado">${icon('download')}</span> ` : '')
|
||||
+ (hdSet.has(c.id) ? '<span class="hd" title="Reescalado a HD">HD</span> '
|
||||
: hdJobs[c.id] ? `<span class="hd pending" title="${hdJobs[c.id] === 'error' ? 'Falló el reescalado' : 'En cola para HD'}">HD${hdJobs[c.id] === 'error' ? '!' : '…'}</span> ` : '')
|
||||
+ (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
|
||||
ul.innerHTML = list.map(c => {
|
||||
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 `<li><label class="pick ${ok ? '' : 'off'}" title="${ok ? '' : dlSet.has(c.id) ? 'Ya está en HD o en cola' : 'No está descargado'}">
|
||||
<input type="checkbox" data-i="${i}" data-cid="${c.id}" ${ok ? '' : 'disabled'} ${picked.has(c.id) ? 'checked' : ''}>
|
||||
<span class="t">${esc(chapLabel(c))}</span><span class="meta">${chapMeta(c, r, done)}</span></label></li>`
|
||||
}
|
||||
const cls = progress?.chapter_id === c.id ? 'current' : done ? 'read' : ''
|
||||
const meta = (newSet.has(String(c.num ?? c.id)) ? '<span class="badge">NUEVO</span> ' : '') + (dlSet.has(c.id) ? `<span title="Descargado">${icon('download')}</span> ` : '') + (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group))
|
||||
return `<li><a class="${cls}" href="${readUrl(id, c.id, r && !done ? r.page : (done ? 0 : null))}"><span>${esc(chapLabel(c))}</span><span class="meta">${meta}</span></a></li>`
|
||||
return `<li><a class="${cls}" href="${readUrl(id, c.id, r && !done ? r.page : (done ? 0 : null))}"><span>${esc(chapLabel(c))}</span><span class="meta">${chapMeta(c, r, done)}</span></a></li>`
|
||||
}).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()
|
||||
$('#order')?.addEventListener('click', () => { desc = !desc; store.set('chapterOrderDesc', desc); 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) })
|
||||
}
|
||||
|
||||
@@ -419,10 +624,11 @@ async function viewManga(id, langOverride) {
|
||||
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) {
|
||||
if (['Manhwa', 'Manhua', 'Webtoon'].includes(manga.type)) return { mode: 'vertical', fit: 'width', dir: 'ltr' }
|
||||
if (manga.type === 'Manga') return { mode: 'paged', fit: 'height', dir: 'rtl' }
|
||||
// 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' }
|
||||
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) {
|
||||
@@ -433,7 +639,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
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}`),
|
||||
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 || {}) }
|
||||
@@ -471,16 +677,20 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
<a class="btn small" href="#/manga/${mangaId}" title="Volver a la ficha">${icon('back')}</a>
|
||||
<div class="ttl"><b>${esc(manga.title)}</b></div>
|
||||
<select id="chap" style="max-width:40vw">${chapOpts}</select>
|
||||
<button class="btn small" id="modequick"></button>
|
||||
<button class="btn small" id="ropts" title="Opciones de lectura">${icon('settings')}</button>
|
||||
</div>
|
||||
<div class="rpanel" hidden>
|
||||
<select id="mode" title="Modo de lectura (se recuerda para este manga)">${Object.entries(READ_MODES).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
|
||||
<select id="fit" title="Ajuste (se recuerda para este manga)"><option value="height">Ajustar alto</option><option value="width">Ajustar ancho</option></select>
|
||||
<select id="dir" title="Dirección (se recuerda para este manga)"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select>
|
||||
<label class="row muted" id="gapwrap" style="font-size:.85rem" title="Deja un espacio entre páginas en modo vertical (se recuerda para este manga)"><input type="checkbox" id="gap"> Separar páginas</label>
|
||||
<select id="filter" title="Filtro">${Object.entries(FILTERS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
|
||||
<label class="row muted" style="font-size:.85rem" title="Brillo">☀ <input type="range" id="bright" min="40" max="120" step="5" style="width:90px"></label>
|
||||
${manga.source === 'lc' ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
|
||||
<p class="muted">Modo, ajuste y dirección se recuerdan para este manga. Zoom: pellizca o toca dos veces en el centro (Ctrl+rueda o + / − en PC).</p>
|
||||
${manga.source === 'lc' || chap.local ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
|
||||
${chap.hdAvailable ? '<label class="row muted" style="font-size:.85rem" title="Usar la versión reescalada de este capítulo"><input type="checkbox" id="hd"> Versión HD</label>'
|
||||
: chap.local && info.upscale?.available ? `<button class="btn small" id="hdone" title="Reescala este capítulo con IA en segundo plano (unos minutos)">${icon('sparkles')} Reescalar a HD</button>` : ''}
|
||||
<p class="muted">Modo, ajuste, dirección y separación se recuerdan para este manga. ${icon('scroll')} en la barra de arriba cambia entre páginas y vertical. Zoom: pellizca o toca dos veces en el centro (Ctrl+rueda o + / − en PC).</p>
|
||||
</div>
|
||||
<div id="pages"></div>
|
||||
<div class="rbar bottom">
|
||||
@@ -497,6 +707,15 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
$('#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 = () => {
|
||||
@@ -505,6 +724,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
}
|
||||
applyFilter()
|
||||
if ($('#saver')) $('#saver').checked = settings.saver
|
||||
if ($('#hd')) $('#hd').checked = settings.hd !== false
|
||||
const endBlock = `<div class="chapter-end"><p class="muted">Fin del capítulo ${esc(chapter.num ?? '')}</p>
|
||||
${next ? `<a class="btn primary" href="${readUrl(mangaId, next.id)}">${icon('next')} Siguiente: ${esc(chapLabel(next))}</a>` : '<p>No hay más capítulos disponibles.</p>'}
|
||||
<a class="btn" href="#/manga/${mangaId}">${icon('back')} Volver a la ficha</a></div>`
|
||||
@@ -597,10 +817,13 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
observer?.disconnect()
|
||||
const keep = lastShown()
|
||||
resetZoom()
|
||||
box.className = isPaged() ? `paged ${settings.fit === 'width' ? 'fit-width' : ''} ${isDouble() ? 'double' : ''}` : 'vertical'
|
||||
box.className = isPaged() ? `paged ${settings.fit === 'width' ? 'fit-width' : ''} ${isDouble() ? 'double' : ''}` : `vertical fit-${settings.fit} ${settings.gap ? 'gap' : ''}`
|
||||
renderModeQuick()
|
||||
box.dataset.dir = settings.dir
|
||||
$('#fit').hidden = !isPaged()
|
||||
// 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
|
||||
@@ -743,18 +966,29 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
$('#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 })
|
||||
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 } })
|
||||
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; savePrefs(); render() })
|
||||
$('#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; savePrefs() })
|
||||
$('#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))
|
||||
|
||||
|
||||
@@ -108,6 +108,10 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
.vertical { display: flex; flex-direction: column; align-items: center; }
|
||||
.vertical img { width: 100%; max-width: 900px; min-height: 300px; background: #111; }
|
||||
.vertical img[data-loaded] { min-height: 0; }
|
||||
/* Vertical para mangas: cada página entera en pantalla y separadas */
|
||||
.vertical.fit-height img { width: auto; max-width: 100%; max-height: 100vh; max-height: 100dvh; }
|
||||
.vertical.fit-height img:not([data-loaded]) { width: min(100%, 640px); }
|
||||
.vertical.gap { gap: 14px; }
|
||||
.chapter-end { padding: 40px 16px 120px; text-align: center; display: grid; gap: 12px; justify-items: center; }
|
||||
|
||||
#toast { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); z-index: 50; background: var(--surface-2);
|
||||
@@ -155,6 +159,8 @@ a.tag:hover, .tag.on { background: var(--accent); color: var(--accent-ink); }
|
||||
.paged.zoomed { cursor: grab; }
|
||||
|
||||
/* Insignias y botón Seguir */
|
||||
.hd { display: inline-block; font-size: .65rem; font-weight: 800; letter-spacing: .03em; padding: 0 5px; border-radius: 4px; border: 1px solid var(--accent); color: var(--accent); vertical-align: middle; }
|
||||
.hd.pending { border-style: dashed; color: var(--muted); border-color: var(--muted); }
|
||||
.badge { display: inline-block; font-size: .72rem; font-weight: 700; padding: 1px 7px; border-radius: 999px; background: var(--accent); color: var(--accent-ink); vertical-align: middle; }
|
||||
.btn.on { border-color: var(--accent); color: var(--accent); }
|
||||
h2 .ic { color: var(--accent); }
|
||||
@@ -203,3 +209,65 @@ table.data td:not(:first-child), table.data th:not(:first-child) { text-align: r
|
||||
.rpanel p { margin: 0; font-size: .78rem; }
|
||||
.reader.hide-ui .rpanel { display: none; }
|
||||
.chart .axis { font-size: 12px; }
|
||||
|
||||
/* Ficha: acciones, menú ⋯ y panel de biblioteca local */
|
||||
.detail-body { min-width: 0; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
|
||||
.btn.on { border-color: var(--accent); color: var(--accent); }
|
||||
.facts { display: flex; flex-wrap: wrap; gap: 2px 16px; font-size: .85rem; margin-top: 10px; }
|
||||
.menu { position: relative; }
|
||||
.menu > summary { list-style: none; padding-left: 12px; padding-right: 12px; }
|
||||
.menu > summary::-webkit-details-marker { display: none; }
|
||||
.menu[open] > summary { background: var(--surface-2); }
|
||||
.menu-list { position: absolute; left: 0; top: calc(100% + 6px); z-index: 15; min-width: 230px; display: grid; padding: 6px;
|
||||
background: var(--surface); border: 1px solid var(--surface-2); border-radius: var(--radius); box-shadow: 0 10px 28px rgba(0,0,0,.45); }
|
||||
.menu-list a, .menu-list button { display: flex; align-items: center; gap: 10px; padding: 9px 10px; border: 0; border-radius: 8px;
|
||||
background: none; text-align: left; cursor: pointer; white-space: nowrap; }
|
||||
.menu-list a:hover, .menu-list button:hover { background: var(--surface-2); }
|
||||
.menu-list .danger { color: var(--danger); }
|
||||
|
||||
.libpanel { margin-top: 20px; max-width: 640px; background: var(--surface); border: 1px solid var(--surface-2); border-radius: 12px; }
|
||||
.psec, .psec-grid { display: grid; gap: 10px; }
|
||||
.psec { padding: 14px 16px; }
|
||||
.psec + .psec { border-top: 1px solid var(--surface-2); }
|
||||
.psec-head { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: 4px 16px; }
|
||||
.psec-title { display: flex; align-items: center; gap: 8px; font-weight: 600; }
|
||||
.psec-title .ic { color: var(--accent); }
|
||||
.psec-status { font-size: .85rem; display: inline-flex; align-items: center; gap: 4px; }
|
||||
.psec-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.psec-actions .push { margin-left: auto; }
|
||||
.note { font-size: .83rem; margin: 0; }
|
||||
.psec .dlbar { height: 6px; }
|
||||
.check { display: flex; align-items: center; gap: 8px; font-size: .85rem; color: var(--muted); cursor: pointer; }
|
||||
input[type=checkbox] { accent-color: var(--accent); width: 16px; height: 16px; margin: 0; flex: none; }
|
||||
.btn:disabled { opacity: .45; cursor: not-allowed; filter: none; }
|
||||
|
||||
.hdform { display: grid; gap: 12px; padding: 12px; border-radius: 10px; background: var(--bg); border: 1px solid var(--surface-2); }
|
||||
.seg { display: inline-flex; flex-wrap: wrap; gap: 2px; padding: 3px; justify-self: start; border-radius: 10px; background: var(--surface); border: 1px solid var(--surface-2); }
|
||||
.seg button { border: 0; background: none; padding: 5px 12px; border-radius: 8px; cursor: pointer; color: var(--muted); font-size: .88rem; }
|
||||
.seg button.on { background: var(--surface-2); color: var(--text); }
|
||||
.range label { display: inline-flex; align-items: center; gap: 8px; font-size: .9rem; }
|
||||
.range select { padding: 6px 10px; }
|
||||
.sum { font-size: .9rem; font-weight: 600; }
|
||||
|
||||
.list-head { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin: 28px 0 12px; }
|
||||
.list-head h2 { margin: 0; }
|
||||
.list-head select { padding: 5px 10px; font-size: .88rem; }
|
||||
.chapters label.pick { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 8px; background: var(--surface); cursor: pointer; user-select: none; }
|
||||
.chapters label.pick:hover { background: var(--surface-2); }
|
||||
.chapters label.pick .t { flex: 1; min-width: 0; }
|
||||
.chapters label.pick.off { opacity: .4; cursor: default; }
|
||||
.chapters label.pick:has(input:checked) { outline: 1px solid var(--accent); }
|
||||
#selbar { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 25; display: flex; align-items: center; gap: 10px;
|
||||
max-width: calc(100% - 32px); padding: 8px 8px 8px 16px; border-radius: 12px; background: var(--surface-2); border: 1px solid #3a3548;
|
||||
box-shadow: 0 10px 28px rgba(0,0,0,.5); font-size: .9rem; }
|
||||
#selbar span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chapters.picking { padding-bottom: 72px; }
|
||||
@media (max-width: 640px) {
|
||||
.actions .btn.primary { flex: 1 1 100%; }
|
||||
.libpanel { max-width: none; }
|
||||
.psec-actions .push { margin-left: 0; }
|
||||
.seg { display: grid; grid-template-columns: repeat(3, 1fr); justify-self: stretch; }
|
||||
#selbar { left: 12px; right: 12px; transform: none; max-width: none; }
|
||||
#selbar span { flex: 1; white-space: normal; font-size: .82rem; line-height: 1.3; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user