- Info: autores, revista y editorial, fechas, tomos, puntuaciones, rankings, anime, obras relacionadas, recomendaciones y enlaces. La obra se identifica por los enlaces de MangaDex o por título. - Fechas de los capítulos: primera traducción conocida (MangaDex en cualquier idioma y MangaUpdates), ordenadas y con los huecos estimados (≈); se ignoran las fechas de subidas masivas de las fuentes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2525 lines
153 KiB
JavaScript
2525 lines
153 KiB
JavaScript
const app = document.getElementById('app')
|
||
// Iconos SVG en línea (trazo estilo Lucide), heredan el color del texto.
|
||
const ICONS = {
|
||
home: '<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/><path d="M10 21v-6h4v6"/>',
|
||
history: '<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 2"/>',
|
||
download: '<path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/>',
|
||
settings: '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h11M19 18h1"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>',
|
||
play: '<path d="M7 4v16l13-8z"/>',
|
||
book: '<path d="M2 5h6a4 4 0 0 1 4 4v12a3 3 0 0 0-3-3H2z"/><path d="M22 5h-6a4 4 0 0 0-4 4v12a3 3 0 0 1 3-3h7z"/>',
|
||
stopRead: '<circle cx="12" cy="12" r="9"/><path d="m15 9-6 6M9 9l6 6"/>',
|
||
stop: '<rect x="6" y="6" width="12" height="12" rx="1.5"/>',
|
||
trash: '<path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M6 6l1 15h10l1-15"/><path d="M10 11v6M14 11v6"/>',
|
||
refresh: '<path d="M21 12a9 9 0 1 1-2.6-6.4L21 8"/><path d="M21 3v5h-5"/>',
|
||
search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
|
||
sort: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
|
||
back: '<path d="M19 12H5"/><path d="m12 19-7-7 7-7"/>',
|
||
prev: '<path d="m15 18-6-6 6-6"/>',
|
||
next: '<path d="m9 18 6-6-6-6"/>',
|
||
more: '<path d="m6 9 6 6 6-6"/>',
|
||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||
login: '<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><path d="m10 17 5-5-5-5"/><path d="M15 12H3"/>',
|
||
logout: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/>',
|
||
userPlus: '<circle cx="9" cy="8" r="4"/><path d="M2 21a7 7 0 0 1 14 0"/><path d="M19 8v6M16 11h6"/>',
|
||
flame: '<path d="M12 22c3.9 0 7-2.8 7-7 0-3.5-2.4-5.6-3.6-8.6-.9 1.8-1.9 2.9-3 3.2.2-3.1-.9-5.6-3-7.6.3 4-4.4 6.8-4.4 12.6C5 19.2 8.1 22 12 22z"/>',
|
||
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
||
grid: '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>',
|
||
wifiOff: '<path d="M2 2l20 20"/><path d="M8.5 16.4a5 5 0 0 1 7 0"/><path d="M5 12.9a10 10 0 0 1 5.2-2.8"/><path d="M14.4 10.2A10 10 0 0 1 19 12.9"/><path d="M2 8.8a15 15 0 0 1 4.2-2.7"/><path d="M10.7 5A15 15 0 0 1 22 8.8"/><path d="M12 20h.01"/>',
|
||
globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18z"/>',
|
||
key: '<circle cx="8" cy="15" r="4"/><path d="m11 12 9-9M17 6l3 3"/>',
|
||
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.9 1.9 0 0 0 3.4 0"/>',
|
||
bellOff: '<path d="M8.7 3A6 6 0 0 1 18 8a21 21 0 0 0 .6 5"/><path d="M17 17H3s3-2 3-9a4.7 4.7 0 0 1 .3-2"/><path d="M10.3 21a1.9 1.9 0 0 0 3.4 0"/><path d="m2 2 20 20"/>',
|
||
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"/>',
|
||
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"/>',
|
||
library: '<path d="M4 4v16M9 4v16"/><path d="m14 4.5 4.5 15.2"/><path d="M3 20h18"/>',
|
||
bookmark: '<path d="M6 3h12v18l-6-4-6 4z"/>',
|
||
phone: '<rect x="6" y="2" width="12" height="20" rx="2.5"/><path d="M11 18h2"/>',
|
||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||
pencil: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13.5 6.5 4 4"/>',
|
||
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v6"/><path d="M12 7.5h.01"/>',
|
||
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>`
|
||
// Iconos del menú superior (index.html).
|
||
document.querySelectorAll('#topbar nav a[data-icon]').forEach(a => { a.innerHTML = `${icon(a.dataset.icon)}<span>${a.textContent}</span>` })
|
||
|
||
const SOURCES = { lc: 'LeerCapitulo (español)', mi: 'M440 (español)', md: 'MangaDex (multi-idioma)' }
|
||
const NO_CATS = new Set(['mi']) // fuentes sin listado por categorías
|
||
// 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 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 numKey = n => { const f = parseFloat(n); return Number.isFinite(f) ? String(f) : null }
|
||
const fmtDate = d => {
|
||
const t = new Date(`${String(d).slice(0, 10)}T12:00:00`)
|
||
return Number.isNaN(t.getTime()) ? '' : t.toLocaleDateString('es', { day: 'numeric', month: 'short', year: 'numeric' })
|
||
}
|
||
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)
|
||
// Portadas que no cargan: icono de la app (la CSP no permite onerror="…" en el HTML).
|
||
document.addEventListener('error', e => {
|
||
const img = e.target
|
||
if (img.tagName !== 'IMG' || !img.hasAttribute('data-fallback') || img.dataset.failed) return
|
||
img.dataset.failed = '1'
|
||
img.src = '/icon.svg'
|
||
}, true)
|
||
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)
|
||
}
|
||
const NO_SERVER = 'Sin conexión con el servidor'
|
||
async function api(path, opts = {}) {
|
||
// Si tarda, se comprueba si el servidor sigue ahí (una fuente lenta no es motivo para cortar, un
|
||
// servidor que no contesta sí): así nada se queda en «Cargando…» para siempre sin red.
|
||
const ctrl = new AbortController()
|
||
const watchdog = setTimeout(async () => { if (!(await pingServer())) ctrl.abort() }, 2500)
|
||
let res
|
||
try {
|
||
res = await fetch(path, {
|
||
...opts,
|
||
signal: ctrl.signal,
|
||
headers: opts.body ? { 'Content-Type': 'application/json' } : undefined,
|
||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||
})
|
||
} catch {
|
||
setServerReachable(false)
|
||
throw new Error(NO_SERVER)
|
||
} finally {
|
||
clearTimeout(watchdog)
|
||
}
|
||
// Respuesta de la copia del service worker: el servidor no contestó a tiempo; comprobar el estado.
|
||
if (res.headers.get('x-from-cache')) refreshNet()
|
||
else if (!serverReachable) setServerReachable(true)
|
||
const data = await res.json().catch(() => ({}))
|
||
if (res.status === 401 && !path.startsWith('/api/login')) { me = null; location.hash = '#/login'; throw new Error('No autenticado') }
|
||
// 503: el servidor ya sabe que no hay internet. 502: una fuente falló; el servidor tarda unos segundos en confirmarlo.
|
||
if (res.status === 503) refreshNet()
|
||
else if (res.status === 502) setTimeout(refreshNet, 7000)
|
||
if (!res.ok) throw new Error(data.error || `Error ${res.status}`)
|
||
return data
|
||
}
|
||
|
||
// ---------- Estado de la conexión ----------
|
||
// Dos casos distintos, con su aviso arriba:
|
||
// - serverReachable = false: no se llega al servidor (sin red, servidor caído). Se lee lo guardado en
|
||
// este dispositivo; la portada lo muestra. Al volver, se envía el progreso pendiente.
|
||
// - serverOnline = false: el servidor contesta pero él no tiene internet. Se lee lo descargado en el
|
||
// servidor; la portada lo muestra.
|
||
// Se comprueba cada 20 s (cada 10 s sin servidor) y al volver a la pestaña.
|
||
let serverOnline = true
|
||
let serverReachable = true
|
||
let netChecked = false
|
||
const isHome = () => !(location.hash.slice(1).split('?')[0] || '/').split('/').filter(Boolean).length
|
||
async function pingServer() {
|
||
const r = await fetch('/api/status', { cache: 'no-store', signal: AbortSignal.timeout(3000) }).catch(() => null)
|
||
return r ? (r.ok ? await r.json().catch(() => ({ online: true })) : { online: true }) : null
|
||
}
|
||
function netBanner() {
|
||
let b = $('#netbanner')
|
||
const msg = !serverReachable
|
||
? `${icon('wifiOff')}<span><b>${NO_SERVER}.</b> ${OFFLINE_OK ? 'Puedes leer lo guardado en este dispositivo; ' : ''}se reconecta solo.</span>`
|
||
: !serverOnline ? `${icon('wifiOff')}<span><b>El servidor no tiene internet.</b> Puedes leer lo descargado en el servidor; se reconecta solo.</span>` : ''
|
||
if (!msg) return b?.remove()
|
||
if (!b) { b = document.createElement('div'); b.id = 'netbanner'; $('#topbar').after(b) }
|
||
if (b.innerHTML !== msg) b.innerHTML = msg
|
||
}
|
||
function setServerReachable(ok) {
|
||
if (ok === serverReachable) return
|
||
serverReachable = ok
|
||
netBanner()
|
||
if (ok) { flushProgress(); refreshNet() }
|
||
if (isHome()) router()
|
||
}
|
||
async function refreshNet() {
|
||
const st = await pingServer()
|
||
netChecked = true
|
||
if (!st) return setServerReachable(false)
|
||
const changed = !serverReachable || st.online !== serverOnline
|
||
serverOnline = st.online !== false
|
||
if (!serverReachable) return setServerReachable(true) // ya redibuja y vuelve a llamar
|
||
netBanner()
|
||
if (changed && isHome()) router()
|
||
}
|
||
setInterval(() => { if (serverReachable) refreshNet() }, 20e3)
|
||
setInterval(() => { if (!serverReachable) refreshNet() }, 10e3)
|
||
document.addEventListener('visibilitychange', () => { if (!document.hidden) refreshNet() })
|
||
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}` : ''}`
|
||
|
||
// ---------- Sin conexión (capítulos guardados en este dispositivo) ----------
|
||
// Requiere service worker, que el navegador solo permite con HTTPS (o en localhost).
|
||
const OFFLINE_OK = 'serviceWorker' in navigator && window.isSecureContext && 'caches' in window
|
||
const OFF_CACHE = 'offline-v1'
|
||
// Con versión en la URL: si un proxy guarda sw.js en caché, cada versión nueva tiene otra URL.
|
||
if (OFFLINE_OK) navigator.serviceWorker.register(`/sw.js?v=${document.querySelector('meta[name="sw-version"]')?.content || ''}`).catch(() => {})
|
||
const offIdx = () => store.get('offlineChapters', {}) // chapterId -> { mangaId, mangaTitle, cover, lang, label, num, pages, bytes, at }
|
||
const offHas = id => !!offIdx()[id]
|
||
const offState = { running: null, queue: [], listeners: new Set(), cancelled: new Set(), abort: null }
|
||
const offEmit = () => offState.listeners.forEach(f => { try { f() } catch {} })
|
||
// Capítulos guardados agrupados por manga.
|
||
function offByManga() {
|
||
const g = new Map()
|
||
for (const [chapterId, c] of Object.entries(offIdx())) {
|
||
const m = g.get(c.mangaId) || { mangaId: c.mangaId, title: c.mangaTitle, cover: c.cover, chapters: [], bytes: 0, at: 0 }
|
||
m.chapters.push({ chapterId, ...c }); m.bytes += c.bytes || 0; m.at = Math.max(m.at, c.at)
|
||
g.set(c.mangaId, m)
|
||
}
|
||
return [...g.values()].sort((a, b) => b.at - a.at)
|
||
}
|
||
// items: [{ mangaId, mangaTitle, cover, lang, chapterId, label, num }]
|
||
function offEnqueue(items) {
|
||
for (const it of items) if (!offHas(it.chapterId) && !offState.queue.some(q => q.chapterId === it.chapterId) && offState.running?.chapterId !== it.chapterId) offState.queue.push(it)
|
||
navigator.storage?.persist?.().catch(() => {})
|
||
offEmit()
|
||
offRun()
|
||
}
|
||
async function offRun() {
|
||
if (offState.running) return
|
||
while (offState.queue.length) {
|
||
const it = offState.queue.shift()
|
||
offState.running = { ...it, done: 0, total: 0 }
|
||
offEmit()
|
||
try { await offSaveChapter(it) } catch (err) { if (!offState.cancelled.has(it.chapterId)) toast(`No se pudo guardar ${it.label}: ${err.message}`) }
|
||
offState.cancelled.delete(it.chapterId)
|
||
}
|
||
offState.running = null
|
||
offEmit()
|
||
}
|
||
// Cancela lo que quede por guardar de un manga (o todo): lo pendiente sale de la cola y el capítulo en
|
||
// curso se interrumpe y se borra lo que ya se había guardado de él.
|
||
function offCancel(mangaId = null) {
|
||
offState.queue = offState.queue.filter(q => mangaId && q.mangaId !== mangaId)
|
||
if (offState.running && (!mangaId || offState.running.mangaId === mangaId)) {
|
||
offState.cancelled.add(offState.running.chapterId)
|
||
offState.abort?.abort() // corta las páginas que se estén bajando
|
||
}
|
||
offEmit()
|
||
}
|
||
async function offSaveChapter(it) {
|
||
const cache = await caches.open(OFF_CACHE)
|
||
const saved = []
|
||
offState.abort = new AbortController()
|
||
try { await offSaveInto(cache, it, saved, offState.abort.signal) } catch (err) {
|
||
await Promise.all(saved.map(u => cache.delete(u, { ignoreSearch: false }))) // nada de capítulos a medias
|
||
throw err
|
||
}
|
||
}
|
||
async function offSaveInto(cache, it, saved, signal) {
|
||
const stop = () => { if (offState.cancelled.has(it.chapterId)) throw new Error('cancelado') }
|
||
const hd = store.get('readerSettings', {}).hd !== false
|
||
const chapUrl = `/api/chapter/${it.chapterId}?saver=0&hd=${hd ? 1 : 0}`
|
||
const res = await fetch(chapUrl, { signal })
|
||
if (!res.ok) throw new Error(`Error ${res.status}`)
|
||
const data = await res.clone().json()
|
||
await cache.put(chapUrl, res)
|
||
saved.push(chapUrl)
|
||
const mUrl = `/api/manga/${it.mangaId}?lang=${encodeURIComponent(it.lang || '')}`
|
||
const mres = await fetch(mUrl).catch(() => null)
|
||
if (mres?.ok) await cache.put(mUrl, mres)
|
||
if (it.cover) { const c = await fetch(it.cover).catch(() => null); if (c?.ok) await cache.put(it.cover, c) }
|
||
offState.running.total = data.pages.length
|
||
let bytes = 0, i = 0
|
||
const worker = async () => {
|
||
while (i < data.pages.length) {
|
||
stop()
|
||
const u = data.pages[i++]
|
||
// Hasta 3 intentos por página (las fuentes fallan a ratos), cada uno con tiempo límite.
|
||
let blob, type
|
||
for (let t = 1; ; t++) {
|
||
try {
|
||
const r = await fetch(u, { signal: AbortSignal.any([signal, AbortSignal.timeout(90e3)]) })
|
||
if (!r.ok) throw new Error(`página ${r.status}`)
|
||
type = r.headers.get('Content-Type')
|
||
blob = await r.blob()
|
||
break
|
||
} catch (err) {
|
||
stop()
|
||
if (t >= 3) throw err
|
||
await new Promise(res => setTimeout(res, 2000 * t))
|
||
}
|
||
}
|
||
bytes += blob.size
|
||
stop()
|
||
await cache.put(u, new Response(blob, { headers: { 'Content-Type': type || 'image/jpeg' } }))
|
||
saved.push(u)
|
||
offState.running.done++
|
||
offEmit()
|
||
}
|
||
}
|
||
await Promise.all([worker(), worker(), worker()])
|
||
stop()
|
||
const idx = offIdx()
|
||
idx[it.chapterId] = { mangaId: it.mangaId, mangaTitle: it.mangaTitle, cover: it.cover, lang: it.lang, label: it.label, num: it.num ?? null, pages: data.pages.length, bytes, at: Date.now() }
|
||
store.set('offlineChapters', idx)
|
||
}
|
||
async function offRemove(chapterIds) {
|
||
const cache = await caches.open(OFF_CACHE)
|
||
const idx = offIdx()
|
||
for (const id of chapterIds) {
|
||
const hit = await cache.match(`/api/chapter/${id}`, { ignoreSearch: true })
|
||
if (hit) {
|
||
const data = await hit.json().catch(() => ({ pages: [] }))
|
||
await Promise.all((data.pages || []).map(u => cache.delete(u)))
|
||
await cache.delete(`/api/chapter/${id}`, { ignoreSearch: true })
|
||
}
|
||
const mangaId = idx[id]?.mangaId
|
||
delete idx[id]
|
||
if (mangaId && !Object.values(idx).some(c => c.mangaId === mangaId)) await cache.delete(`/api/manga/${mangaId}`, { ignoreSearch: true })
|
||
}
|
||
store.set('offlineChapters', idx)
|
||
offEmit()
|
||
}
|
||
|
||
// Progreso leído sin conexión: se guarda aquí y se envía al volver la red.
|
||
function queueProgress(p) { const q = store.get('pendingProgress', {}); q[p.mangaId] = p; store.set('pendingProgress', q) }
|
||
async function flushProgress() {
|
||
const q = store.get('pendingProgress', {})
|
||
for (const [k, p] of Object.entries(q)) {
|
||
try { await api('/api/progress', { method: 'POST', body: p }); delete q[k] } catch { if (!navigator.onLine) break; delete q[k] } // con red y error: descartarlo
|
||
}
|
||
store.set('pendingProgress', q)
|
||
}
|
||
window.addEventListener('online', flushProgress)
|
||
setTimeout(flushProgress, 3000)
|
||
// Instalar como app (Chrome/Edge/Android ofrecen el aviso; se guarda para el botón de Ajustes).
|
||
let installPrompt = null
|
||
window.addEventListener('beforeinstallprompt', e => { e.preventDefault(); installPrompt = e })
|
||
|
||
// ---------- Router ----------
|
||
let prevHash = '', curHash = '' // para volver atrás sin pasar por el lector
|
||
async function router() {
|
||
if (location.hash !== curHash) { prevHash = curHash; curHash = location.hash }
|
||
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 Promise.all([api('/api/me').catch(() => ({})), netChecked ? null : refreshNet()])
|
||
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] === 'info') return await viewInfo(parts[1])
|
||
if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1)
|
||
if (parts[0] === 'history') return (location.hash = '#/library') // antiguo nombre
|
||
if (parts[0] === 'library') return await viewLibrary(params)
|
||
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()
|
||
return await viewHome(params)
|
||
} catch (e) {
|
||
if (e.message !== 'No autenticado') app.innerHTML = `<p class="error">${esc(e.message)}</p>`
|
||
}
|
||
}
|
||
window.addEventListener('hashchange', router)
|
||
router()
|
||
|
||
// ---------- Login / registro ----------
|
||
function viewAuth(signup) {
|
||
app.innerHTML = `
|
||
<div class="auth">
|
||
<h1>Manga Reader</h1>
|
||
<p class="muted">Inicia sesión para leer y guardar tu progreso.</p>
|
||
<form>
|
||
<input type="text" name="username" placeholder="Usuario" autocomplete="username" required>
|
||
<input type="password" name="password" placeholder="Contraseña" autocomplete="current-password" required>
|
||
<p class="error"></p>
|
||
<button class="btn primary" value="login">${icon('login')} Entrar</button>
|
||
${signup ? `<button class="btn" value="register">${icon('userPlus')} Crear cuenta</button>` : ''}
|
||
</form>
|
||
</div>`
|
||
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 `<a class="card" href="#/manga/${m.id}">
|
||
<img class="cover" loading="lazy" src="${esc(m.cover || '/icon.svg')}" alt="" data-fallback>
|
||
<div class="t">${esc(m.title)}</div>${m.sub ? `<div class="sub">${esc(m.sub)}</div>` : ''}</a>`
|
||
}
|
||
function continueCard(h) {
|
||
const pct = h.total_pages ? Math.round(((h.page + 1) / h.total_pages) * 100) : 0
|
||
// Terminado con capítulos nuevos: a la ficha, para elegir el siguiente.
|
||
return `<a class="cont" href="${h.hasNew ? `#/manga/${h.manga_id}` : readUrl(h.manga_id, h.chapter_id, h.page)}">
|
||
<img src="${esc(h.cover || '/icon.svg')}" alt="" loading="lazy" data-fallback>
|
||
<div class="info"><b>${esc(h.manga_title)}</b>
|
||
<div class="muted">${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · pág. ${h.page + 1}/${h.total_pages || '?'}</div>
|
||
${h.finished ? '<div><span class="badge">Terminado</span></div>' : h.hasNew ? '<div><span class="badge">Caps. nuevos</span></div>' : ''}
|
||
<div class="muted" style="font-size:.8rem">${timeAgo(h.updated_at)}</div>
|
||
<div class="bar"><i style="width:${pct}%"></i></div></div></a>`
|
||
}
|
||
// Sección "Novedades": mangas seguidos con capítulos nuevos.
|
||
function newsSection(fol) {
|
||
const fresh = fol.follows.filter(f => f.newCount > 0)
|
||
const status = fol.checking ? 'Comprobando…' : fol.lastCheckAt ? `Última comprobación ${timeAgo(fol.lastCheckAt)}` : `Se comprueba cada ${fol.intervalHours} h`
|
||
if (!fol.follows.length) return ''
|
||
return `<div class="row" style="justify-content:space-between;margin-top:8px">
|
||
<h2 style="margin:12px 0">${icon('sparkles')} Novedades ${fresh.length ? `<span class="badge">${fresh.reduce((n, f) => n + f.newCount, 0)}</span>` : ''}</h2>
|
||
<span class="row muted" style="font-size:.85rem">Sigues ${fol.follows.length} manga${fol.follows.length === 1 ? '' : 's'} · ${status}
|
||
<button class="btn small" id="checknow" ${fol.checking ? 'disabled' : ''}>${icon('refresh')} Comprobar ahora</button></span></div>
|
||
${fresh.length ? `<div class="continue">${fresh.map(f => `<a class="cont" href="#/manga/${f.manga_id}">
|
||
<img src="${esc(f.cover || '/icon.svg')}" alt="" loading="lazy">
|
||
<div class="info"><b>${esc(f.title)}</b>
|
||
<div><span class="badge">${f.newCount} ${f.newCount === 1 ? 'cap. nuevo' : 'caps. nuevos'}</span></div>
|
||
<div class="muted" style="font-size:.8rem">${f.last_new_at ? timeAgo(f.last_new_at) : ''}</div></div></a>`).join('')}</div>`
|
||
: '<p class="muted" style="margin-top:0">No hay capítulos nuevos en los mangas que sigues.</p>'}`
|
||
}
|
||
function bindNews() {
|
||
$('#checknow')?.addEventListener('click', async e => {
|
||
e.target.closest('button').disabled = true
|
||
await api('/api/follows/check', { method: 'POST' })
|
||
toast('Comprobando capítulos nuevos…')
|
||
// Refrescar la sección mientras dura la comprobación.
|
||
const poll = async () => {
|
||
const fol = await api('/api/follows').catch(() => null)
|
||
if (!fol || !$('#news')) return
|
||
$('#news').innerHTML = newsSection(fol)
|
||
bindNews()
|
||
if (fol.checking) setTimeout(poll, 3000)
|
||
else toast('Comprobación terminada')
|
||
}
|
||
setTimeout(poll, 2000)
|
||
})
|
||
}
|
||
const tagHref = (source, t) => `#/?source=${source}&tag=${encodeURIComponent(t.id)}&tagName=${encodeURIComponent(t.name)}`
|
||
// Recomendaciones según los géneros que más lees (las calcula el servidor, caché de 6 h).
|
||
async function loadRecs(refresh = false) {
|
||
if (!$('#recs')) return
|
||
const r = await api(`/api/recommendations${refresh ? '?refresh=1' : ''}`).catch(() => null)
|
||
// La portada pudo redibujarse mientras tanto: usar la sección actual.
|
||
const box = $('#recs')
|
||
if (!box) return
|
||
if (!r?.items?.length) { box.innerHTML = ''; return }
|
||
box.innerHTML = `<div class="row rhead"><h2>${icon('sparkles')} Recomendado para ti</h2>
|
||
<span class="muted small">Según lo que más lees: ${r.genres.map(g => `<a href="${tagHref(g.source || 'md', g)}">${esc(g.name)}</a>`).join(', ')}</span>
|
||
<button class="btn small" id="recref" title="Otras recomendaciones">${icon('refresh')}</button></div>
|
||
<div class="recrow">${r.items.map(mangaCard).join('')}</div>`
|
||
$('#recref', box).addEventListener('click', e => { e.currentTarget.disabled = true; loadRecs(true) })
|
||
}
|
||
// Portada sin internet en el servidor: lo descargado en el servidor, con buscador local.
|
||
async function viewHomeOffline(params) {
|
||
app.innerHTML = `
|
||
<section id="cont"></section>
|
||
<h2>${icon('download')} Descargado en el servidor</h2>
|
||
<form class="search"><input type="search" name="q" placeholder="Buscar en lo descargado…" value="${esc(params.get('q') || '')}" autocomplete="off"></form>
|
||
<div id="results" class="loading">Cargando…</div>`
|
||
const [list, hist] = await Promise.all([api('/api/search?q='), api('/api/history').catch(() => [])])
|
||
const have = new Set(list.map(m => m.id))
|
||
const cont = hist.filter(h => !h.finished && have.has(h.manga_id))
|
||
if (cont.length) $('#cont').innerHTML = `<h2>Continuar leyendo</h2><div class="continue">${cont.slice(0, 12).map(continueCard).join('')}</div>`
|
||
const res = $('#results')
|
||
const fold = t => String(t).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||
const render = () => {
|
||
const words = fold($('[name=q]').value).split(/\s+/).filter(Boolean)
|
||
const shown = list.filter(m => words.every(w => fold(m.title).includes(w)))
|
||
res.className = shown.length ? 'grid' : 'loading'
|
||
res.innerHTML = shown.length ? shown.map(mangaCard).join('') : list.length ? 'Nada descargado con ese nombre.' : 'No hay nada descargado en el servidor todavía.'
|
||
}
|
||
$('form.search').addEventListener('submit', e => e.preventDefault())
|
||
$('[name=q]').addEventListener('input', render)
|
||
render()
|
||
}
|
||
|
||
// Portada sin conexión con el servidor: lo guardado en este dispositivo.
|
||
function viewHomeDevice() {
|
||
const dev = OFFLINE_OK ? offByManga() : []
|
||
app.innerHTML = `<h2>${icon('phone')} En este dispositivo</h2>` + (dev.length
|
||
? `<div class="grid">${dev.map(m => mangaCard({ id: m.mangaId, title: m.title, cover: m.cover, sub: `${plural(m.chapters.length, 'capítulo')} guardado${m.chapters.length === 1 ? '' : 's'}` })).join('')}</div>`
|
||
: `<p class="muted">${OFFLINE_OK ? 'No hay capítulos guardados en este dispositivo. Con conexión, usa «Guardar en el dispositivo» en la ficha o en el lector para leer sin internet.'
|
||
: 'Leer sin conexión necesita abrir la app por HTTPS.'}</p>`)
|
||
}
|
||
|
||
async function viewHome(params) {
|
||
if (!serverReachable) return viewHomeDevice()
|
||
if (!serverOnline) return viewHomeOffline(params)
|
||
const q = params.get('q') || ''
|
||
const tag = params.get('tag') || ''
|
||
const tagName = params.get('tagName') || tag
|
||
const anyLang = params.get('any') === '1'
|
||
const source = SOURCES[params.get('source')] ? params.get('source') : store.get('source', 'lc')
|
||
let mode = tag ? 'cats' : params.get('mode') || 'popular'
|
||
if (mode === 'cats' && NO_CATS.has(source)) mode = 'popular'
|
||
store.set('source', source)
|
||
const showCats = mode === 'cats' && !tag
|
||
app.innerHTML = `
|
||
<section id="cont"></section>
|
||
<section id="recs"></section>
|
||
<div class="tabs" id="sources">${Object.entries(SOURCES).map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}" title="${esc(v)}">${esc(v.replace(/\s*\(.*\)$/, ''))}</button>`).join('')}</div>
|
||
<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">${icon('search')}<span class="lbl">Buscar</span></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 && !tag ? `<h2>Resultados para “${esc(q)}”</h2>` : `<div class="tabs" id="modes">
|
||
<button data-m="popular" class="${mode === 'popular' ? 'on' : ''}">${icon('flame')} Populares</button>
|
||
<button data-m="latest" class="${mode === 'latest' ? 'on' : ''}">${icon('clock')} Actualizados recientemente</button>
|
||
${NO_CATS.has(source) ? '' : `<button data-m="cats" class="${mode === 'cats' ? 'on' : ''}">${icon('grid')} 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>${icon('more')} 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 }), ...(tag ? { tag, tagName } : {}), ...($('[name=any]').checked ? { any: '1' } : {}), ...extra })
|
||
location.hash = `#/?${p}`
|
||
}
|
||
form.addEventListener('submit', e => { e.preventDefault(); go() })
|
||
$('[name=any]').addEventListener('change', () => go())
|
||
app.querySelectorAll('#modes button').forEach(b => b.addEventListener('click', () => { location.hash = `#/?source=${source}&mode=${b.dataset.m}${anyLang ? '&any=1' : ''}` }))
|
||
app.querySelectorAll('#sources button').forEach(b => b.addEventListener('click', () => { location.hash = `#/?source=${b.dataset.s}&mode=${mode}${anyLang ? '&any=1' : ''}` }))
|
||
|
||
if (!q && !tag && !showCats) {
|
||
Promise.all([api('/api/history'), api('/api/follows')]).then(([hist, fol]) => {
|
||
if (!$('#cont')) return
|
||
$('#cont').innerHTML = `<div id="news">${newsSection(fol)}</div>` + (hist = hist.filter(h => !h.finished), hist.length ? `<h2>Continuar leyendo</h2><div class="continue">${hist.slice(0, 12).map(continueCard).join('')}</div>` : '')
|
||
bindNews(fol)
|
||
}).catch(() => {})
|
||
loadRecs()
|
||
}
|
||
|
||
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]) => `<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; M440 en "Actualizados" (sus otros listados no paginan).
|
||
const pageable = source === 'md' || !!tag || (source === 'mi' && mode === 'latest' && !q)
|
||
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 ----------
|
||
// ---------- Info: datos de la obra de AniList y MangaUpdates ----------
|
||
const AL_FORMAT = { MANGA: 'Manga', NOVEL: 'Novela ligera', ONE_SHOT: 'Oneshot', Manga: 'Manga', Manhwa: 'Manhwa', Manhua: 'Manhua' }
|
||
const AL_STATUS = { FINISHED: 'Terminado', RELEASING: 'En publicación', NOT_YET_RELEASED: 'Sin estrenar', CANCELLED: 'Cancelado', HIATUS: 'En pausa' }
|
||
const AL_COUNTRY = { JP: 'Japón', KR: 'Corea del Sur', CN: 'China', TW: 'Taiwán' }
|
||
const AL_ROLE = r => r.replace(/\bchs?\b/g, 'caps.').replace(/^Story & Art/i, 'Historia y dibujo').replace(/^Original Creator/i, 'Creador original').replace(/^Original Story/i, 'Historia original').replace(/^Story/i, 'Historia').replace(/^Art/i, 'Dibujo')
|
||
const AL_REL = { SEQUEL: 'Secuela', PREQUEL: 'Precuela', SIDE_STORY: 'Historia paralela', SPIN_OFF: 'Spin-off', ALTERNATIVE: 'Versión alternativa', PARENT: 'Obra principal', ADAPTATION: 'Adaptación', SOURCE: 'Original', CHARACTER: 'Personajes', SUMMARY: 'Resumen', COMPILATION: 'Recopilación', CONTAINS: 'Contiene', OTHER: 'Relacionado' }
|
||
const AL_ANIME = { TV: 'Serie TV', TV_SHORT: 'Serie corta', MOVIE: 'Película', OVA: 'OVA', ONA: 'ONA', SPECIAL: 'Especial', MUSIC: 'Vídeo musical' }
|
||
const fmtFuzzy = d => (!d ? '' : d.length === 4 ? d : d.length === 7 ? new Date(`${d}-15T12:00:00`).toLocaleDateString('es', { month: 'long', year: 'numeric' }) : fmtDate(d))
|
||
|
||
async function viewInfo(id) {
|
||
if (cleanup) { cleanup(); cleanup = null }
|
||
app.innerHTML = '<div class="loading">Buscando información de la obra…</div>'
|
||
const d = await api(`/api/meta/${id}`)
|
||
const src = d.source || {}
|
||
const back = `<a class="btn small" href="#/manga/${id}">${icon('back')} Volver</a>`
|
||
if (!d.found) {
|
||
app.innerHTML = `<div class="info-page">${back}<h1>${esc(src.title || '')}</h1>
|
||
<p class="muted">No se encontró esta obra en AniList ni en MangaUpdates. Puede tener otro título allí: prueba a buscarla en
|
||
<a class="tag" href="https://anilist.co/search/manga?search=${encodeURIComponent(src.title || '')}" target="_blank" rel="noopener">AniList</a>.</p></div>`
|
||
return
|
||
}
|
||
const row = (k, v) => (v ? `<dt>${k}</dt><dd>${v}</dd>` : '')
|
||
const list = a => a.map(esc).join(', ')
|
||
const t = d.titles
|
||
const people = d.staff.reduce((m, p) => m.set(p.name, [...(m.get(p.name) || []), AL_ROLE(p.role)]), new Map())
|
||
const dates = d.start ? `${fmtFuzzy(d.start)}${d.end ? ` – ${fmtFuzzy(d.end)}` : d.status === 'RELEASING' ? ' – actualidad' : ''}` : ''
|
||
const size = [d.volumes ? `${d.volumes} tomos` : '', d.chapters ? `${d.chapters} capítulos` : d.latestChapter ? `${d.latestChapter} capítulos (hasta ahora)` : ''].filter(Boolean).join(' · ')
|
||
const ranks = d.rankings.map(r => `#${r.rank} ${r.type === 'RATED' ? 'mejor valorado' : 'más popular'}${r.allTime ? ' de siempre' : ` de ${r.year}`}`)
|
||
const search = title => `#/?q=${encodeURIComponent(title)}`
|
||
const cardOf = r => `<a class="card" href="${search(r.title)}" title="Buscar «${esc(r.title)}»">
|
||
<img class="cover" loading="lazy" src="${esc(r.cover || '/icon.svg')}" alt="" data-fallback>
|
||
<div class="t">${esc(r.title)}</div><div class="sub">${esc([r.relation ? AL_REL[r.relation] || r.relation : '', r.year || '', r.score ? `${r.score}%` : ''].filter(Boolean).join(' · '))}</div></a>`
|
||
const srcDesc = (src.description || '').trim()
|
||
app.innerHTML = `
|
||
<div class="info-page">
|
||
${d.banner ? `<div class="banner" style="background-image:url('${esc(d.banner)}')"></div>` : ''}
|
||
<div class="row info-top">${back}</div>
|
||
<div class="detail">
|
||
<img class="cover" src="${esc(d.cover || src.cover || '/icon.svg')}" alt="" data-fallback>
|
||
<div class="detail-body">
|
||
<h1>${esc(t.english || t.romaji || src.title)}</h1>
|
||
<div class="muted">${esc([t.romaji !== (t.english || t.romaji) ? t.romaji : '', t.native].filter(Boolean).join(' · '))}</div>
|
||
<div class="scores">
|
||
${d.score != null ? `<div class="score"><b>${d.score}%</b><span>AniList</span></div>` : ''}
|
||
${d.muScore != null ? `<div class="score"><b>${Number(d.muScore).toFixed(1)}</b><span>MangaUpdates${d.muVotes ? ` · ${fmtNum(d.muVotes)} votos` : ''}</span></div>` : ''}
|
||
${d.popularity ? `<div class="score"><b>${fmtNum(d.popularity)}</b><span>lectores en AniList</span></div>` : ''}
|
||
</div>
|
||
${ranks.length ? `<div class="tags">${ranks.map(r => `<span class="tag hot">${esc(r)}</span>`).join('')}</div>` : ''}
|
||
<dl class="kv">
|
||
${[...people].map(([name, roles]) => row(esc(roles.join(' y ')), esc(name))).join('')}
|
||
${row('Revista', d.magazines.map(m => esc(m.name) + (m.publisher ? ` <span class="muted">(${esc(m.publisher)})</span>` : '')).join(', '))}
|
||
${row('Editorial', d.publishers.filter(p => p.type === 'Original').map(p => esc(p.name)).join(', '))}
|
||
${row('En inglés', d.publishers.filter(p => p.type === 'English').map(p => esc(p.name)).join(', '))}
|
||
${row('Tipo', esc([AL_FORMAT[d.format] || d.format, AL_COUNTRY[d.country]].filter(Boolean).join(' · ')))}
|
||
${row('Publicación', esc(dates))}
|
||
${row('Estado', esc([AL_STATUS[d.status], d.statusDetail].filter(Boolean).join(' · ')))}
|
||
${row('Extensión', esc(size))}
|
||
${row('Anime', [...d.adaptations.map(a => esc(`${AL_ANIME[a.format] || a.format || 'Anime'}${a.year ? ` (${a.year})` : ''}`)), d.anime ? `<span class="muted">adapta: ${esc(d.anime.start)}${d.anime.end ? ` → ${esc(d.anime.end)}` : ''}</span>` : ''].filter(Boolean).join('<br>'))}
|
||
</dl>
|
||
<div class="tags">${d.genres.map(g => `<span class="tag">${esc(g)}</span>`).join('')}${d.tags.map(g => `<span class="tag soft">${esc(g)}</span>`).join('')}</div>
|
||
</div>
|
||
</div>
|
||
${srcDesc ? `<h2>Sinopsis</h2><p class="desc long">${esc(srcDesc)}</p>` : ''}
|
||
${d.description ? (srcDesc ? `<details class="more"><summary>Sinopsis de AniList (en inglés)</summary><p class="desc long">${esc(d.description)}</p></details>` : `<h2>Sinopsis <span class="muted">(en inglés)</span></h2><p class="desc long">${esc(d.description)}</p>`) : ''}
|
||
${t.other.length ? `<h2>Otros títulos</h2><p class="muted">${list(t.other)}</p>` : ''}
|
||
${d.related.length ? `<h2>Obras relacionadas</h2><div class="grid">${d.related.map(cardOf).join('')}</div>` : ''}
|
||
${d.recommendations.length ? `<h2>Si te gusta, prueba</h2><div class="grid">${d.recommendations.map(cardOf).join('')}</div>` : ''}
|
||
${d.links.length ? `<h2>Enlaces</h2><div class="tags">${d.links.map(l => `<a class="tag" href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.site)}</a>`).join('')}</div>` : ''}
|
||
<p class="muted note">Datos de AniList y MangaUpdates${d.matched?.title ? ` (identificado por título como «${esc(d.matched.title)}»)` : ''}. Las fechas de los capítulos son las de la primera traducción conocida.</p>
|
||
</div>`
|
||
}
|
||
|
||
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, upscale, following, clips: clipCount = 0, lists: inLists = [], newKeys = [] } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
|
||
const dlSet = new Set(downloaded)
|
||
const newSet = new Set(newKeys.map(String))
|
||
let isFollowing = following
|
||
const readMap = new Map(reads.map(r => [r.chapter_id, r]))
|
||
const isLc = manga.source !== 'md' // sitios en español sin selector de idioma
|
||
const langOpts = [...new Set([lang, me.lang, ...chapterLangs, ...manga.languages])].filter(Boolean)
|
||
const altSearch = `#/?source=lc&q=${encodeURIComponent(manga.title)}`
|
||
const altSearchMi = `#/?source=mi&q=${encodeURIComponent(manga.title)}`
|
||
const first = chapters[0]
|
||
let mainBtn = ''
|
||
if (progress && chapters.some(c => c.id === progress.chapter_id)) {
|
||
mainBtn = `<a class="btn primary" href="${readUrl(id, progress.chapter_id, progress.page)}">${icon('play')} Continuar · ${progress.chapter_num ? `Cap. ${esc(progress.chapter_num)}` : 'Oneshot'}, pág. ${progress.page + 1}</a>`
|
||
} else if (progress) {
|
||
mainBtn = `<a class="btn primary" href="${readUrl(id, progress.chapter_id, progress.page)}">${icon('play')} Continuar · Cap. ${esc(progress.chapter_num ?? '?')} (${esc(LANGS[progress.lang] || progress.lang || '')}), pág. ${progress.page + 1}</a>`
|
||
}
|
||
if (first && !mainBtn) mainBtn = `<a class="btn primary" href="${readUrl(id, first.id)}">${icon('book')} Empezar a leer</a>`
|
||
|
||
const menuItems = [
|
||
manga.source === 'lc' ? '' : `<a href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
|
||
manga.source === 'mi' ? '' : `<a href="${altSearchMi}">${icon('search')} Buscar en M440</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 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="actions">${mainBtn}
|
||
<button class="btn" id="follow"></button>
|
||
${offline ? '' : `<a class="btn" href="#/info/${id}" title="Autor, revista, puntuación, adaptaciones…">${icon('info')} Info</a>`}
|
||
${offline ? '' : `<details class="menu" id="lmenu"><summary class="btn" id="lbtn" title="Añadir a tus listas">${icon('bookmark')} Listas</summary><div class="menu-list" id="lmlist"><div class="muted note">Cargando…</div></div></details>`}
|
||
${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="facts muted"><span>Fuente: ${esc(SOURCES[manga.source])}</span>${download ? `<span>Carpeta: mangas/${esc(download.folder)}</span>` : ''}${clipCount ? `<a href="#/clips?manga=${encodeURIComponent(id)}">${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}</a>` : ''}</div>
|
||
${offline ? '<p class="error">Sin conexión con la fuente: mostrando solo los capítulos descargados.</p>' : ''}
|
||
<div class="libpanel"><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></div><div class="psec" id="off" 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>
|
||
${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 buscarlo en <a class="tag" href="${altSearch}">LeerCapitulo</a> o <a class="tag" href="${altSearchMi}">M440</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>`}
|
||
<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>`
|
||
|
||
// --- 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.
|
||
let wantedSet = new Set(download?.wanted || [])
|
||
let hdSet = new Set(upscale?.chapters || []), hdJobs = upscale?.jobs || {}
|
||
let hdInfo = upscale
|
||
let sel = null // null | { kind: 'dl' | 'hd', mode: 'pending' | 'range' | 'pick' }
|
||
const picked = new Set()
|
||
let lastPick = null
|
||
const KINDS = {
|
||
dl: {
|
||
verb: 'Descargar', ic: 'download', modes: { range: 'Rango', pick: 'Elegir' },
|
||
// Sin descargar y sin estar ya en cola.
|
||
candidates: () => chapters.filter(c => !dlSet.has(c.id) && !wantedSet.has(c.id)),
|
||
rangeBase: () => chapters,
|
||
mark: c => (dlSet.has(c.id) ? ' (descargado)' : ''),
|
||
summary: ids => {
|
||
const pages = ids.reduce((n, cid) => n + (chapters.find(c => c.id === cid)?.pages || 0), 0)
|
||
return plural(ids.length, 'capítulo') + (pages ? ` · ${pages} págs.` : '')
|
||
},
|
||
offTitle: c => (dlSet.has(c.id) ? 'Ya descargado' : 'Ya está en cola'),
|
||
run: async ids => {
|
||
renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title, chapterIds: ids } }))
|
||
toast(`${plural(ids.length, 'capítulo')} en cola de descarga`)
|
||
},
|
||
},
|
||
hd: {
|
||
verb: 'Reescalar', ic: 'sparkles', modes: { pending: 'Pendientes', range: 'Rango', pick: 'Elegir' },
|
||
// Descargados, sin HD y sin estar ya en cola (los fallidos sí, para reintentar).
|
||
candidates: () => chapters.filter(c => dlSet.has(c.id) && !hdSet.has(c.id) && (!hdJobs[c.id] || hdJobs[c.id] === 'error')),
|
||
rangeBase: () => chapters.filter(c => dlSet.has(c.id)),
|
||
mark: c => (hdSet.has(c.id) ? ' (HD)' : ''),
|
||
summary: ids => {
|
||
const pages = ids.reduce((n, cid) => n + (hdInfo?.pages?.[cid] || 0), 0)
|
||
return `${plural(ids.length, 'capítulo')} · ${pages} págs. · ${fmtDur(pages * (hdInfo?.secPerPage || 13))}`
|
||
},
|
||
offTitle: c => (!dlSet.has(c.id) ? 'No está descargado' : 'Ya está en HD o en cola'),
|
||
run: async ids => {
|
||
renderHd(await api(`/api/upscale/${id}`, { method: 'POST', body: { chapterIds: ids } }))
|
||
toast(`${plural(ids.length, 'capítulo')} en cola para HD`)
|
||
},
|
||
},
|
||
}
|
||
if (OFFLINE_OK) KINDS.off = {
|
||
verb: 'Guardar', ic: 'phone', modes: { range: 'Rango', pick: 'Elegir' },
|
||
candidates: () => chapters.filter(c => !offHas(c.id) && !offState.queue.some(q => q.chapterId === c.id) && offState.running?.chapterId !== c.id),
|
||
rangeBase: () => chapters,
|
||
mark: c => (offHas(c.id) ? ' (en el dispositivo)' : ''),
|
||
summary: ids => plural(ids.length, 'capítulo'),
|
||
offTitle: () => 'Ya está en el dispositivo',
|
||
run: async ids => {
|
||
offEnqueue(ids.map(cid => { const c = chapters.find(x => x.id === cid); return { mangaId: id, mangaTitle: manga.title, cover: manga.cover, lang, chapterId: cid, label: chapLabel(c), num: c.num } }))
|
||
toast(`Guardando ${plural(ids.length, 'capítulo')} en este dispositivo`)
|
||
},
|
||
}
|
||
const selIds = () => {
|
||
if (!sel) return []
|
||
const K = KINDS[sel.kind]
|
||
const cand = K.candidates()
|
||
if (sel.mode === 'pending') return cand.map(c => c.id)
|
||
if (sel.mode === 'pick') return cand.filter(c => picked.has(c.id)).map(c => c.id)
|
||
const base = K.rangeBase()
|
||
let a = base.findIndex(c => c.id === $('#selfrom')?.value), b = base.findIndex(c => c.id === $('#selto')?.value)
|
||
if (a < 0 || b < 0) return []
|
||
if (a > b) [a, b] = [b, a]
|
||
const inRange = new Set(base.slice(a, b + 1).map(c => c.id))
|
||
return cand.filter(c => inRange.has(c.id)).map(c => c.id)
|
||
}
|
||
const updateSummary = () => {
|
||
const ids = selIds()
|
||
const K = sel && KINDS[sel.kind]
|
||
if ($('#selsum')) $('#selsum').textContent = ids.length ? K.summary(ids) : sel?.mode === 'pick' ? 'Marca capítulos en la lista de abajo.' : 'No hay capítulos pendientes en esa selección.'
|
||
if ($('#selgo')) $('#selgo').disabled = !ids.length
|
||
const bar = $('#selbar')
|
||
if (!bar) return
|
||
bar.hidden = sel?.mode !== 'pick'
|
||
if (sel?.mode !== 'pick') return
|
||
bar.innerHTML = `<span>${ids.length ? K.summary(ids) : 'Ningún capítulo marcado'}</span>
|
||
<button class="btn small primary" id="selrun" ${ids.length ? '' : 'disabled'}>${icon(K.ic)} ${K.verb}</button>
|
||
<button class="btn small" id="selexit" title="Salir de la selección">${icon('x')}</button>`
|
||
$('#selrun').addEventListener('click', runSel)
|
||
$('#selexit').addEventListener('click', () => setSel(null))
|
||
}
|
||
async function runSel() {
|
||
const ids = selIds()
|
||
if (!ids.length) return
|
||
const K = KINDS[sel.kind]
|
||
try {
|
||
if (sel.kind === 'hd' && sel.mode === 'pending' && $('#hdautoform') && $('#hdautoform').checked !== !!hdInfo?.auto) {
|
||
await api(`/api/upscale/${id}/auto`, { method: 'POST', body: { auto: $('#hdautoform').checked } }).catch(() => {})
|
||
}
|
||
picked.clear()
|
||
setSel(null)
|
||
await K.run(ids)
|
||
} catch (err) { toast(err.message) }
|
||
}
|
||
function setSel(next) {
|
||
const wasPick = sel?.mode === 'pick', prevKind = sel?.kind
|
||
if (next?.kind !== prevKind) picked.clear()
|
||
sel = next
|
||
if (prevKind && prevKind !== next?.kind) renderSelForm(prevKind) // cerrar el otro formulario
|
||
if (next) renderSelForm(next.kind)
|
||
if (wasPick !== (next?.mode === 'pick') || (wasPick && prevKind !== next?.kind)) renderList()
|
||
$('#dlopen')?.classList.toggle('on', sel?.kind === 'dl')
|
||
$('#hdopen')?.classList.toggle('on', sel?.kind === 'hd')
|
||
$('#offopen')?.classList.toggle('on', sel?.kind === 'off')
|
||
updateSummary()
|
||
}
|
||
function renderSelForm(kind) {
|
||
const box = $(`#${kind}form`)
|
||
if (!box) return
|
||
const open = sel?.kind === kind
|
||
box.hidden = !open
|
||
if (!open) { box.innerHTML = ''; return }
|
||
const K = KINDS[kind]
|
||
const base = K.rangeBase(), cand = K.candidates()
|
||
const opt = (c, on) => `<option value="${c.id}" ${on ? 'selected' : ''}>${esc(c.num ? `Cap. ${c.num}` : 'Oneshot')}${K.mark(c)}</option>`
|
||
const from = cand[0] || base[0], to = cand[Math.min(cand.length, 10) - 1] || base.at(-1)
|
||
box.innerHTML = `
|
||
<div class="seg" role="tablist">${Object.entries(K.modes).map(([m, label]) => `<button data-sm="${m}" class="${sel.mode === m ? 'on' : ''}">${label}</button>`).join('')}</div>
|
||
${sel.mode === 'range' ? `<div class="row range"><label>Desde <select id="selfrom">${base.map(c => opt(c, c === from)).join('')}</select></label>
|
||
<label>hasta <select id="selto">${base.map(c => opt(c, c === to)).join('')}</select></label></div>` : ''}
|
||
${sel.mode === 'pick' ? '<div class="note muted">Marca los capítulos en la lista de abajo. Mayús + clic marca un tramo entero.</div>' : ''}
|
||
${sel.mode === '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="selsum" class="sum"></b></div>
|
||
<div class="psec-actions"><button class="btn small primary" id="selgo">${icon(K.ic)} ${K.verb}</button><button class="btn small" id="selclose">Cerrar</button></div>`
|
||
box.querySelectorAll('[data-sm]').forEach(b => b.addEventListener('click', () => setSel({ kind, mode: b.dataset.sm })))
|
||
$('#selfrom')?.addEventListener('change', updateSummary)
|
||
$('#selto')?.addEventListener('change', updateSummary)
|
||
$('#selclose').addEventListener('click', () => setSel(null))
|
||
$('#selgo').addEventListener('click', runSel)
|
||
}
|
||
// Tras un sondeo: repintar lo que dependa de los conjuntos y cerrar el selector si ya no queda nada.
|
||
const refreshSel = () => {
|
||
renderList()
|
||
if (sel && sel.mode !== 'pick') renderSelForm(sel.kind)
|
||
if (sel && !KINDS[sel.kind].candidates().length) setSel(null)
|
||
else updateSummary()
|
||
}
|
||
|
||
// --- Copia local (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 some = d?.scope === 'some'
|
||
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 canPick = !offline && chapters.some(c => !dlSet.has(c.id) && !wantedSet.has(c.id) && !(d?.wanted || []).includes(c.id))
|
||
const pickBtn = label => (canPick ? `<button class="btn small ${sel?.kind === 'dl' ? 'on' : ''}" id="dlopen">${icon('download')} ${label}</button>` : '')
|
||
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 capítulos en CBZ en el servidor para leerlos sin conexión (y poder reescalarlos a HD). Puedes bajar el manga entero o solo algunos capítulos.</div>'
|
||
actions = `<button class="btn small" id="dlall">${icon('download')} Descargar todo</button>${pickBtn('Elegir capítulos…')}`
|
||
} else if (active) {
|
||
status = `${downloadLabel(d)} · ${Math.floor(downloadPct(d))}%`
|
||
body = `${bar}<div class="note muted">${downloadDetail(d)}</div>`
|
||
actions = `${pickBtn('Añadir capítulos…')}<button class="btn small" id="dlcancel">${icon('stop')} Cancelar</button>${delBtn}`
|
||
} else {
|
||
const have = dlSet.size
|
||
status = d.status === 'done'
|
||
? `${icon('check')} ${some ? `${have} de ${chapters.length}` : have} capítulos${d.size ? ` · ${fmtSize(d.size)}` : ''}`
|
||
: downloadLabel(d)
|
||
body = bar + (d.status === 'error' && d.error ? `<div class="error note">${esc(d.error)}</div>` : '')
|
||
+ (some && d.status === 'done' ? '<div class="note muted">Solo capítulos elegidos: los nuevos no se descargan solos.</div>' : '')
|
||
const resume = d.status === 'error' ? 'Reintentar' : d.status === 'cancelled' ? 'Continuar descarga' : null
|
||
actions = some
|
||
? `${resume ? `<button class="btn small" id="dlgo">${icon('refresh')} ${resume}</button>` : ''}${pickBtn('Descargar más…')}<button class="btn small" id="dlall" title="Bajar también todos los capítulos que falten y, en adelante, los nuevos">${icon('download')} Descargar todo</button>${delBtn}`
|
||
: `<button class="btn small" id="dlgo" title="Descarga los capítulos que falten">${icon(resume ? 'download' : 'refresh')} ${resume || 'Buscar capítulos nuevos'}</button>${delBtn}`
|
||
}
|
||
if (!$('#dltop')) box.innerHTML = '<div id="dltop" class="psec-grid"></div><div id="dlform" class="hdform" hidden></div>'
|
||
$('#dltop').innerHTML = secHead('download', 'Copia local', status) + body + (offline ? '' : `<div class="psec-actions">${actions}</div>`)
|
||
$('#dlgo')?.addEventListener('click', async () => {
|
||
// Reanudar respeta el modo (todo o capítulos elegidos).
|
||
renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title, ...(some ? { chapterIds: d.wanted?.length ? d.wanted : [] } : {}) } }).catch(err => (toast(err.message), d)))
|
||
})
|
||
$('#dlall')?.addEventListener('click', async () => {
|
||
const missing = chapters.filter(c => !dlSet.has(c.id)).length
|
||
if (d && !confirm(`¿Descargar todo «${manga.title}»?\nSe bajarán ${plural(missing, 'capítulo')} que faltan y, en adelante, los nuevos.`)) return
|
||
renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title } }))
|
||
toast(`Descargando en mangas/${manga.title}`)
|
||
})
|
||
$('#dlopen')?.addEventListener('click', () => setSel(sel?.kind === 'dl' ? null : { kind: 'dl', mode: 'range' }))
|
||
$('#dlcancel')?.addEventListener('click', async () => renderDl(await api(`/api/download/${id}/cancel`, { method: 'POST' })))
|
||
$('#dldel')?.addEventListener('click', async () => {
|
||
if (!confirm(`¿Eliminar la copia local de «${manga.title}»?\nSe borrará la carpeta mangas/${d.folder}${d.size ? ` (${fmtSize(d.size)})` : ''}, incluida la versión HD.\nTu historial de lectura no se toca.`)) return
|
||
await api(`/api/download/${id}`, { method: 'DELETE' })
|
||
toast('Copia local eliminada')
|
||
viewManga(id, langOverride)
|
||
})
|
||
const wantedBefore = wantedSet.size
|
||
wantedSet = new Set(d?.wanted || [])
|
||
if (wantedSet.size !== wantedBefore) refreshSel()
|
||
clearTimeout(dlTimer)
|
||
if (active) dlTimer = setTimeout(async () => {
|
||
const next = await api(`/api/download/${id}`).catch(() => d)
|
||
if (!$('#dl')) return
|
||
if (next && next.done !== d.done) {
|
||
const fresh = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`).catch(() => null)
|
||
fresh?.downloaded.forEach(x => dlSet.add(x))
|
||
if (fresh?.download) next.size = fresh.download.size
|
||
if (fresh?.upscale) renderHd(fresh.upscale)
|
||
refreshSel()
|
||
}
|
||
renderDl(next)
|
||
}, 2000)
|
||
}
|
||
|
||
// --- Reescalado HD de lo descargado.
|
||
let hdTimer = null
|
||
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 = KINDS.hd.candidates().length > 0
|
||
const actions = [
|
||
canAdd ? `<button class="btn small ${sel?.kind === 'hd' ? 'on' : ''}" id="hdopen">${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('')
|
||
// Los formularios se conservan 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', () => setSel(sel?.kind === 'hd' ? null : { kind: 'hd', mode: '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) refreshSel()
|
||
}
|
||
|
||
// --- En este dispositivo (sin conexión).
|
||
const renderOff = () => {
|
||
const box = $('#off')
|
||
if (!box || !OFFLINE_OK) return
|
||
const mine = Object.entries(offIdx()).filter(([, c]) => c.mangaId === id)
|
||
const bytes = mine.reduce((n, [, c]) => n + (c.bytes || 0), 0)
|
||
const run = offState.running?.mangaId === id ? offState.running : null
|
||
const queued = offState.queue.filter(q => q.mangaId === id).length
|
||
box.hidden = false
|
||
// Siguientes 3 sin guardar a partir de donde vas (o desde el principio).
|
||
const from = Math.max(0, chapters.findIndex(c => c.id === progress?.chapter_id))
|
||
const nextIds = chapters.slice(from).filter(c => !offHas(c.id)).slice(0, 3)
|
||
let body = ''
|
||
if (run || queued) {
|
||
const pct = run?.total ? (run.done / run.total) * 100 : 0
|
||
body = `<div class="bar dlbar"><i style="width:${pct.toFixed(1)}%"></i></div>
|
||
<div class="note muted">${run ? `${esc(run.label)} · pág. ${run.done}/${run.total || '?'}` : 'Esperando…'}${queued ? ` · ${queued} más` : ''}</div>`
|
||
} else if (!mine.length) body = '<div class="note muted">Guarda capítulos en este móvil o PC para leerlos sin internet (también los no descargados en el servidor).</div>'
|
||
const actions = [
|
||
nextIds.length && !run ? `<button class="btn small" id="offnext">${icon('phone')} Guardar ${nextIds.length === 1 ? 'el siguiente' : `los ${nextIds.length} siguientes`}</button>` : '',
|
||
KINDS.off.candidates().length ? `<button class="btn small ${sel?.kind === 'off' ? 'on' : ''}" id="offopen">${icon('phone')} Elegir capítulos…</button>` : '',
|
||
mine.length ? `<button class="btn small danger push" id="offdel">${icon('trash')} Borrar del dispositivo</button>` : '',
|
||
].join('')
|
||
if (!$('#offtop')) box.innerHTML = '<div id="offtop" class="psec-grid"></div><div id="offform" class="hdform" hidden></div>'
|
||
$('#offtop').innerHTML = secHead('phone', 'En este dispositivo', mine.length ? `${plural(mine.length, 'capítulo')} · ${fmtSize(bytes)}` : '') + body + `<div class="psec-actions">${actions}</div>`
|
||
$('#offnext')?.addEventListener('click', () => KINDS.off.run(nextIds.map(c => c.id)))
|
||
$('#offopen')?.addEventListener('click', () => setSel(sel?.kind === 'off' ? null : { kind: 'off', mode: 'range' }))
|
||
$('#offdel')?.addEventListener('click', async () => {
|
||
if (!confirm(`¿Borrar de este dispositivo los ${plural(mine.length, 'capítulo')} guardados de «${manga.title}» (${fmtSize(bytes)})?`)) return
|
||
await offRemove(mine.map(([cid]) => cid))
|
||
toast('Borrado del dispositivo')
|
||
})
|
||
}
|
||
let offRefreshAt = 0
|
||
const onOff = () => {
|
||
if (!$('#off')) return
|
||
renderOff()
|
||
// La lista y el selector solo cuando cambia qué está guardado (no en cada página descargada).
|
||
const n = Object.keys(offIdx()).length + offState.queue.length + (offState.running ? 1 : 0)
|
||
if (n !== offRefreshAt) { offRefreshAt = n; refreshSel() }
|
||
}
|
||
if (OFFLINE_OK) offState.listeners.add(onOff)
|
||
|
||
// --- Listas propias (menú "Listas").
|
||
let listSet = new Set(inLists)
|
||
const renderListsBtn = () => {
|
||
const b = $('#lbtn')
|
||
if (!b) return
|
||
b.classList.toggle('on', listSet.size > 0)
|
||
b.innerHTML = `${icon('bookmark')} ${listSet.size ? `En ${plural(listSet.size, 'lista')}` : 'Listas'}`
|
||
}
|
||
renderListsBtn()
|
||
async function renderListsMenu() {
|
||
const box = $('#lmlist')
|
||
if (!box) return
|
||
const lists = await api('/api/lists').catch(() => [])
|
||
box.innerHTML = lists.map(l => `<label class="check lcheck"><input type="checkbox" data-list="${l.id}" ${listSet.has(l.id) ? 'checked' : ''}> ${esc(l.name)}</label>`).join('')
|
||
+ `<button id="lnew">${icon('plus')} Nueva lista…</button><a href="#/library">${icon('library')} Ver mi librería</a>`
|
||
box.querySelectorAll('[data-list]').forEach(x => x.addEventListener('change', async () => {
|
||
const lid = Number(x.dataset.list)
|
||
try {
|
||
const r = x.checked
|
||
? await api(`/api/lists/${lid}/items`, { method: 'POST', body: { mangaId: id, title: manga.title, cover: manga.cover } })
|
||
: await api(`/api/lists/${lid}/items/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||
listSet = new Set(r.lists)
|
||
renderListsBtn()
|
||
} catch (err) { toast(err.message); x.checked = !x.checked }
|
||
}))
|
||
$('#lnew').addEventListener('click', async () => {
|
||
const name = prompt('Nombre de la nueva lista:')
|
||
if (!name?.trim()) return
|
||
try {
|
||
const l = await api('/api/lists', { method: 'POST', body: { name } })
|
||
const r = await api(`/api/lists/${l.id}/items`, { method: 'POST', body: { mangaId: id, title: manga.title, cover: manga.cover } })
|
||
listSet = new Set(r.lists)
|
||
renderListsBtn()
|
||
renderListsMenu()
|
||
toast(`Añadido a «${l.name}»`)
|
||
} catch (err) { toast(err.message) }
|
||
})
|
||
}
|
||
$('#lmenu')?.addEventListener('toggle', e => { if (e.target.open) renderListsMenu() })
|
||
|
||
const renderFollow = () => {
|
||
const b = $('#follow')
|
||
b.innerHTML = isFollowing ? `${icon('bell')} Siguiendo` : `${icon('bellOff')} Seguir`
|
||
b.classList.toggle('on', isFollowing)
|
||
b.title = isFollowing ? 'Recibes avisos de capítulos nuevos. Pulsa para dejar de seguir.' : 'Avisarme cuando salgan capítulos nuevos'
|
||
b.hidden = offline
|
||
}
|
||
renderFollow()
|
||
$('#follow').addEventListener('click', async () => {
|
||
const r = await api(`/api/follow/${id}`, isFollowing ? { method: 'DELETE' } : { method: 'POST', body: { title: manga.title, cover: manga.cover, lang } })
|
||
isFollowing = r.following
|
||
renderFollow()
|
||
toast(isFollowing ? 'Te avisaremos de los capítulos nuevos' : 'Ya no sigues este manga')
|
||
})
|
||
$('#drop')?.addEventListener('click', async () => {
|
||
if (!confirm(`¿Dejar de leer «${manga.title}»?\nSe quitará del historial, se borrará el progreso de sus capítulos y dejarás de seguirlo.`)) return
|
||
await api(`/api/history/${id}`, { method: 'DELETE' })
|
||
toast('Quitado del historial')
|
||
// Volver a donde se estaba (salvo el lector o la propia ficha, que lo volverían a marcar) o al inicio.
|
||
const back = prevHash && !prevHash.startsWith('#/read/') && !prevHash.startsWith(`#/manga/${id}`) && prevHash !== '#/login'
|
||
location.hash = back ? prevHash : '#/'
|
||
})
|
||
// Cerrar el menú ⋯ al pulsar fuera.
|
||
const closeMenu = e => app.querySelectorAll('.menu[open]').forEach(menu => { if (!menu.contains(e.target)) menu.open = false })
|
||
document.addEventListener('click', closeMenu)
|
||
cleanup = () => { clearTimeout(dlTimer); clearTimeout(hdTimer); document.removeEventListener('click', closeMenu); offState.listeners.delete(onOff) }
|
||
app.querySelectorAll('[data-lang]').forEach(a => a.addEventListener('click', e => { e.preventDefault(); store.set(`lang:${id}`, a.dataset.lang); viewManga(id, a.dataset.lang) }))
|
||
|
||
const ul = $('.chapters', app)
|
||
let desc = store.get('chapterOrderDesc', true)
|
||
const chapMeta = (c, r, done) => (newSet.has(String(c.num ?? c.id)) ? '<span class="badge">NUEVO</span> ' : '')
|
||
+ (dlSet.has(c.id) ? `<span title="Descargado">${icon('download')}</span> ` : wantedSet.has(c.id) ? `<span class="queued" title="En cola de descarga">${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> ` : '')
|
||
+ (OFFLINE_OK && offHas(c.id) ? `<span class="dev" title="Guardado en este dispositivo">${icon('phone')}</span> ` : '')
|
||
+ [r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : manga.source === 'md' ? esc(c.group) : '', chapDate(c)].filter(Boolean).join(' · ')
|
||
// Fecha de cada capítulo: la primera publicación conocida (MangaDex/MangaUpdates, se pide aparte) o, si
|
||
// no, la de la fuente, salvo que sea la de una subida masiva (la misma fecha en muchos capítulos).
|
||
let extDates = {}
|
||
const srcDatesOk = (() => {
|
||
const ds = chapters.map(c => c.publishAt?.slice(0, 10)).filter(Boolean)
|
||
const n = {}
|
||
for (const d of ds) n[d] = (n[d] || 0) + 1
|
||
return ds.length < 8 || Math.max(...Object.values(n)) / ds.length <= 0.25
|
||
})()
|
||
// Fechas por capítulo: { d, approx } por id. Un capítulo no puede ser posterior al siguiente (las
|
||
// traducciones a veces llegan desordenadas) y los huecos entre dos fechas conocidas se estiman.
|
||
let dateMap = new Map()
|
||
function buildDates() {
|
||
const DAY = 864e5
|
||
const known = chapters.map(c => {
|
||
const d = extDates[numKey(c.num)] || (srcDatesOk ? c.publishAt?.slice(0, 10) : null)
|
||
const t = d ? Date.parse(`${d}T12:00:00Z`) : NaN
|
||
return { c, n: parseFloat(c.num), t: Number.isNaN(t) ? null : t }
|
||
}).filter(x => Number.isFinite(x.n)).sort((a, b) => a.n - b.n)
|
||
let min = Infinity
|
||
for (let i = known.length - 1; i >= 0; i--) if (known[i].t != null) { min = Math.min(min, known[i].t); known[i].t = min }
|
||
dateMap = new Map()
|
||
let prev = null
|
||
known.forEach((x, i) => {
|
||
if (x.t == null) return
|
||
dateMap.set(x.c.id, { t: x.t, approx: false })
|
||
if (prev && i - prev.i > 1 && i - prev.i <= 150) {
|
||
for (let k = prev.i + 1; k < i; k++) {
|
||
const f = (known[k].n - prev.n) / (x.n - prev.n)
|
||
dateMap.set(known[k].c.id, { t: Math.round((prev.t + f * (x.t - prev.t)) / DAY) * DAY, approx: true })
|
||
}
|
||
}
|
||
prev = { i, n: x.n, t: x.t }
|
||
})
|
||
}
|
||
buildDates()
|
||
const chapDate = c => {
|
||
const e = dateMap.get(c.id)
|
||
if (!e) return ''
|
||
const d = new Date(e.t).toISOString().slice(0, 10)
|
||
return `<span class="cdate${e.approx ? ' approx' : ''}" title="${e.approx ? 'Fecha estimada (entre capítulos con fecha conocida)' : 'Primera publicación conocida'}">${e.approx ? '≈ ' : ''}${fmtDate(d)}</span>`
|
||
}
|
||
function renderList() {
|
||
if (!ul) return
|
||
const list = desc ? [...chapters].reverse() : chapters
|
||
const picking = sel?.mode === 'pick'
|
||
const pickable = new Set(picking ? KINDS[sel.kind].candidates().map(c => c.id) : [])
|
||
ul.classList.toggle('picking', picking)
|
||
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 (picking) {
|
||
const ok = pickable.has(c.id)
|
||
return `<li><label class="pick ${ok ? '' : 'off'}" title="${ok ? '' : KINDS[sel.kind].offTitle(c)}">
|
||
<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' : ''
|
||
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 en la lista (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()
|
||
})
|
||
renderList()
|
||
if (!offline && serverOnline) api(`/api/meta/${id}/dates`).then(r => { extDates = r.dates || {}; if (Object.keys(extDates).length) { buildDates(); renderList() } }, () => {})
|
||
renderDl(download)
|
||
renderHd(upscale)
|
||
renderOff()
|
||
$('#order')?.addEventListener('click', () => { desc = !desc; lastPick = null; store.set('chapterOrderDesc', desc); renderList() })
|
||
$('#lang')?.addEventListener('change', e => { store.set(`lang:${id}`, e.target.value); viewManga(id, e.target.value) })
|
||
}
|
||
|
||
// ---------- Lector ----------
|
||
const READ_MODES = { paged: 'Una página', double: 'Doble página', vertical: 'Vertical' }
|
||
const PAGE_ANIMS = { flip: 'Pasar hoja', slide: 'Deslizar', fade: 'Fundido', none: 'Sin animación' }
|
||
// 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' }
|
||
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' }
|
||
}
|
||
|
||
async function viewReader(mangaId, chapterId, startPage) {
|
||
document.body.classList.add('reading')
|
||
app.innerHTML = '<div class="reader"><div class="loading">Cargando capítulo…</div></div>'
|
||
const lang = store.get(`lang:${mangaId}`, me.lang)
|
||
// Filtro, brillo y ahorro de datos son globales (navegador); modo, ajuste y dirección van por manga (servidor).
|
||
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches
|
||
const globalSettings = { saver: false, filter: 'none', brightness: 100, anim: reduceMotion ? 'fade' : 'flip', ...store.get('readerSettings', {}) }
|
||
const [info, chap] = await Promise.all([
|
||
api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`),
|
||
api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}&hd=${globalSettings.hd === false ? 0 : 1}`),
|
||
])
|
||
const { manga, chapters } = info
|
||
const settings = { ...globalSettings, ...defaultPrefs(manga), ...(info.prefs || {}) }
|
||
const idx = chapters.findIndex(c => c.id === chapterId)
|
||
const chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' }
|
||
const prev = idx > 0 ? chapters[idx - 1] : null
|
||
const next = idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1] : null
|
||
const pages = chap.pages
|
||
const total = pages.length
|
||
let page = Math.min(Math.max(startPage >= 0 ? startPage : chap.savedPage || 0, 0), total - 1)
|
||
let shown = [page] // páginas visibles (2 en doble página)
|
||
const isPaged = () => settings.mode !== 'vertical'
|
||
const isDouble = () => settings.mode === 'double' && window.innerWidth >= 700 && window.innerWidth > window.innerHeight
|
||
const lastShown = () => (isPaged() ? shown.at(-1) : page)
|
||
|
||
// Guardado del progreso (con debounce y al salir de la página).
|
||
let saveTimer = null
|
||
const payload = () => ({
|
||
mangaId, chapterId, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapter.num ?? null,
|
||
chapterTitle: chapter.title, lang: manga.source !== 'md' ? 'es' : chapter.lang || lang, page: lastShown(), totalPages: total,
|
||
isLast: idx >= 0 && !next, // al terminarlo, el manga sale de "Continuar leyendo"
|
||
})
|
||
const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; const p = payload(); api('/api/progress', { method: 'POST', body: p }).catch(() => queueProgress(p)) }
|
||
const scheduleSave = () => { clearTimeout(saveTimer); saveTimer = setTimeout(saveNow, 700) }
|
||
const beacon = () => { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; navigator.onLine ? navigator.sendBeacon('/api/progress', JSON.stringify(payload())) : queueProgress(payload()) } }
|
||
const onVis = () => document.visibilityState === 'hidden' && beacon()
|
||
document.addEventListener('visibilitychange', onVis)
|
||
window.addEventListener('pagehide', beacon)
|
||
saveNow()
|
||
|
||
const chapOpts = chapters.map(c => `<option value="${c.id}" ${c.id === chapterId ? 'selected' : ''}>${esc(chapLabel(c))}</option>`).join('')
|
||
app.innerHTML = `
|
||
<div class="reader">
|
||
<div class="rbar top">
|
||
<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="clipbtn" title="Recortar un panel (se guarda en Recortes y se mejora a HD)">${icon('scissors')}</button>
|
||
<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>
|
||
<select id="anim" title="Animación al pasar página (modos por página)">${Object.entries(PAGE_ANIMS).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
|
||
<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 !== 'md' || 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>` : ''}
|
||
${OFFLINE_OK ? `<button class="btn small" id="offone" ${offHas(chapterId) ? 'disabled' : ''} title="Guarda las páginas en este dispositivo para leerlas sin internet">${icon('phone')} ${offHas(chapterId) ? 'En el dispositivo' : 'Guardar sin conexión'}</button>` : ''}
|
||
<p class="muted">Modo, ajuste y direcció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">
|
||
${prev ? `<a class="btn small" href="${readUrl(mangaId, prev.id)}">${icon('prev')} Cap. ant.</a>` : ''}
|
||
<span id="pnum" class="muted"></span>
|
||
<input type="range" id="slider" min="1" max="${total}" value="${page + 1}">
|
||
<button class="btn small" id="zoomreset" hidden title="Quitar zoom">${icon('zoomOut')}</button>
|
||
${next ? `<a class="btn small" href="${readUrl(mangaId, next.id)}">Cap. sig. ${icon('next')}</a>` : ''}
|
||
</div>
|
||
</div>`
|
||
const reader = $('.reader', app)
|
||
const box = $('#pages', app)
|
||
const slider = $('#slider', app)
|
||
$('#mode').value = settings.mode
|
||
$('#fit').value = settings.fit
|
||
$('#dir').value = settings.dir
|
||
// Botón rápido páginas ⇄ vertical (vuelve al último modo por páginas usado).
|
||
let lastPaged = settings.mode === 'double' ? 'double' : 'paged'
|
||
function renderModeQuick() {
|
||
const b = $('#modequick')
|
||
b.innerHTML = icon(isPaged() ? 'scroll' : 'pages')
|
||
b.title = isPaged() ? 'Leer en vertical (desplazamiento continuo)' : 'Leer por páginas'
|
||
$('#mode').value = settings.mode
|
||
}
|
||
$('#filter').value = FILTERS[settings.filter] ? settings.filter : 'none'
|
||
$('#bright').value = settings.brightness
|
||
const applyFilter = () => {
|
||
const f = [FILTERS[settings.filter]?.[1] || '', settings.brightness != 100 ? `brightness(${settings.brightness / 100})` : ''].join(' ').trim()
|
||
box.style.setProperty('--page-filter', f || 'none')
|
||
}
|
||
applyFilter()
|
||
if ($('#saver')) $('#saver').checked = settings.saver
|
||
if ($('#hd')) $('#hd').checked = settings.hd !== false
|
||
const endBlock = `<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>`
|
||
|
||
if (!info.prefs && !store.get(`autoPrefs:${mangaId}`)) {
|
||
store.set(`autoPrefs:${mangaId}`, 1)
|
||
if (manga.type) toast(`${manga.type}: ${READ_MODES[settings.mode].toLowerCase()}${settings.mode === 'vertical' ? '' : `, ${settings.dir === 'rtl' ? 'der → izq' : 'izq → der'}`}. Lo que cambies se recordará.`)
|
||
}
|
||
|
||
const setPageLabel = () => {
|
||
$('#pnum').textContent = `${shown.length > 1 ? `${shown[0] + 1}-${shown[1] + 1}` : lastShown() + 1} / ${total}`
|
||
slider.value = lastShown() + 1
|
||
}
|
||
let observer = null
|
||
let atEnd = false
|
||
|
||
// --- Doble página: la 1.ª va sola (portada) y las apaisadas (ya dobles) también.
|
||
const wide = new Set()
|
||
const isWide = img => img.naturalWidth > img.naturalHeight * 1.1
|
||
function groupOf(i) {
|
||
if (!isDouble()) return [i]
|
||
for (let s = 0; s < total;) {
|
||
const g = s === 0 || wide.has(s) || wide.has(s + 1) || s + 1 >= total ? [s] : [s, s + 1]
|
||
if (g.includes(i)) return g
|
||
s += g.length
|
||
}
|
||
return [i]
|
||
}
|
||
function preload() {
|
||
for (let k = 1; k <= 4; k++) {
|
||
const i = shown.at(-1) + k
|
||
if (i >= total) break
|
||
const im = new Image()
|
||
im.onload = () => isWide(im) && wide.add(i)
|
||
im.src = pages[i]
|
||
}
|
||
}
|
||
// dir: 1 hacia delante, -1 hacia atrás, 0 sin animación (salto con la barra, cambio de modo…).
|
||
function showPaged(dir = 0) {
|
||
finishTurn()
|
||
const old = $('.stage', box)
|
||
const stage = document.createElement('div')
|
||
stage.className = 'stage'
|
||
stage.classList.toggle('two', shown.length === 2)
|
||
stage.innerHTML = shown.map(i => `<img data-i="${i}" src="${pages[i]}" alt="" draggable="false">`).join('')
|
||
if (old && dir && settings.anim !== 'none' && zoom.s === 1) turnPage(old, stage, dir)
|
||
else { box.replaceChildren(stage) }
|
||
stage.querySelectorAll('img').forEach(img => img.addEventListener('load', () => {
|
||
const i = Number(img.dataset.i)
|
||
if (isWide(img) && !wide.has(i)) {
|
||
wide.add(i)
|
||
if (shown.length === 2 && shown.includes(i)) goTo(shown[0], false, true) // reagrupar
|
||
}
|
||
}, { once: true }))
|
||
resetZoom()
|
||
window.scrollTo(0, 0)
|
||
preload()
|
||
}
|
||
// --- Animación al pasar página. La página nueva se pone debajo y la vieja (o una hoja) se anima encima;
|
||
// al terminar solo queda la nueva. Si se pasa otra página antes de acabar, la animación se completa ya.
|
||
let turning = null // { anims, cleanup }
|
||
function finishTurn() {
|
||
if (!turning) return
|
||
const t = turning
|
||
turning = null
|
||
t.anims.forEach(a => { try { a.cancel() } catch {} })
|
||
t.cleanup()
|
||
}
|
||
// Animación hecha a mano (requestAnimationFrame) con la misma forma que las de la Web Animations API
|
||
// que usa finishTurn: cancel() y la promesa finished.
|
||
function frameAnim(duration, step) {
|
||
let raf = 0, settle
|
||
const finished = new Promise((res, rej) => { settle = { res, rej } })
|
||
const t0 = performance.now()
|
||
const tick = now => {
|
||
const g = Math.min(1, (now - t0) / duration)
|
||
step(g)
|
||
if (g < 1) raf = requestAnimationFrame(tick)
|
||
else settle.res()
|
||
}
|
||
step(0)
|
||
raf = requestAnimationFrame(tick)
|
||
return { finished, cancel() { cancelAnimationFrame(raf); settle.rej(new Error('cancelada')) } }
|
||
}
|
||
// Hoja de papel que se dobla: tiras verticales anidadas, cada una girada un poco respecto a la anterior,
|
||
// así que la hoja forma una curva. Cada tira enseña su trozo de la página por delante y del reverso
|
||
// (la página siguiente en doble página, o papel en blanco) por detrás.
|
||
function paperCurl(r, frontSrc, backSrc, spineLeft) {
|
||
const N = Math.max(10, Math.min(22, Math.round(r.w / 24)))
|
||
const w = r.w / N
|
||
const sgn = spineLeft ? -1 : 1 // hacia dónde gira: el borde libre se levanta hacia quien mira
|
||
const el = document.createElement('div')
|
||
el.className = 'curl'
|
||
const strips = []
|
||
let parent = el
|
||
for (let i = 0; i < N; i++) {
|
||
const fc = spineLeft ? i : N - 1 - i // columna de la página (desde la izquierda) de esta tira
|
||
const bc = N - 1 - fc // en el reverso, la hoja está dada la vuelta
|
||
const s = document.createElement('div')
|
||
s.className = 'strip'
|
||
Object.assign(s.style, { width: `${w}px`, height: `${r.h}px`, [spineLeft ? 'left' : 'right']: '0', transformOrigin: spineLeft ? 'left center' : 'right center' })
|
||
const img = (src, col) => `<img src="${src}" alt="" style="width:${r.w}px;height:${r.h}px;left:${-col * w}px">`
|
||
s.innerHTML = `<div class="sf front">${img(frontSrc, fc)}<i></i></div><div class="sf back${backSrc ? '' : ' paper'}">${backSrc ? img(backSrc, bc) : ''}<i></i></div>`
|
||
parent.append(s)
|
||
strips.push({ s, f: s.firstChild.lastChild, b: s.children[1].lastChild })
|
||
parent = s
|
||
}
|
||
const step = spineLeft ? w : -w
|
||
// Ángulo (0 = en su sitio, 180 = pasada) de cada tira en el avance g (0 a 1). El borde libre va por
|
||
// delante del lomo y la curva es máxima a mitad de camino. reverse: la hoja vuelve a su sitio (el
|
||
// mismo gesto al revés, con el borde libre llegando primero).
|
||
function angles(g, reverse) {
|
||
const e = g < 0.5 ? 4 * g ** 3 : 1 - (-2 * g + 2) ** 3 / 2
|
||
const bend = Math.sin(Math.PI * g) * 125
|
||
return strips.map((t, i) => {
|
||
const a = Math.min(180, Math.max(0, 180 * e + bend * (((i + 0.5) / N) ** 1.4 - 0.45)))
|
||
return reverse ? 180 - a : a
|
||
})
|
||
}
|
||
// Devuelve cuánto está levantada la hoja (0 a 1), para la sombra de debajo.
|
||
function set(g, reverse) {
|
||
let prev = 0, lift = 0
|
||
angles(g, reverse).forEach((a, i) => {
|
||
const t = strips[i]
|
||
t.s.style.transform = `${i ? `translateX(${step}px) ` : ''}rotateY(${(sgn * (a - prev)).toFixed(2)}deg)`
|
||
prev = a
|
||
const c = Math.cos((a * Math.PI) / 180)
|
||
t.f.style.opacity = ((1 - c) * 0.3).toFixed(3) // se oscurece al inclinarse
|
||
t.b.style.opacity = ((1 + c) * 0.3).toFixed(3)
|
||
lift = Math.max(lift, Math.sin((a * Math.PI) / 180))
|
||
})
|
||
return lift
|
||
}
|
||
// Tramo de g en el que se ve algo de la hoja sobre la página (alguna tira por debajo de 90°): con una
|
||
// sola página, lo que pasa del lomo queda fuera de la pantalla y no vale la pena esperarlo.
|
||
function span(reverse) {
|
||
const seen = g => angles(g, reverse).some(a => a < 88)
|
||
let g0 = 0, g1 = 1
|
||
while (g0 < 1 && !seen(g0)) g0 += 0.01
|
||
while (g1 > g0 && !seen(g1)) g1 -= 0.01
|
||
return [Math.max(0, g0 - 0.02), Math.min(1, g1 + 0.04)]
|
||
}
|
||
return { el, set, span }
|
||
}
|
||
function turnPage(old, stage, dir) {
|
||
// "Delante" es hacia la izquierda en izq→der (la hoja derecha pasa a la izquierda) y al revés en der→izq.
|
||
const sign = (settings.dir === 'rtl' ? -1 : 1) * dir
|
||
const fitWidth = box.classList.contains('fit-width')
|
||
let kind = settings.anim
|
||
const ready = im => im.complete && im.naturalWidth > 0
|
||
const oldImgs = [...old.querySelectorAll('img')]
|
||
// La hoja necesita las páginas ya cargadas y la misma disposición (1↔1 o 2↔2); si no, deslizar.
|
||
if (kind === 'flip' && (fitWidth || !oldImgs.every(ready))) kind = 'slide'
|
||
if (fitWidth && kind === 'slide') kind = 'fade' // páginas largas con scroll: un fundido queda mejor
|
||
old.classList.add('leaving')
|
||
box.prepend(stage) // la nueva debajo (antes en el DOM), la vieja encima
|
||
const double = oldImgs.length === 2 && shown.length === 2
|
||
// Una página hacia atrás: la hoja que vuelve es la nueva, que tiene que estar ya cargada.
|
||
if (kind === 'flip' && !double && dir < 0 && !ready(stage.querySelector('img'))) kind = 'slide'
|
||
const anims = []
|
||
const extra = []
|
||
const cleanup = () => { old.remove(); extra.forEach(e => e.remove()) }
|
||
const ease = 'cubic-bezier(.2,.7,.2,1)'
|
||
if (kind === 'fade') {
|
||
anims.push(old.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 180, easing: 'ease-out', fill: 'forwards' }))
|
||
} else if (kind === 'slide') {
|
||
anims.push(old.animate([{ transform: 'translateX(0)' }, { transform: `translateX(${-sign * 100}%)` }], { duration: 260, easing: ease, fill: 'forwards' }))
|
||
anims.push(stage.animate([{ transform: `translateX(${sign * 100}%)` }, { transform: 'translateX(0)' }], { duration: 260, easing: ease }))
|
||
} else {
|
||
// Pasar hoja: la hoja se dobla como papel (se levanta primero el borde libre y el lomo la sigue).
|
||
// Medidas relativas al contenedor.
|
||
const B = box.getBoundingClientRect()
|
||
const rel = im => { const r = im.getBoundingClientRect(); return { l: r.left - B.left, t: r.top - B.top, w: r.width, h: r.height } }
|
||
const byX = imgs => [...imgs].sort((a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left)
|
||
const place = (el, r) => Object.assign(el.style, { left: `${r.l}px`, top: `${r.t}px`, width: `${r.w}px`, height: `${r.h}px` })
|
||
let rect, frontSrc, backSrc = null, spineLeft, reverse = false
|
||
if (double) {
|
||
// Doble página: gira la hoja del lado hacia el que se avanza (derecha si sign > 0); su reverso es
|
||
// la página nueva del otro lado del lomo, y la página vieja de ese lado sigue a la vista (copia
|
||
// encima de la nueva) hasta que la hoja cae.
|
||
const oldX = byX(oldImgs)
|
||
const newX = byX(stage.querySelectorAll('img'))
|
||
const front = sign > 0 ? oldX.at(-1) : oldX[0]
|
||
const stay = sign > 0 ? oldX[0] : oldX.at(-1)
|
||
rect = rel(front)
|
||
frontSrc = front.src
|
||
backSrc = (sign > 0 ? newX[0] : newX.at(-1)).src
|
||
spineLeft = sign > 0
|
||
const keep = document.createElement('img')
|
||
keep.className = 'leafkeep'
|
||
keep.src = stay.src
|
||
place(keep, rel(stay))
|
||
box.append(keep)
|
||
extra.push(keep)
|
||
old.style.visibility = 'hidden' // la hoja y la copia ocupan su lugar
|
||
} else {
|
||
// Una página: el lomo está a la izquierda en izq→der y a la derecha en der→izq. Hacia delante, la
|
||
// página actual se levanta y se va; hacia atrás, la anterior vuelve y se coloca encima (el mismo
|
||
// gesto al revés, con el borde libre por delante), con la actual debajo hasta que la tapa.
|
||
const img = dir > 0 ? oldImgs[0] : stage.querySelector('img')
|
||
rect = rel(img)
|
||
frontSrc = img.src
|
||
spineLeft = settings.dir !== 'rtl'
|
||
reverse = dir < 0
|
||
if (dir > 0) old.style.visibility = 'hidden'
|
||
}
|
||
// Sombra que la hoja levantada proyecta sobre lo que hay debajo.
|
||
const drop = document.createElement('i')
|
||
drop.className = `curldrop ${spineLeft ? 'l' : 'r'}`
|
||
place(drop, rect)
|
||
const curl = paperCurl(rect, frontSrc, backSrc, spineLeft)
|
||
place(curl.el, rect)
|
||
box.append(drop, curl.el)
|
||
extra.push(drop, curl.el)
|
||
const [g0, g1] = double ? [0, 1] : curl.span(reverse)
|
||
anims.push(frameAnim(double ? 700 : 560, t => {
|
||
const lift = curl.set(g0 + (g1 - g0) * t, reverse)
|
||
drop.style.opacity = (lift * 0.8).toFixed(3)
|
||
}))
|
||
}
|
||
turning = { anims, cleanup }
|
||
Promise.all(anims.map(a => a.finished)).then(() => { if (turning?.anims === anims) { turning = null; cleanup() } }, () => {})
|
||
}
|
||
function showEnd() {
|
||
finishTurn()
|
||
atEnd = true
|
||
resetZoom()
|
||
box.innerHTML = endBlock
|
||
reader.classList.remove('hide-ui')
|
||
}
|
||
|
||
function goTo(n, fromScroll = false, force = false, dir = 0) {
|
||
if (isPaged()) {
|
||
if (n >= total) return showEnd()
|
||
n = Math.min(Math.max(n, 0), total - 1)
|
||
const g = groupOf(n)
|
||
const changed = atEnd || g[0] !== shown[0] || g.length !== shown.length
|
||
const wasEnd = atEnd
|
||
atEnd = false
|
||
shown = g
|
||
page = g[0]
|
||
setPageLabel()
|
||
if (changed || force || !$('.stage img', box)) showPaged(changed && !wasEnd ? dir : 0)
|
||
if (changed) scheduleSave()
|
||
return
|
||
}
|
||
n = Math.min(Math.max(n, 0), total - 1)
|
||
const changed = n !== page
|
||
page = n
|
||
shown = [n]
|
||
setPageLabel()
|
||
if (!fromScroll) box.querySelectorAll('img')[page]?.scrollIntoView()
|
||
if (changed) scheduleSave()
|
||
}
|
||
const forward = () => { if (!atEnd) goTo(shown.at(-1) + 1, false, false, 1) }
|
||
const backward = () => {
|
||
if (atEnd) { box.innerHTML = ''; goTo(total - 1, false, true); return }
|
||
if (page > 0) goTo(groupOf(page - 1)[0], false, false, -1)
|
||
}
|
||
|
||
function render() {
|
||
observer?.disconnect()
|
||
const keep = lastShown()
|
||
resetZoom()
|
||
box.className = isPaged() ? `paged ${settings.fit === 'width' ? 'fit-width' : ''} ${isDouble() ? 'double' : ''}` : `vertical fit-${settings.fit}`
|
||
renderModeQuick()
|
||
box.dataset.dir = settings.dir
|
||
// En der → izq la barra de progreso también avanza de derecha a izquierda (en vertical no aplica).
|
||
slider.dir = isPaged() && settings.dir === 'rtl' ? 'rtl' : 'ltr'
|
||
$('#dir').hidden = !isPaged()
|
||
if (isPaged()) {
|
||
box.innerHTML = ''
|
||
atEnd = false
|
||
shown = []
|
||
goTo(keep)
|
||
return
|
||
}
|
||
page = keep
|
||
shown = [keep]
|
||
box.innerHTML = pages.map((src, i) => `<img data-i="${i}" ${i < page + 3 ? `src="${src}"` : `data-src="${src}"`} alt="">`).join('') + endBlock
|
||
box.querySelectorAll('img').forEach(img => img.addEventListener('load', () => img.setAttribute('data-loaded', ''), { once: true }))
|
||
const lazy = new IntersectionObserver(entries => entries.forEach(e => {
|
||
if (e.isIntersecting && e.target.dataset.src) { e.target.src = e.target.dataset.src; delete e.target.dataset.src }
|
||
}), { rootMargin: '2000px 0px' })
|
||
// No seguir el scroll hasta haber saltado a la página guardada, para no pisarla con la 0.
|
||
let ready = false
|
||
observer = new IntersectionObserver(entries => {
|
||
if (!ready) return
|
||
for (const e of entries) if (e.isIntersecting) goTo(Number(e.target.dataset.i), true)
|
||
}, { rootMargin: '-45% 0px -45% 0px' })
|
||
const imgs = [...box.querySelectorAll('img')]
|
||
imgs.forEach(img => { lazy.observe(img); observer.observe(img) })
|
||
const lazyDisconnect = observer.disconnect.bind(observer)
|
||
observer.disconnect = () => { lazyDisconnect(); lazy.disconnect() }
|
||
const before = imgs.slice(0, page + 1).map(img => img.complete ? null : new Promise(r => { img.addEventListener('load', r, { once: true }); img.addEventListener('error', r, { once: true }) }))
|
||
Promise.race([Promise.all(before), new Promise(r => setTimeout(r, 8000))]).then(() => {
|
||
imgs[page]?.scrollIntoView()
|
||
lastY = window.scrollY
|
||
setTimeout(() => (ready = true), 300)
|
||
})
|
||
setPageLabel()
|
||
}
|
||
|
||
// --- Zoom (modos por página): pellizco, doble toque/clic en el centro, Ctrl+rueda y teclas + - 0.
|
||
const zoom = { s: 1, x: 0, y: 0 }
|
||
const stageEl = () => $('.stage:not(.leaving)', box)
|
||
function applyZoom() {
|
||
const st = stageEl()
|
||
if (st) st.style.transform = zoom.s === 1 ? '' : `translate(${zoom.x}px, ${zoom.y}px) scale(${zoom.s})`
|
||
box.classList.toggle('zoomed', zoom.s > 1)
|
||
$('#zoomreset').hidden = zoom.s === 1
|
||
}
|
||
function clampPan() {
|
||
const st = stageEl()
|
||
if (!st) return
|
||
const W = st.offsetWidth, H = st.offsetHeight
|
||
zoom.x = Math.min(0, Math.max(W - W * zoom.s, zoom.x))
|
||
zoom.y = Math.min(0, Math.max(H - H * zoom.s, zoom.y))
|
||
}
|
||
// (px, py): punto en coordenadas del contenedor que queda fijo al cambiar la escala.
|
||
function zoomAt(s, px, py) {
|
||
s = Math.min(5, Math.max(1, s))
|
||
const cx = (px - zoom.x) / zoom.s, cy = (py - zoom.y) / zoom.s
|
||
zoom.s = s
|
||
zoom.x = px - cx * s
|
||
zoom.y = py - cy * s
|
||
if (s === 1) { zoom.x = 0; zoom.y = 0 }
|
||
clampPan()
|
||
applyZoom()
|
||
}
|
||
function resetZoom() { zoom.s = 1; zoom.x = 0; zoom.y = 0; applyZoom() }
|
||
const local = (cx, cy) => { const r = box.getBoundingClientRect(); return [cx - r.left, cy - r.top] }
|
||
|
||
const toggleUi = () => { reader.classList.toggle('hide-ui'); if (reader.classList.contains('hide-ui')) $('.rpanel', reader).hidden = true }
|
||
const pts = new Map()
|
||
let gesture = null, tap = null, lastTap = { t: 0, x: 0, y: 0 }, uiTimer = null
|
||
function onTap(cx, cy) {
|
||
const now = Date.now()
|
||
const dbl = now - lastTap.t < 300 && Math.hypot(cx - lastTap.x, cy - lastTap.y) < 40
|
||
lastTap = dbl ? { t: 0, x: 0, y: 0 } : { t: now, x: cx, y: cy }
|
||
const xr = cx / window.innerWidth
|
||
const center = xr > 0.3 && xr < 0.7
|
||
if (dbl && (center || zoom.s > 1)) {
|
||
clearTimeout(uiTimer)
|
||
if (zoom.s > 1) resetZoom()
|
||
else zoomAt(2.5, ...local(cx, cy))
|
||
return
|
||
}
|
||
if (zoom.s > 1) return // con zoom, un toque no pasa página
|
||
if (center) { clearTimeout(uiTimer); uiTimer = setTimeout(toggleUi, 280); return }
|
||
;(xr >= 0.7) === (settings.dir === 'ltr') ? forward() : backward()
|
||
}
|
||
box.addEventListener('pointerdown', e => {
|
||
if (!isPaged() || atEnd || e.target.closest('a,button,select,input')) return
|
||
if (e.pointerType === 'mouse' && e.button !== 0) return
|
||
pts.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||
if (pts.size === 1) {
|
||
tap = { x: e.clientX, y: e.clientY, t: Date.now(), moved: false }
|
||
gesture = zoom.s > 1 ? { type: 'pan', x: e.clientX, y: e.clientY } : null
|
||
if (gesture) box.setPointerCapture?.(e.pointerId)
|
||
} else if (pts.size === 2) {
|
||
const [a, b] = [...pts.values()]
|
||
gesture = { type: 'pinch', d: Math.hypot(a.x - b.x, a.y - b.y), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 }
|
||
tap = null
|
||
}
|
||
})
|
||
box.addEventListener('pointermove', e => {
|
||
if (!pts.has(e.pointerId)) return
|
||
pts.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||
if (tap && Math.hypot(e.clientX - tap.x, e.clientY - tap.y) > 10) tap.moved = true
|
||
if (gesture?.type === 'pinch' && pts.size >= 2) {
|
||
const [a, b] = [...pts.values()]
|
||
const d = Math.hypot(a.x - b.x, a.y - b.y), mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2
|
||
zoom.x += mx - gesture.mx
|
||
zoom.y += my - gesture.my
|
||
zoomAt(zoom.s * (d / gesture.d), ...local(mx, my))
|
||
Object.assign(gesture, { d, mx, my })
|
||
} else if (gesture?.type === 'pan') {
|
||
zoom.x += e.clientX - gesture.x
|
||
zoom.y += e.clientY - gesture.y
|
||
gesture.x = e.clientX
|
||
gesture.y = e.clientY
|
||
clampPan()
|
||
applyZoom()
|
||
}
|
||
})
|
||
const endPointer = e => {
|
||
if (!pts.delete(e.pointerId)) return
|
||
if (pts.size === 1 && zoom.s > 1) {
|
||
const [p] = [...pts.values()]
|
||
gesture = { type: 'pan', x: p.x, y: p.y }
|
||
} else if (pts.size === 0) {
|
||
if (e.type === 'pointerup' && tap && !tap.moved && Date.now() - tap.t < 350) onTap(e.clientX, e.clientY)
|
||
else if (e.type === 'pointerup' && tap?.moved && zoom.s === 1 && e.pointerType !== 'mouse') {
|
||
// Deslizar el dedo en horizontal pasa página (hacia la izquierda = adelante en izq→der).
|
||
const dx = e.clientX - tap.x, dy = e.clientY - tap.y
|
||
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && Date.now() - tap.t < 800) ((dx < 0) === (settings.dir === 'ltr') ? forward : backward)()
|
||
}
|
||
gesture = null
|
||
tap = null
|
||
}
|
||
}
|
||
box.addEventListener('pointerup', endPointer)
|
||
box.addEventListener('pointercancel', endPointer)
|
||
box.addEventListener('wheel', e => {
|
||
if (!isPaged() || atEnd || !e.ctrlKey) return // Ctrl+rueda o pellizco del trackpad
|
||
e.preventDefault()
|
||
zoomAt(zoom.s * Math.exp(-e.deltaY / 200), ...local(e.clientX, e.clientY))
|
||
}, { passive: false })
|
||
// En modo vertical el zoom es el nativo del navegador; un toque muestra/oculta las barras.
|
||
box.addEventListener('click', e => {
|
||
if (isPaged() || e.target.closest('a,button')) return
|
||
reader.classList.toggle('hide-ui')
|
||
})
|
||
$('#zoomreset').addEventListener('click', resetZoom)
|
||
$('#ropts').addEventListener('click', () => { const p = $('.rpanel', reader); p.hidden = !p.hidden; $('#ropts').classList.toggle('on', !p.hidden) })
|
||
|
||
const saveGlobal = () => store.set('readerSettings', { saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false, anim: settings.anim })
|
||
const savePrefs = () => {
|
||
api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } })
|
||
.then(() => toast('Guardado para este manga')).catch(err => toast(err.message))
|
||
}
|
||
$('#mode').addEventListener('change', e => { settings.mode = e.target.value; if (isPaged()) lastPaged = settings.mode; savePrefs(); render() })
|
||
$('#modequick').addEventListener('click', () => {
|
||
settings.mode = isPaged() ? 'vertical' : lastPaged
|
||
savePrefs()
|
||
render()
|
||
})
|
||
$('#fit').addEventListener('change', e => { settings.fit = e.target.value; savePrefs(); render() })
|
||
$('#dir').addEventListener('change', e => { settings.dir = e.target.value; box.dataset.dir = settings.dir; slider.dir = settings.dir; savePrefs() })
|
||
$('#filter').addEventListener('change', e => { settings.filter = e.target.value; saveGlobal(); applyFilter() })
|
||
$('#anim').value = settings.anim
|
||
$('#anim').addEventListener('change', e => { settings.anim = e.target.value; saveGlobal() })
|
||
$('#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) })
|
||
$('#offone')?.addEventListener('click', e => {
|
||
const b = e.target.closest('button')
|
||
b.disabled = true
|
||
b.innerHTML = `${icon('phone')} Guardando…`
|
||
const done = () => { if (offHas(chapterId) && b.isConnected) { b.innerHTML = `${icon('phone')} En el dispositivo`; offState.listeners.delete(done) } }
|
||
offState.listeners.add(done)
|
||
offEnqueue([{ mangaId, mangaTitle: manga.title, cover: manga.cover, lang, chapterId, label: chapLabel(chapter), num: chapter.num }])
|
||
})
|
||
slider.addEventListener('input', () => goTo(Number(slider.value) - 1))
|
||
|
||
// --- Recortes: se arrastra un rectángulo sobre la página; se recorta del original (no de la versión HD)
|
||
// y el servidor lo reescala aparte. El rectángulo se guarda en coordenadas del documento para
|
||
// poder desplazarse (rueda) en vertical mientras se recorta.
|
||
let crop = null
|
||
function endCrop() {
|
||
if (!crop) return
|
||
crop.ov.remove()
|
||
window.removeEventListener('scroll', crop.draw)
|
||
crop = null
|
||
$('#clipbtn')?.classList.remove('on')
|
||
}
|
||
function startCrop() {
|
||
if (crop) return endCrop()
|
||
if (atEnd || !box.querySelector('img')) return toast('Vuelve a una página para recortar')
|
||
reader.classList.remove('hide-ui')
|
||
$('.rpanel', reader).hidden = true
|
||
const ov = document.createElement('div')
|
||
ov.className = 'cropov'
|
||
ov.innerHTML = `<div class="crop-bar"><span>${icon('scissors')} Arrastra sobre el panel que quieres guardar</span>
|
||
<button class="btn small" data-a="cancel">${icon('x')} Cancelar</button>
|
||
<button class="btn small primary" data-a="save" disabled>${icon('check')} Guardar</button></div>
|
||
<div class="crop-rect" hidden></div>`
|
||
reader.append(ov)
|
||
$('#clipbtn').classList.add('on')
|
||
const rectEl = $('.crop-rect', ov)
|
||
crop = { ov, r: null, start: null }
|
||
crop.draw = () => {
|
||
const r = crop?.r
|
||
if (!r) return
|
||
Object.assign(rectEl.style, { left: `${r.l - scrollX}px`, top: `${r.t - scrollY}px`, width: `${r.r - r.l}px`, height: `${r.b - r.t}px` })
|
||
rectEl.hidden = false
|
||
}
|
||
window.addEventListener('scroll', crop.draw, { passive: true })
|
||
ov.addEventListener('wheel', e => { e.preventDefault(); scrollBy(0, e.deltaY) }, { passive: false })
|
||
ov.addEventListener('pointerdown', e => {
|
||
if (e.target.closest('.crop-bar') || (e.pointerType === 'mouse' && e.button !== 0)) return
|
||
ov.setPointerCapture(e.pointerId)
|
||
crop.start = { x: e.clientX + scrollX, y: e.clientY + scrollY }
|
||
crop.r = null
|
||
rectEl.hidden = true
|
||
$('[data-a=save]', ov).disabled = true
|
||
})
|
||
ov.addEventListener('pointermove', e => {
|
||
if (!crop.start) return
|
||
const x = e.clientX + scrollX, y = e.clientY + scrollY
|
||
crop.r = { l: Math.min(x, crop.start.x), t: Math.min(y, crop.start.y), r: Math.max(x, crop.start.x), b: Math.max(y, crop.start.y) }
|
||
crop.draw()
|
||
})
|
||
const up = () => {
|
||
if (!crop?.start) return
|
||
crop.start = null
|
||
const ok = crop.r && crop.r.r - crop.r.l >= 12 && crop.r.b - crop.r.t >= 12
|
||
if (!ok) { crop.r = null; rectEl.hidden = true }
|
||
$('[data-a=save]', ov).disabled = !ok
|
||
}
|
||
ov.addEventListener('pointerup', up)
|
||
ov.addEventListener('pointercancel', up)
|
||
$('[data-a=cancel]', ov).addEventListener('click', endCrop)
|
||
$('[data-a=save]', ov).addEventListener('click', e => saveCrop(e.target.closest('button')))
|
||
}
|
||
// La imagen bajo el rectángulo (la de mayor intersección) y la zona en fracciones de esa imagen.
|
||
function cropTarget(r) {
|
||
const c = { l: r.l - scrollX, t: r.t - scrollY, r: r.r - scrollX, b: r.b - scrollY }
|
||
let best = null
|
||
for (const img of box.querySelectorAll('img')) {
|
||
if (!img.complete || !img.naturalWidth) continue
|
||
const b = img.getBoundingClientRect()
|
||
const w = Math.min(c.r, b.right) - Math.max(c.l, b.left), h = Math.min(c.b, b.bottom) - Math.max(c.t, b.top)
|
||
if (w > 0 && h > 0 && (!best || w * h > best.area)) best = { img, b, area: w * h }
|
||
}
|
||
if (!best) return null
|
||
const { img, b } = best
|
||
const x0 = Math.max(c.l, b.left) - b.left, y0 = Math.max(c.t, b.top) - b.top
|
||
const x1 = Math.min(c.r, b.right) - b.left, y1 = Math.min(c.b, b.bottom) - b.top
|
||
return { img, page: Number(img.dataset.i), fx: x0 / b.width, fy: y0 / b.height, fw: (x1 - x0) / b.width, fh: (y1 - y0) / b.height }
|
||
}
|
||
async function saveCrop(btn) {
|
||
const t = crop?.r && cropTarget(crop.r)
|
||
if (!t) return toast('Marca una zona sobre la página')
|
||
btn.disabled = true
|
||
try {
|
||
const u = new URL(t.img.currentSrc || t.img.src, location.href)
|
||
u.searchParams.delete('hd') // recortar del original: el servidor lo reescala él mismo
|
||
const src = await new Promise((resolve, reject) => {
|
||
const im = new Image()
|
||
im.onload = () => resolve(im)
|
||
im.onerror = () => reject(new Error('No se pudo cargar la página'))
|
||
im.src = u.href
|
||
})
|
||
const sx = Math.round(t.fx * src.naturalWidth), sy = Math.round(t.fy * src.naturalHeight)
|
||
const sw = Math.max(1, Math.round(t.fw * src.naturalWidth)), sh = Math.max(1, Math.round(t.fh * src.naturalHeight))
|
||
if (sw < 16 || sh < 16) throw new Error('La zona es demasiado pequeña')
|
||
const k = Math.min(1, 4000 / Math.max(sw, sh))
|
||
const cv = document.createElement('canvas')
|
||
cv.width = Math.round(sw * k); cv.height = Math.round(sh * k)
|
||
cv.getContext('2d').drawImage(src, sx, sy, sw, sh, 0, 0, cv.width, cv.height)
|
||
const blob = await new Promise(r => cv.toBlob(r, 'image/png'))
|
||
const qs = new URLSearchParams({ mangaId, chapterId, page: t.page, mangaTitle: manga.title, chapterLabel: chapLabel(chapter), cover: manga.cover || '' })
|
||
const res = await fetch(`/api/clips?${qs}`, { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob })
|
||
const data = await res.json().catch(() => ({}))
|
||
if (!res.ok) throw new Error(data.error || `Error ${res.status}`)
|
||
endCrop()
|
||
toast(data.status === 'off' ? 'Recorte guardado' : 'Recorte guardado: se está mejorando a HD')
|
||
} catch (err) {
|
||
toast(err.message)
|
||
btn.disabled = false
|
||
}
|
||
}
|
||
$('#clipbtn').addEventListener('click', startCrop)
|
||
|
||
const onKey = e => {
|
||
if (crop) { if (e.key === 'Escape') endCrop(); return }
|
||
if (!isPaged() || e.target.matches('input,select')) return
|
||
const fwdKey = settings.dir === 'ltr' ? 'ArrowRight' : 'ArrowLeft'
|
||
const backKey = settings.dir === 'ltr' ? 'ArrowLeft' : 'ArrowRight'
|
||
const [cx, cy] = [box.clientWidth / 2, Math.min(box.clientHeight, window.innerHeight) / 2]
|
||
if (e.key === fwdKey || e.key === ' ') { e.preventDefault(); forward() }
|
||
else if (e.key === backKey) backward()
|
||
else if (e.key === '+' || e.key === '=') zoomAt(zoom.s * 1.25, cx, cy)
|
||
else if (e.key === '-') zoomAt(zoom.s / 1.25, cx, cy)
|
||
else if (e.key === '0') resetZoom()
|
||
}
|
||
document.addEventListener('keydown', onKey)
|
||
let lastY = 0
|
||
const onScroll = () => {
|
||
if (settings.mode !== 'vertical') return
|
||
const y = window.scrollY
|
||
if (Math.abs(y - lastY) > 30) { reader.classList.toggle('hide-ui', y > lastY); lastY = y }
|
||
}
|
||
window.addEventListener('scroll', onScroll, { passive: true })
|
||
// Al girar la tablet o redimensionar, rehacer la agrupación de doble página.
|
||
let resizeTimer = null
|
||
const onResize = () => {
|
||
clearTimeout(resizeTimer)
|
||
resizeTimer = setTimeout(() => {
|
||
if (!isPaged() || atEnd) return
|
||
box.classList.toggle('double', isDouble())
|
||
goTo(lastShown(), false, true)
|
||
}, 200)
|
||
}
|
||
window.addEventListener('resize', onResize)
|
||
|
||
render()
|
||
cleanup = () => {
|
||
endCrop()
|
||
if (saveTimer) saveNow()
|
||
observer?.disconnect()
|
||
clearTimeout(uiTimer)
|
||
document.removeEventListener('visibilitychange', onVis)
|
||
window.removeEventListener('pagehide', beacon)
|
||
document.removeEventListener('keydown', onKey)
|
||
window.removeEventListener('scroll', onScroll)
|
||
window.removeEventListener('resize', onResize)
|
||
}
|
||
}
|
||
|
||
// ---------- Recortes ----------
|
||
const clipStatus = c => c.status === 'done' ? `<span class="hd" title="Reescalado ×${c.scale}">HD</span>`
|
||
: c.status === 'error' ? '<span class="hd pending" title="Falló el reescalado">HD!</span>'
|
||
: c.status === 'off' ? '' : '<span class="hd pending" title="Reescalando…">HD…</span>'
|
||
async function viewClips(params) {
|
||
const mangaId = params.get('manga')
|
||
const here = () => location.hash.split('?')[0] === '#/clips'
|
||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||
let data = await api(`/api/clips${mangaId ? `?manga=${encodeURIComponent(mangaId)}` : ''}`)
|
||
if (!here()) return
|
||
const total = data.mangas.reduce((a, m) => a + m.n, 0)
|
||
app.innerHTML = `<h1>${icon('scissors')} Recortes</h1>
|
||
${data.mangas.length > 1 || mangaId ? `<div class="tabs">
|
||
<button data-m="" class="${mangaId ? '' : 'on'}">Todos (${total})</button>
|
||
${data.mangas.map(m => `<button data-m="${esc(m.id)}" class="${m.id === mangaId ? 'on' : ''}">${esc(m.title)} (${m.n})</button>`).join('')}</div>` : ''}
|
||
<div id="cgrid" class="clips"></div>
|
||
<div id="lb" class="lightbox" hidden></div>`
|
||
app.querySelectorAll('.tabs [data-m]').forEach(b => b.addEventListener('click', () => { location.hash = b.dataset.m ? `#/clips?manga=${encodeURIComponent(b.dataset.m)}` : '#/clips' }))
|
||
const grid = $('#cgrid'), lb = $('#lb')
|
||
let open = -1, showHd = true
|
||
|
||
function renderGrid() {
|
||
grid.innerHTML = data.clips.length ? data.clips.map((c, i) => `
|
||
<figure class="clip" data-i="${i}">
|
||
<img src="${c.hd || c.img}" alt="" loading="lazy" style="aspect-ratio:${c.width}/${c.height}">
|
||
<figcaption><b>${esc(c.mangaTitle)}</b><span>${esc((c.chapterLabel || '').split(' — ')[0])} · p. ${c.page + 1} ${clipStatus(c)}</span>
|
||
${c.note ? `<i>${esc(c.note)}</i>` : ''}</figcaption>
|
||
</figure>`).join('')
|
||
: `<p class="muted">Aún no tienes recortes. En el lector, toca ${icon('scissors')} y arrastra sobre un panel para guardarlo aquí; se mejora a HD automáticamente.</p>`
|
||
grid.querySelectorAll('.clip').forEach(f => f.addEventListener('click', () => openClip(Number(f.dataset.i))))
|
||
}
|
||
function openClip(i) {
|
||
open = i
|
||
const c = data.clips[i]
|
||
if (!c) return closeClip()
|
||
const src = c.hd && showHd ? c.hd : c.img
|
||
const size = c.scale ? `${c.width}×${c.height} → ${c.width * c.scale}×${c.height * c.scale} (×${c.scale})` : `${c.width}×${c.height}`
|
||
lb.innerHTML = `
|
||
<div class="lb-img"><img src="${src}" alt=""></div>
|
||
<div class="lb-side">
|
||
<div class="row" style="justify-content:space-between">
|
||
<span class="muted">${i + 1} / ${data.clips.length}</span>
|
||
<span class="row" style="gap:6px">
|
||
<button class="btn small" data-a="prev" ${i ? '' : 'disabled'} title="Anterior">${icon('prev')}</button>
|
||
<button class="btn small" data-a="next" ${i < data.clips.length - 1 ? '' : 'disabled'} title="Siguiente">${icon('next')}</button>
|
||
<button class="btn small" data-a="close" title="Cerrar">${icon('x')}</button>
|
||
</span>
|
||
</div>
|
||
<h2 style="margin:4px 0 0"><a href="#/manga/${c.mangaId}">${esc(c.mangaTitle)}</a></h2>
|
||
<div class="muted">${esc(c.chapterLabel || '')} · página ${c.page + 1}</div>
|
||
<div class="muted" style="font-size:.85rem">${size} · ${timeAgo(c.createdAt)} ${clipStatus(c)}</div>
|
||
${c.status === 'error' ? `<p class="error" style="font-size:.85rem">${esc(c.error || 'Error al reescalar')}</p>` : ''}
|
||
${c.status === 'off' ? '<p class="muted" style="font-size:.85rem">Reescalado no disponible en este servidor.</p>' : ''}
|
||
<input id="note" placeholder="Nota (opcional)" maxlength="1000" value="${esc(c.note || '')}">
|
||
<a class="btn primary" href="${readUrl(c.mangaId, c.chapterId, c.page)}">${icon('book')} Ir a la página</a>
|
||
${c.hd ? `<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="showhd" ${showHd ? 'checked' : ''}> Ver versión HD</label>` : ''}
|
||
<div class="row" style="gap:6px;flex-wrap:wrap">
|
||
<a class="btn small" href="${(c.hd && showHd ? c.hd + '&' : c.img + '?')}dl=1" download>${icon('download')} Descargar</a>
|
||
${c.status === 'error' || (c.status === 'off' && data.available) ? `<button class="btn small" data-a="retry">${icon('refresh')} Reintentar HD</button>` : ''}
|
||
<button class="btn small danger" data-a="del">${icon('trash')} Eliminar</button>
|
||
</div>
|
||
</div>`
|
||
lb.hidden = false
|
||
document.body.classList.add('noscroll')
|
||
lb.querySelector('[data-a=prev]').addEventListener('click', () => openClip(open - 1))
|
||
lb.querySelector('[data-a=next]').addEventListener('click', () => openClip(open + 1))
|
||
lb.querySelector('[data-a=close]').addEventListener('click', closeClip)
|
||
$('#showhd', lb)?.addEventListener('change', e => { showHd = e.target.checked; openClip(open) })
|
||
$('#note', lb).addEventListener('change', async e => {
|
||
try { Object.assign(c, await api(`/api/clips/${c.id}`, { method: 'PATCH', body: { note: e.target.value } })); renderGrid(); toast('Nota guardada') } catch (err) { toast(err.message) }
|
||
})
|
||
lb.querySelector('[data-a=retry]')?.addEventListener('click', async () => {
|
||
try { Object.assign(c, await api(`/api/clips/${c.id}/retry`, { method: 'POST' })); renderGrid(); openClip(open); poll() } catch (err) { toast(err.message) }
|
||
})
|
||
lb.querySelector('[data-a=del]').addEventListener('click', async () => {
|
||
if (!confirm('¿Eliminar este recorte?')) return
|
||
try {
|
||
await api(`/api/clips/${c.id}`, { method: 'DELETE' })
|
||
data.clips.splice(open, 1)
|
||
renderGrid()
|
||
data.clips.length ? openClip(Math.min(open, data.clips.length - 1)) : closeClip()
|
||
} catch (err) { toast(err.message) }
|
||
})
|
||
}
|
||
function closeClip() { open = -1; lb.hidden = true; lb.innerHTML = ''; document.body.classList.remove('noscroll') }
|
||
lb.addEventListener('click', e => { if (e.target === lb || e.target.classList.contains('lb-img')) closeClip() })
|
||
const onKey = e => {
|
||
if (open < 0 || e.target.matches('input')) return
|
||
if (e.key === 'Escape') closeClip()
|
||
else if (e.key === 'ArrowLeft' && open > 0) openClip(open - 1)
|
||
else if (e.key === 'ArrowRight' && open < data.clips.length - 1) openClip(open + 1)
|
||
}
|
||
document.addEventListener('keydown', onKey)
|
||
|
||
// Mientras haya recortes en cola, refrescar su estado.
|
||
let timer = null
|
||
function poll() {
|
||
clearTimeout(timer)
|
||
if (!data.clips.some(c => c.status === 'queued' || c.status === 'running')) return
|
||
timer = setTimeout(async () => {
|
||
if (!here()) return
|
||
const fresh = await api(`/api/clips${mangaId ? `?manga=${encodeURIComponent(mangaId)}` : ''}`).catch(() => null)
|
||
if (!fresh || !here()) return
|
||
const key = d => d.clips.map(c => `${c.id}:${c.status}`).join()
|
||
if (key(fresh) !== key(data)) {
|
||
const openId = data.clips[open]?.id
|
||
data = fresh
|
||
renderGrid()
|
||
if (openId != null) { const j = data.clips.findIndex(c => c.id === openId); j >= 0 ? openClip(j) : closeClip() }
|
||
}
|
||
poll()
|
||
}, 3000)
|
||
}
|
||
renderGrid()
|
||
poll()
|
||
cleanup = () => { clearTimeout(timer); document.removeEventListener('keydown', onKey); document.body.classList.remove('noscroll') }
|
||
}
|
||
|
||
// ---------- Librería ----------
|
||
// Pestañas: Leyendo, Siguiendo, Terminados, En el dispositivo y las listas propias.
|
||
const libCard = ({ href, cover, title, sub, pct, badge, del }) => `<a class="card hcard" href="${href}">
|
||
<img class="cover" loading="lazy" src="${esc(cover || '/icon.svg')}" alt="" data-fallback>
|
||
${badge ? `<span class="badge corner">${badge}</span>` : ''}
|
||
${del ? `<button class="hdel" ${del.attrs} title="${esc(del.title)}" aria-label="${esc(del.title)}">${icon(del.icon || 'trash')}</button>` : ''}
|
||
<div class="bar" ${pct == null ? 'style="visibility:hidden"' : ''}><i style="width:${pct || 0}%"></i></div>
|
||
<div class="t">${esc(title)}</div>
|
||
${sub ? `<div class="sub">${sub}</div>` : ''}
|
||
</a>`
|
||
async function viewLibrary(params) {
|
||
const tab = params.get('tab') || 'reading'
|
||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||
const [hist, fol, lists] = serverReachable
|
||
? await Promise.all([api('/api/history').catch(() => null), api('/api/follows').catch(() => null), api('/api/lists').catch(() => [])])
|
||
: [null, null, []]
|
||
if (!location.hash.startsWith('#/library')) return
|
||
const online = !!hist
|
||
const dev = OFFLINE_OK ? offByManga() : []
|
||
const reading = (hist || []).filter(h => !h.finished)
|
||
const finished = (hist || []).filter(h => h.finished)
|
||
const follows = fol?.follows || []
|
||
const tabs = [
|
||
['reading', 'Leyendo', 'book', reading.length],
|
||
['following', 'Siguiendo', 'bell', follows.length],
|
||
['finished', 'Terminados', 'check', finished.length],
|
||
...(OFFLINE_OK && (dev.length || tab === 'device') ? [['device', 'En el dispositivo', 'phone', dev.length]] : []),
|
||
...lists.map(l => [`list-${l.id}`, l.name, 'bookmark', l.count]),
|
||
]
|
||
const curList = tab.startsWith('list-') ? lists.find(l => `list-${l.id}` === tab) : null
|
||
app.innerHTML = `<h1>Librería</h1>
|
||
<div class="tabs libtabs">${tabs.map(([k, label, ic, n]) => `<button data-tab="${k}" class="${k === tab ? 'on' : ''}">${icon(ic)} ${esc(label)}${n ? ` <span class="muted">${n}</span>` : ''}</button>`).join('')}
|
||
${online ? `<button id="newlist" title="Crear una lista">${icon('plus')} Nueva lista</button>` : ''}</div>
|
||
${curList ? `<div class="row lhead"><span class="muted small">Lista propia · añade mangas desde su ficha con ${icon('bookmark')} Listas</span>
|
||
<button class="btn small" id="lren">${icon('pencil')} Renombrar</button><button class="btn small danger" id="ldel">${icon('trash')} Eliminar lista</button></div>` : ''}
|
||
<div id="libbody"><div class="loading">Cargando…</div></div>`
|
||
app.querySelectorAll('[data-tab]').forEach(b => b.addEventListener('click', () => { location.hash = `#/library?tab=${b.dataset.tab}` }))
|
||
const body = $('#libbody')
|
||
const grid = (cards, empty) => (cards.length ? `<div class="grid">${cards.join('')}</div>` : `<p class="muted">${empty}</p>`)
|
||
const histCard = h => libCard({
|
||
href: `#/manga/${h.manga_id}`, cover: h.cover, title: h.manga_title,
|
||
pct: h.total_pages ? Math.round(((h.page + 1) / h.total_pages) * 100) : 0,
|
||
badge: h.finished ? (h.hasNew ? 'Caps. nuevos' : '') : h.hasNew ? 'Caps. nuevos' : '',
|
||
sub: `${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · ${timeAgo(h.updated_at)}`,
|
||
del: { attrs: `data-delhist="${h.manga_id}"`, title: 'Borrar del historial' },
|
||
})
|
||
if (!online && tab !== 'device') {
|
||
body.innerHTML = `<p class="muted">Sin conexión con el servidor.${dev.length ? ' Puedes leer lo guardado en este dispositivo:' : ''}</p>`
|
||
+ (dev.length ? `<p><a class="btn" href="#/library?tab=device">${icon('phone')} En el dispositivo</a></p>` : '')
|
||
} else if (tab === 'reading') body.innerHTML = grid(reading.map(histCard), 'No estás leyendo nada ahora mismo.')
|
||
else if (tab === 'finished') body.innerHTML = grid(finished.map(histCard), 'Aún no has terminado ningún manga.')
|
||
else if (tab === 'following') {
|
||
body.innerHTML = grid(follows.map(f => libCard({
|
||
href: `#/manga/${f.manga_id}`, cover: f.cover, title: f.title, badge: f.newCount ? `${f.newCount} nuevo${f.newCount > 1 ? 's' : ''}` : '',
|
||
sub: f.last_new_at ? `Último capítulo nuevo ${timeAgo(f.last_new_at)}` : 'Sin novedades aún',
|
||
del: { attrs: `data-unfollow="${f.manga_id}"`, title: 'Dejar de seguir', icon: 'bellOff' },
|
||
})), 'No sigues ningún manga. Pulsa «Seguir» en su ficha para recibir avisos de capítulos nuevos.')
|
||
} else if (tab === 'device') {
|
||
const total = dev.reduce((n, m) => n + m.bytes, 0)
|
||
body.innerHTML = (dev.length ? `<p class="muted small">${plural(dev.reduce((n, m) => n + m.chapters.length, 0), 'capítulo')} · ${fmtSize(total)} en este dispositivo. Se leen sin internet.</p>` : '')
|
||
+ grid(dev.map(m => {
|
||
const last = m.chapters.sort((a, b) => (Number(a.num) || 0) - (Number(b.num) || 0))
|
||
return libCard({
|
||
href: `#/manga/${m.mangaId}`, cover: m.cover, title: m.title,
|
||
sub: `${plural(m.chapters.length, 'capítulo')} · ${fmtSize(m.bytes)}<br>${esc(last.map(c => c.num ?? '·').slice(0, 8).join(', '))}${last.length > 8 ? '…' : ''}`,
|
||
del: { attrs: `data-deldev="${m.mangaId}"`, title: 'Borrar del dispositivo' },
|
||
})
|
||
}), 'No hay nada guardado en este dispositivo. En la ficha de un manga, «En este dispositivo» guarda capítulos para leerlos sin internet.')
|
||
} else if (curList) {
|
||
const l = await api(`/api/lists/${curList.id}`).catch(err => (toast(err.message), { items: [] }))
|
||
body.innerHTML = grid(l.items.map(it => libCard({
|
||
href: `#/manga/${it.manga_id}`, cover: it.cover, title: it.title, sub: `Añadido ${timeAgo(it.added_at)}`,
|
||
del: { attrs: `data-unlist="${it.manga_id}"`, title: 'Quitar de la lista', icon: 'x' },
|
||
})), 'Esta lista está vacía. Añade mangas desde su ficha con el botón «Listas».')
|
||
} else body.innerHTML = '<p class="muted">Esa lista ya no existe.</p>'
|
||
|
||
const reload = () => viewLibrary(params)
|
||
body.addEventListener('click', async e => {
|
||
const b = e.target.closest('button.hdel')
|
||
if (!b) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
try {
|
||
if (b.dataset.delhist) {
|
||
if (!confirm('¿Borrar el historial de este manga?')) return
|
||
await api(`/api/history/${b.dataset.delhist}`, { method: 'DELETE' })
|
||
} else if (b.dataset.unfollow) {
|
||
await api(`/api/follow/${b.dataset.unfollow}`, { method: 'DELETE' })
|
||
toast('Ya no sigues este manga')
|
||
} else if (b.dataset.deldev) {
|
||
const m = dev.find(x => x.mangaId === b.dataset.deldev)
|
||
if (!confirm(`¿Borrar de este dispositivo los ${plural(m.chapters.length, 'capítulo')} de «${m.title}» (${fmtSize(m.bytes)})?`)) return
|
||
await offRemove(m.chapters.map(c => c.chapterId))
|
||
} else if (b.dataset.unlist) {
|
||
await api(`/api/lists/${curList.id}/items/${encodeURIComponent(b.dataset.unlist)}`, { method: 'DELETE' })
|
||
}
|
||
reload()
|
||
} catch (err) { toast(err.message) }
|
||
})
|
||
$('#newlist')?.addEventListener('click', async () => {
|
||
const name = prompt('Nombre de la nueva lista:')
|
||
if (!name?.trim()) return
|
||
try { const l = await api('/api/lists', { method: 'POST', body: { name } }); location.hash = `#/library?tab=list-${l.id}` } catch (err) { toast(err.message) }
|
||
})
|
||
$('#lren')?.addEventListener('click', async () => {
|
||
const name = prompt('Nuevo nombre de la lista:', curList.name)
|
||
if (!name?.trim()) return
|
||
try { await api(`/api/lists/${curList.id}`, { method: 'PATCH', body: { name } }); reload() } catch (err) { toast(err.message) }
|
||
})
|
||
$('#ldel')?.addEventListener('click', async () => {
|
||
if (!confirm(`¿Eliminar la lista «${curList.name}»? Los mangas no se tocan.`)) return
|
||
try { await api(`/api/lists/${curList.id}`, { method: 'DELETE' }); location.hash = '#/library' } catch (err) { toast(err.message) }
|
||
})
|
||
}
|
||
|
||
// ---------- Descargas ----------
|
||
// 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>'
|
||
let q, list
|
||
try {
|
||
[q, list] = await Promise.all([api('/api/queues'), api('/api/downloads')])
|
||
} catch (err) {
|
||
// Sin servidor: al menos la cola de este dispositivo (que sigue por su cuenta si hay red a medias).
|
||
if (!OFFLINE_OK || !here()) throw err
|
||
q = { downloads: [], upscale: { available: true, groups: [], current: null }, clips: { queued: 0, running: 0, errors: 0 }, covers: 0, offline: err.message }
|
||
list = []
|
||
}
|
||
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="" data-fallback></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>`
|
||
// Guardado en este dispositivo: lo hace el navegador, no el servidor (offState).
|
||
const devHtml = () => {
|
||
if (!OFFLINE_OK) return ''
|
||
const items = [...(offState.running ? [offState.running] : []), ...offState.queue]
|
||
const groups = new Map()
|
||
for (const it of items) {
|
||
const g = groups.get(it.mangaId) || { ...it, chapters: [] }
|
||
g.chapters.push(it)
|
||
groups.set(it.mangaId, g)
|
||
}
|
||
const saved = Object.values(offIdx())
|
||
const bytes = saved.reduce((n, c) => n + (c.bytes || 0), 0)
|
||
return `<h2>${icon('phone')} En este dispositivo ${items.length ? `<span class="badge">${items.length}</span>` : ''}</h2>
|
||
<p class="muted small">Capítulos que se guardan en este ${/Mobi|Android|iPhone/i.test(navigator.userAgent) ? 'móvil' : 'navegador'} para leer sin conexión; la cola sigue mientras la app esté abierta.
|
||
${saved.length ? `Guardados: <a href="#/library?tab=device">${plural(saved.length, 'capítulo')} · ${fmtSize(bytes)}</a>.` : ''}</p>
|
||
${groups.size ? `<div class="grid qgrid">${[...groups.values()].map((g, i) => {
|
||
const cur = offState.running?.mangaId === g.mangaId ? offState.running : null
|
||
const rest = g.chapters.length - (cur ? 1 : 0)
|
||
return qcard({
|
||
key: `dev:${g.mangaId}`, id: g.mangaId, cover: g.cover, title: g.mangaTitle,
|
||
pct: cur?.total ? (cur.done / cur.total) * 100 : null,
|
||
badge: cur ? icon('phone') : i + 1,
|
||
lines: `${cur ? `<div>${esc(cur.label)} · pág. ${cur.done}/${cur.total || '?'}</div>` : ''}
|
||
${rest ? `<div>${cur ? `${rest} más` : plural(rest, 'capítulo')} en cola</div>` : ''}`,
|
||
actions: `<button class="btn small" data-devcancel="${esc(g.mangaId)}">${icon('stop')} Cancelar</button>`,
|
||
})
|
||
}).join('')}</div>` : '<p class="muted">No se está guardando nada. Se añade desde la ficha («En este dispositivo») o desde el lector.</p>'}`
|
||
}
|
||
const html = q.offline ? `<h1>Colas</h1><p class="error">${esc(q.offline)}: no se pueden ver las colas del servidor.</p>
|
||
<section class="qsec" id="devq">${devHtml()}</section>` : `<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>
|
||
${OFFLINE_OK ? `<section class="qsec" id="devq">${devHtml()}</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.devcancel) { offCancel(b.dataset.devcancel); toast('Guardado cancelado') }
|
||
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(), q.offline ? 10000 : busy ? 2000 : 15000)
|
||
// El guardado en el dispositivo avanza en el navegador: su sección se redibuja sola en cada página.
|
||
let raf = 0
|
||
const onDev = () => {
|
||
if (raf) return
|
||
raf = requestAnimationFrame(() => {
|
||
raf = 0
|
||
const box = $('#devq')
|
||
if (!box || !here()) return
|
||
const tpl = document.createElement('section')
|
||
tpl.innerHTML = devHtml()
|
||
morph(box, tpl, true)
|
||
})
|
||
}
|
||
offState.listeners.add(onDev)
|
||
cleanup = () => { clearTimeout(t); offState.listeners.delete(onDev); cancelAnimationFrame(raf) }
|
||
}
|
||
|
||
// ---------- Estadísticas ----------
|
||
const fmtNum = n => n.toLocaleString('es')
|
||
const fmtDay = d => new Date(`${d}T12:00:00`).toLocaleDateString('es', { day: 'numeric', month: 'short' })
|
||
const niceMax = v => { const p = 10 ** Math.floor(Math.log10(v)); return [1, 2, 2.5, 5, 10].map(m => m * p).find(m => m >= v) }
|
||
|
||
// Barras verticales de una sola serie; cada columna tiene una zona de hover más grande que la barra.
|
||
function barChart(data, { value, tip, label }, W = 560) {
|
||
const H = 190, L = 34, B = 24, T = 8
|
||
const max = niceMax(Math.max(1, ...data.map(value)))
|
||
const cw = (W - L) / data.length
|
||
const bw = Math.max(4, Math.min(28, cw - 6))
|
||
const y = v => T + (H - T - B) * (1 - v / max)
|
||
const ticks = Number.isInteger(max / 2) ? [0, max / 2, max] : [0, max]
|
||
const grid = ticks.map(v => `<line x1="${L}" x2="${W}" y1="${y(v)}" y2="${y(v)}" class="grid"/><text x="${L - 6}" y="${y(v) + 4}" text-anchor="end" class="axis">${fmtNum(Math.round(v))}</text>`).join('')
|
||
const bars = data.map((d, i) => {
|
||
const v = value(d)
|
||
const x = L + i * cw + (cw - bw) / 2
|
||
const top = y(v), base = y(0), r = Math.min(4, bw / 2, base - top)
|
||
const bar = v > 0 ? `<path class="mark" d="M${x},${base}V${top + r}Q${x},${top} ${x + r},${top}H${x + bw - r}Q${x + bw},${top} ${x + bw},${top + r}V${base}Z"/>` : ''
|
||
const every = W < 420 ? 3 : 2
|
||
const lab = (data.length - 1 - i) % every ? '' : `<text x="${i === data.length - 1 ? x + bw : x + bw / 2}" y="${H - 6}" text-anchor="${i === data.length - 1 ? 'end' : 'middle'}" class="axis">${label(d)}</text>`
|
||
return `<g class="col" data-tip="${esc(tip(d))}"><rect x="${L + i * cw}" y="${T}" width="${cw}" height="${H - T - B}" class="hit"/>${bar}${lab}</g>`
|
||
}).join('')
|
||
return `<svg viewBox="0 0 ${W} ${H}" class="chart" role="img">${grid}${bars}</svg>`
|
||
}
|
||
|
||
async function viewStats() {
|
||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||
const st = await api('/api/stats')
|
||
const t = st.totals
|
||
if (!t.pages) {
|
||
app.innerHTML = '<h1>Estadísticas</h1><p class="muted">Aún no hay lecturas registradas. Lee algún capítulo y vuelve por aquí.</p>'
|
||
return
|
||
}
|
||
const maxDay = Math.max(1, ...st.calendar.map(d => d.pages))
|
||
const level = p => (p === 0 ? 0 : Math.min(5, Math.ceil((p / maxDay) * 5)))
|
||
const weekLabel = w => fmtDay(w.start)
|
||
const tile = (value, label, sub = '') => `<div class="tile"><div class="tv">${value}</div><div class="tl">${label}</div>${sub ? `<div class="ts">${sub}</div>` : ''}</div>`
|
||
// Calendario: columnas = semanas (lunes arriba).
|
||
const firstDow = (new Date(`${st.calendar[0].day}T12:00:00`).getDay() + 6) % 7
|
||
const cells = [...Array(firstDow).fill('<i class="cell empty"></i>'), ...st.calendar.map(d =>
|
||
`<i class="cell l${level(d.pages)}" data-tip="${esc(`${fmtDay(d.day)}: ${d.pages ? `${fmtNum(d.pages)} págs.` : 'sin lectura'}${d.chapters ? ` · ${d.chapters} caps.` : ''}`)}"></i>`)].join('')
|
||
const gmax = Math.max(1, ...st.genres.map(g => g.pages))
|
||
|
||
app.innerHTML = `<h1>${icon('chart')} Estadísticas</h1>
|
||
<div class="tiles">
|
||
${tile(`${st.streak.current} ${st.streak.current === 1 ? 'día' : 'días'}`, 'Racha actual', st.streak.current && !st.streak.readToday ? 'Lee hoy para mantenerla' : st.streak.readToday ? 'Hoy ya leíste ✓' : '')}
|
||
${tile(`${st.streak.best} ${st.streak.best === 1 ? 'día' : 'días'}`, 'Mejor racha')}
|
||
${tile(fmtNum(t.thisWeekChapters), 'Capítulos esta semana', `${fmtNum(t.thisWeekPages)} páginas`)}
|
||
${tile(fmtNum(t.chapters), 'Capítulos terminados')}
|
||
${tile(fmtNum(t.pages), 'Páginas leídas')}
|
||
${tile(fmtNum(t.mangas), 'Mangas', `${t.activeDays} ${t.activeDays === 1 ? 'día' : 'días'} con lectura`)}
|
||
</div>
|
||
<div class="charts">
|
||
<section class="panel"><h3>Capítulos terminados por semana</h3><div class="chart-slot" data-chart="chapters"></div></section>
|
||
<section class="panel"><h3>Páginas leídas por semana</h3><div class="chart-slot" data-chart="pages"></div></section>
|
||
</div>
|
||
<section class="panel"><h3>Actividad (últimas 18 semanas)</h3>
|
||
<div class="heat-wrap"><div class="heat">${cells}</div></div>
|
||
<div class="heat-legend muted">Menos <i class="cell l0"></i><i class="cell l1"></i><i class="cell l2"></i><i class="cell l3"></i><i class="cell l4"></i><i class="cell l5"></i> Más páginas</div>
|
||
</section>
|
||
<div class="charts">
|
||
<section class="panel"><h3>Géneros favoritos</h3>
|
||
${st.genres.length ? `<div class="hbars">${st.genres.map(g => `<div class="hbar" data-tip="${esc(`${g.name}: ${fmtNum(g.pages)} págs. (${g.pct}% de lo leído)`)}">
|
||
<span class="hl">${esc(g.name)}</span><span class="ht"><i style="width:${(g.pages / gmax) * 100}%"></i></span><span class="hv">${g.pct}%</span></div>`).join('')}</div>
|
||
<p class="muted" style="font-size:.8rem">Porcentaje de páginas leídas de mangas con ese género (un manga cuenta en todos sus géneros).</p>`
|
||
: '<p class="muted">Abre la ficha de los mangas que lees para registrar sus géneros.</p>'}
|
||
</section>
|
||
<section class="panel"><h3>Lo más leído</h3>
|
||
<div class="toplist">${st.topMangas.map((m, i) => `<a class="cont" href="#/manga/${m.id}"><span class="rank">${i + 1}</span>
|
||
<img src="${esc(m.cover || '/icon.svg')}" alt="" loading="lazy">
|
||
<div class="info"><b>${esc(m.title)}</b><div class="muted">${fmtNum(m.pages)} págs. · ${m.chapters} caps.</div></div></a>`).join('')}</div>
|
||
</section>
|
||
</div>
|
||
<details class="panel"><summary>Ver datos por semana</summary>
|
||
<table class="data"><thead><tr><th>Semana del</th><th>Capítulos</th><th>Páginas</th></tr></thead>
|
||
<tbody>${[...st.weeks].reverse().map(w => `<tr><td>${fmtDay(w.start)}</td><td>${w.chapters}</td><td>${fmtNum(w.pages)}</td></tr>`).join('')}</tbody></table>
|
||
</details>
|
||
<div id="tip" hidden></div>`
|
||
|
||
// Las gráficas se dibujan al ancho real del panel para que el texto no encoja en el móvil.
|
||
const charts = {
|
||
chapters: { value: w => w.chapters, label: weekLabel, tip: w => `Semana del ${fmtDay(w.start)}: ${w.chapters} caps.` },
|
||
pages: { value: w => w.pages, label: weekLabel, tip: w => `Semana del ${fmtDay(w.start)}: ${fmtNum(w.pages)} págs.` },
|
||
}
|
||
const drawCharts = () => app.querySelectorAll('.chart-slot').forEach(el => { el.innerHTML = barChart(st.weeks, charts[el.dataset.chart], Math.max(260, el.clientWidth)) })
|
||
drawCharts()
|
||
let rt = null
|
||
const onResize = () => { clearTimeout(rt); rt = setTimeout(drawCharts, 150) }
|
||
window.addEventListener('resize', onResize)
|
||
|
||
// Tooltip compartido para barras, celdas y géneros.
|
||
const tipEl = $('#tip')
|
||
const move = e => {
|
||
const target = e.target.closest('[data-tip]')
|
||
if (!target) { tipEl.hidden = true; return }
|
||
tipEl.textContent = target.dataset.tip
|
||
tipEl.hidden = false
|
||
const r = tipEl.getBoundingClientRect()
|
||
tipEl.style.left = `${Math.min(window.innerWidth - r.width - 8, Math.max(8, e.clientX - r.width / 2))}px`
|
||
tipEl.style.top = `${Math.max(8, e.clientY - r.height - 12)}px`
|
||
}
|
||
app.addEventListener('pointermove', move)
|
||
app.addEventListener('pointerdown', move)
|
||
app.addEventListener('pointerleave', () => (tipEl.hidden = true))
|
||
cleanup = () => { app.removeEventListener('pointermove', move); app.removeEventListener('pointerdown', move); window.removeEventListener('resize', onResize) }
|
||
}
|
||
|
||
// ---------- Ajustes ----------
|
||
function viewSettings() {
|
||
app.innerHTML = `<h1>Ajustes</h1>
|
||
<div class="settings">
|
||
<section><b>Cuenta</b><div class="muted">Sesión iniciada como <b>${esc(me.username)}</b></div>
|
||
<button class="btn" id="logout">${icon('logout')} Cerrar sesión</button></section>
|
||
<section><b>Idioma preferido de los capítulos</b>
|
||
<select id="lang">${Object.entries(LANGS).map(([k, v]) => `<option value="${k}" ${k === me.lang ? 'selected' : ''}>${v}</option>`).join('')}</select></section>
|
||
<section><b>Cambiar contraseña</b>
|
||
<form id="pw" style="display:grid;gap:8px">
|
||
<input type="password" name="old" placeholder="Contraseña actual" autocomplete="current-password" required>
|
||
<input type="password" name="new" placeholder="Nueva contraseña" autocomplete="new-password" required minlength="8">
|
||
<button class="btn primary">${icon('key')} Guardar</button></form></section>
|
||
<section><b>${icon('download')} Descarga anticipada</b>
|
||
<p class="muted" style="margin:0;font-size:.88rem">Al pasar la mitad de un capítulo, el servidor descarga los siguientes en segundo plano para que abran al instante (y estén en la copia local).</p>
|
||
<div class="row" style="flex-wrap:wrap;gap:10px">
|
||
<select id="pfn">${[[0, 'Desactivada'], [1, 'El siguiente capítulo'], [2, '2 capítulos siguientes'], [3, '3 capítulos siguientes'], [5, '5 capítulos siguientes']].map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select>
|
||
<label class="row muted" style="font-size:.88rem"><input type="checkbox" id="pfhd"> Reescalarlos también a HD</label>
|
||
</div></section>
|
||
<section><b>${icon('phone')} App y lectura sin conexión</b>
|
||
${OFFLINE_OK ? `<p class="muted" style="margin:0;font-size:.88rem">Capítulos guardados en este dispositivo: <b id="offsum">…</b>. Se guardan desde la ficha de cada manga («En este dispositivo») o desde el lector.</p>
|
||
<div class="row" style="flex-wrap:wrap;gap:8px"><a class="btn small" href="#/library?tab=device">${icon('library')} Ver lo guardado</a>
|
||
<button class="btn small danger" id="offclear">${icon('trash')} Borrar todo del dispositivo</button>
|
||
<button class="btn small" id="install" hidden>${icon('download')} Instalar como app</button></div>`
|
||
: `<p class="muted" style="margin:0;font-size:.88rem">Instalar la app y leer sin conexión necesita que la página se abra por <b>HTTPS</b> (el navegador no lo permite en <code>http://${esc(location.host)}</code>). Por ejemplo con <code>tailscale serve</code> o un proxy inverso con certificado.</p>`}
|
||
</section>
|
||
<section id="secsec"><b>${icon('key')} Seguridad: IPs bloqueadas</b><div id="bans" class="muted" style="font-size:.88rem">Cargando…</div></section>
|
||
<section><b>${icon('bell')} Avisos de capítulos nuevos</b>
|
||
<p class="muted" style="margin:0;font-size:.88rem">El servidor revisa cada pocas horas los mangas que sigues (los que empiezas a leer se siguen solos).
|
||
Si un manga tiene copia local, los capítulos nuevos se descargan automáticamente. Además puedes recibir una notificación:</p>
|
||
<form id="notify" style="display:grid;gap:8px">
|
||
<b style="font-size:.9rem">ntfy</b>
|
||
<input type="text" name="ntfyUrl" placeholder="Servidor, p. ej. http://192.168.1.141:4546" autocomplete="off">
|
||
<input type="text" name="ntfyTopic" placeholder="Tema, p. ej. mangas" autocomplete="off">
|
||
<input type="password" name="ntfyToken" placeholder="Token (tk_…) o usuario:contraseña" autocomplete="new-password">
|
||
<b style="font-size:.9rem">Telegram <span class="muted" style="font-weight:normal">(opcional)</span></b>
|
||
<input type="password" name="tgToken" placeholder="Token del bot (de @BotFather)" autocomplete="new-password">
|
||
<input type="text" name="tgChat" placeholder="Chat ID" autocomplete="off">
|
||
<p class="muted" style="margin:0;font-size:.8rem">Los tokens guardados no se muestran: deja el campo vacío para conservarlo o escribe «-» para borrarlo.</p>
|
||
<div class="row"><button class="btn primary">${icon('check')} Guardar</button><button type="button" class="btn" id="ntest">${icon('send')} Enviar prueba</button></div>
|
||
</form></section>
|
||
</div>`
|
||
api('/api/notify').then(n => {
|
||
const f = $('#notify')
|
||
if (!f) return
|
||
f.ntfyUrl.value = n.ntfyUrl
|
||
f.ntfyTopic.value = n.ntfyTopic
|
||
f.tgChat.value = n.tgChat
|
||
if (n.hasNtfyToken) f.ntfyToken.placeholder = 'Token guardado ✓ (vacío = conservar, «-» = borrar)'
|
||
if (n.hasTgToken) f.tgToken.placeholder = 'Token guardado ✓ (vacío = conservar, «-» = borrar)'
|
||
}).catch(() => {})
|
||
$('#notify').addEventListener('submit', async e => {
|
||
e.preventDefault()
|
||
const f = e.target
|
||
try {
|
||
await api('/api/notify', { method: 'POST', body: Object.fromEntries(['ntfyUrl', 'ntfyTopic', 'ntfyToken', 'tgToken', 'tgChat'].map(k => [k, f[k].value])) })
|
||
toast('Avisos guardados')
|
||
viewSettings()
|
||
} catch (err) { toast(err.message) }
|
||
})
|
||
const renderBans = async () => {
|
||
const b = await api('/api/bans').catch(() => null)
|
||
const box = $('#bans')
|
||
if (!box || !b) return
|
||
box.innerHTML = `<p style="margin:0">Tras ${b.maxFails} intentos fallidos en ${b.windowMin} min (contraseña incorrecta o registro cerrado), la IP queda bloqueada ${b.banHours} h. La red local y Tailscale no se bloquean nunca.</p>
|
||
${b.bans.length ? `<div class="banlist">${b.bans.map(x => `<div class="row"><code>${esc(x.ip)}</code><span class="muted small" style="flex:1">hasta ${new Date(x.until).toLocaleString('es')} · ${esc(x.reason || '')}</span>
|
||
<button class="btn small" data-unban="${esc(x.ip)}">Desbloquear</button></div>`).join('')}</div>` : '<p style="margin:6px 0 0">Ninguna IP bloqueada.</p>'}
|
||
<p class="muted small" style="margin:6px 0 0">Tu IP vista por el servidor: <code>${esc(b.you.ip)}</code>${b.you.protected ? ' (red local, nunca se bloquea)' : ''}${b.trustedHeader ? ` · leída de <code>${esc(b.trustedHeader)}</code>` : ''}</p>
|
||
${b.fails.length ? `<p style="margin:6px 0 0">Intentos fallidos recientes: ${b.fails.map(f => `<code>${esc(f.ip)}</code> ×${f.n}`).join(', ')}</p>` : ''}`
|
||
box.querySelectorAll('[data-unban]').forEach(x => x.addEventListener('click', async () => {
|
||
try { await api(`/api/bans/${encodeURIComponent(x.dataset.unban)}`, { method: 'DELETE' }); toast('IP desbloqueada'); renderBans() } catch (err) { toast(err.message) }
|
||
}))
|
||
}
|
||
renderBans()
|
||
api('/api/prefetch').then(c => { if (!$('#pfn')) return; $('#pfn').value = String(c.n); $('#pfhd').checked = c.hd }).catch(() => {})
|
||
const savePf = () => api('/api/prefetch', { method: 'POST', body: { n: Number($('#pfn').value), hd: $('#pfhd').checked } }).then(() => toast('Descarga anticipada guardada')).catch(err => toast(err.message))
|
||
$('#pfn').addEventListener('change', savePf)
|
||
$('#pfhd').addEventListener('change', savePf)
|
||
if (OFFLINE_OK) {
|
||
const dev = offByManga()
|
||
$('#offsum').textContent = dev.length ? `${plural(dev.reduce((n, m) => n + m.chapters.length, 0), 'capítulo')} de ${plural(dev.length, 'manga')} · ${fmtSize(dev.reduce((n, m) => n + m.bytes, 0))}` : 'ninguno'
|
||
$('#offclear').hidden = !dev.length
|
||
$('#offclear').addEventListener('click', async () => {
|
||
if (!confirm('¿Borrar todos los capítulos guardados en este dispositivo?')) return
|
||
await caches.delete(OFF_CACHE)
|
||
store.set('offlineChapters', {})
|
||
toast('Borrado')
|
||
viewSettings()
|
||
})
|
||
const inst = $('#install')
|
||
inst.hidden = !installPrompt
|
||
inst.addEventListener('click', async () => { installPrompt?.prompt(); installPrompt = null; inst.hidden = true })
|
||
}
|
||
$('#ntest').addEventListener('click', async () => {
|
||
try { await api('/api/notify/test', { method: 'POST' }); toast('Notificación de prueba enviada') } catch (err) { toast(err.message) }
|
||
})
|
||
$('#logout').addEventListener('click', async () => { await api('/api/logout', { method: 'POST' }); me = null; location.hash = '#/login'; router() })
|
||
$('#lang').addEventListener('change', async e => { await api('/api/settings', { method: 'POST', body: { lang: e.target.value } }); me.lang = e.target.value; toast('Idioma guardado') })
|
||
$('#pw').addEventListener('submit', async e => {
|
||
e.preventDefault()
|
||
try {
|
||
await api('/api/settings', { method: 'POST', body: { oldPassword: e.target.old.value, newPassword: e.target.new.value } })
|
||
e.target.reset(); toast('Contraseña cambiada')
|
||
} catch (err) { toast(err.message) }
|
||
})
|
||
}
|