1560 lines
91 KiB
JavaScript
1560 lines
91 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"/>',
|
||
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"/>',
|
||
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)', md: 'MangaDex (multi-idioma)' }
|
||
// Filtros de imagen del lector (CSS filter); el brillo se suma aparte.
|
||
const FILTERS = {
|
||
none: ['Sin filtro', ''],
|
||
sepia: ['Sepia', 'sepia(.65) contrast(.95)'],
|
||
paper: ['Papel cálido', 'sepia(.35) saturate(1.15) hue-rotate(-8deg) brightness(.95)'],
|
||
vintage: ['Vintage', 'sepia(.45) contrast(1.1) saturate(.75)'],
|
||
night: ['Noche (invertido)', 'invert(.88) hue-rotate(180deg) contrast(.95)'],
|
||
gray: ['Blanco y negro', 'grayscale(1)'],
|
||
contrast: ['Alto contraste', 'contrast(1.35) brightness(1.03)'],
|
||
vivid: ['Colores vivos', 'saturate(1.45) contrast(1.05)'],
|
||
}
|
||
const DL_ACTIVE = ['queued', 'downloading']
|
||
const fmtSize = b => (b >= 1e9 ? `${(b / 1e9).toFixed(1)} GB` : b >= 1e6 ? `${Math.round(b / 1e6)} MB` : `${Math.round((b || 0) / 1e3)} KB`)
|
||
// Progreso global contando la fracción del capítulo en curso.
|
||
function downloadPct(d) {
|
||
if (!d?.total) return 0
|
||
const frac = d.current?.pagesTotal ? d.current.pages / d.current.pagesTotal : 0
|
||
return Math.min(100, ((d.done + frac) / d.total) * 100)
|
||
}
|
||
function downloadDetail(d) {
|
||
const c = d?.current
|
||
if (!c) return d?.status === 'queued' ? 'Esperando turno…' : ''
|
||
return c.pagesTotal ? `Cap. ${esc(c.chapter ?? '?')} · pág. ${c.pages}/${c.pagesTotal}` : `Cap. ${esc(c.chapter ?? '?')} · preparando…`
|
||
}
|
||
function downloadLabel(d) {
|
||
if (!d) return ''
|
||
const n = `${d.done}/${d.total || '?'}`
|
||
return {
|
||
queued: 'En cola…',
|
||
downloading: `Descargando ${n}`,
|
||
done: `Descargado (${d.done} caps.)`,
|
||
error: `Descarga incompleta ${n}`,
|
||
cancelled: `Descarga cancelada ${n}`,
|
||
}[d.status] || d.status
|
||
}
|
||
const LANGS = { 'es-la': 'Español (Latam)', es: 'Español (España)', en: 'English', 'pt-br': 'Português (BR)', fr: 'Français', it: 'Italiano', ja: '日本語' }
|
||
let me = null
|
||
let cleanup = null
|
||
|
||
// ---------- Utilidades ----------
|
||
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
||
const $ = (sel, root = document) => root.querySelector(sel)
|
||
const store = {
|
||
get(k, d) { try { return JSON.parse(localStorage.getItem(k)) ?? d } catch { return d } },
|
||
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)) } catch {} },
|
||
}
|
||
function toast(msg) {
|
||
const t = document.getElementById('toast')
|
||
t.textContent = msg
|
||
t.hidden = false
|
||
clearTimeout(toast.t)
|
||
toast.t = setTimeout(() => (t.hidden = true), 2500)
|
||
}
|
||
async function api(path, opts = {}) {
|
||
const res = await fetch(path, {
|
||
...opts,
|
||
headers: opts.body ? { 'Content-Type': 'application/json' } : undefined,
|
||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||
})
|
||
const data = await res.json().catch(() => ({}))
|
||
if (res.status === 401 && !path.startsWith('/api/login')) { me = null; location.hash = '#/login'; throw new Error('No autenticado') }
|
||
if (!res.ok) throw new Error(data.error || `Error ${res.status}`)
|
||
return data
|
||
}
|
||
function timeAgo(ts) {
|
||
const s = (Date.now() - ts) / 1000
|
||
if (s < 60) return 'hace un momento'
|
||
if (s < 3600) return `hace ${Math.floor(s / 60)} min`
|
||
if (s < 86400) return `hace ${Math.floor(s / 3600)} h`
|
||
return `hace ${Math.floor(s / 86400)} d`
|
||
}
|
||
const chapLabel = c => (c.num ? `Cap. ${c.num}` : 'Oneshot') + (c.title ? ` — ${c.title}` : '')
|
||
const readUrl = (mangaId, chapterId, page) => `#/read/${mangaId}/${chapterId}${page != null ? `?page=${page + 1}` : ''}`
|
||
|
||
// ---------- Router ----------
|
||
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 api('/api/me').catch(() => ({}))
|
||
me = r.user
|
||
me && (me.signup = r.signup)
|
||
if (!me) return viewAuth(r.signup)
|
||
}
|
||
try {
|
||
if (parts[0] === 'login') return (location.hash = '#/')
|
||
if (parts[0] === 'manga') return await viewManga(parts[1])
|
||
if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1)
|
||
if (parts[0] === 'history') return await viewHistory()
|
||
if (parts[0] === 'downloads') return await viewDownloads()
|
||
if (parts[0] === '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="">
|
||
<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">
|
||
<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)}`
|
||
async function viewHome(params) {
|
||
const q = params.get('q') || ''
|
||
const tag = params.get('tag') || ''
|
||
const tagName = params.get('tagName') || tag
|
||
const mode = tag ? 'cats' : params.get('mode') || 'popular'
|
||
const anyLang = params.get('any') === '1'
|
||
const source = SOURCES[params.get('source')] ? params.get('source') : store.get('source', 'lc')
|
||
store.set('source', source)
|
||
const showCats = mode === 'cats' && !tag
|
||
app.innerHTML = `
|
||
<section id="cont"></section>
|
||
<div class="tabs" id="sources">${Object.entries(SOURCES).map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}">${v}</button>`).join('')}</div>
|
||
<form class="search"><input type="search" name="q" placeholder="Buscar ${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>
|
||
<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(() => {})
|
||
}
|
||
|
||
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 (sus otros listados no paginan).
|
||
const pageable = source === 'md' || !!tag
|
||
const seen = new Set()
|
||
let page = 1
|
||
const load = async () => {
|
||
const qs = new URLSearchParams({ q, mode, source, page, ...(tag ? { tag } : {}), ...(anyLang ? { anyLang: '1' } : {}) })
|
||
const list = (await api(`/api/search?${qs}`)).filter(m => !seen.has(m.id) && seen.add(m.id))
|
||
if (page === 1) {
|
||
res.className = list.length ? 'grid' : 'loading'
|
||
res.innerHTML = list.length ? '' : 'Sin resultados.'
|
||
}
|
||
res.insertAdjacentHTML('beforeend', list.map(mangaCard).join(''))
|
||
$('#more').hidden = !pageable || list.length < 20
|
||
}
|
||
$('#more').addEventListener('click', async e => {
|
||
e.target.disabled = true
|
||
page++
|
||
try { await load() } catch (err) { toast(err.message) }
|
||
e.target.disabled = false
|
||
})
|
||
await load()
|
||
}
|
||
|
||
// ---------- Ficha del manga ----------
|
||
async function viewManga(id, langOverride) {
|
||
if (cleanup) { cleanup(); cleanup = null }
|
||
app.innerHTML = '<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, 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 === 'lc'
|
||
const langOpts = [...new Set([lang, me.lang, ...chapterLangs, ...manga.languages])].filter(Boolean)
|
||
const altSearch = `#/?source=lc&q=${encodeURIComponent(manga.title)}`
|
||
const first = chapters[0]
|
||
let mainBtn = ''
|
||
if (progress && chapters.some(c => c.id === progress.chapter_id)) {
|
||
mainBtn = `<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 = [
|
||
isLc ? '' : `<a href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
|
||
progress || reads.length ? `<button id="drop" class="danger">${icon('stopRead')} Dejar de leer…</button>` : '',
|
||
].join('')
|
||
|
||
app.innerHTML = `
|
||
<div class="detail">
|
||
<img class="cover" src="${esc(manga.cover || '/icon.svg')}" alt="">
|
||
<div 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>
|
||
${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>
|
||
</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 <a class="tag" href="${altSearch}">buscarlo en LeerCapitulo</a>.
|
||
${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `<a class="tag" href="#" data-lang="${esc(l)}">${esc(LANGS[l] || l)}</a>`).join(' ')}` : 'No hay capítulos en ningún idioma.'}</p>`}
|
||
<div id="selbar" hidden></div>`
|
||
|
||
const secHead = (ic, title, status) => `<div class="psec-head"><div class="psec-title">${icon(ic)} ${title}</div>${status ? `<div class="psec-status muted">${status}</div>` : ''}</div>`
|
||
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
|
||
const fmtDur = sec => sec < 90 ? '≈ 1 min' : sec < 3600 ? `≈ ${Math.round(sec / 60)} min` : `≈ ${Math.floor(sec / 3600)} h ${Math.round((sec % 3600) / 60)} min`
|
||
|
||
// --- Selector de capítulos compartido por "Copia local" (kind 'dl') y "Reescalado HD" (kind 'hd'):
|
||
// todo lo pendiente (solo HD), un rango o una selección hecha en la lista de abajo.
|
||
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`)
|
||
},
|
||
},
|
||
}
|
||
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')
|
||
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()
|
||
}
|
||
|
||
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 menu = $('.menu', app)
|
||
const closeMenu = e => { if (menu && !menu.contains(e.target)) menu.open = false }
|
||
document.addEventListener('click', closeMenu)
|
||
cleanup = () => { clearTimeout(dlTimer); clearTimeout(hdTimer); document.removeEventListener('click', closeMenu) }
|
||
app.querySelectorAll('[data-lang]').forEach(a => a.addEventListener('click', e => { e.preventDefault(); store.set(`lang:${id}`, a.dataset.lang); viewManga(id, a.dataset.lang) }))
|
||
|
||
const ul = $('.chapters', app)
|
||
let desc = store.get('chapterOrderDesc', true)
|
||
const 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> ` : '')
|
||
+ (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group))
|
||
function renderList() {
|
||
if (!ul) return
|
||
const list = desc ? [...chapters].reverse() : chapters
|
||
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()
|
||
renderDl(download)
|
||
renderHd(upscale)
|
||
$('#order')?.addEventListener('click', () => { desc = !desc; lastPick = null; store.set('chapterOrderDesc', desc); renderList() })
|
||
$('#lang')?.addEventListener('change', e => { store.set(`lang:${id}`, e.target.value); viewManga(id, e.target.value) })
|
||
}
|
||
|
||
// ---------- Lector ----------
|
||
const READ_MODES = { paged: 'Una página', double: 'Doble página', vertical: 'Vertical' }
|
||
// Ajustes por defecto según el tipo de obra (se usan hasta que el usuario cambie algo en ese manga).
|
||
function defaultPrefs(manga) {
|
||
if (['Manhwa', 'Manhua', 'Webtoon'].includes(manga.type)) return { mode: 'vertical', fit: 'width', dir: 'ltr' }
|
||
if (manga.type === 'Manga') return { mode: 'paged', fit: 'height', dir: 'rtl' }
|
||
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 globalSettings = { saver: false, filter: 'none', brightness: 100, ...store.get('readerSettings', {}) }
|
||
const [info, chap] = await Promise.all([
|
||
api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`),
|
||
api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}&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 === 'lc' ? '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; api('/api/progress', { method: 'POST', body: payload() }).catch(() => {}) }
|
||
const scheduleSave = () => { clearTimeout(saveTimer); saveTimer = setTimeout(saveNow, 700) }
|
||
const beacon = () => { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; navigator.sendBeacon('/api/progress', JSON.stringify(payload())) } }
|
||
const onVis = () => document.visibilityState === 'hidden' && beacon()
|
||
document.addEventListener('visibilitychange', onVis)
|
||
window.addEventListener('pagehide', beacon)
|
||
saveNow()
|
||
|
||
const chapOpts = chapters.map(c => `<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="filter" title="Filtro">${Object.entries(FILTERS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
|
||
<label class="row muted" style="font-size:.85rem" title="Brillo">☀ <input type="range" id="bright" min="40" max="120" step="5" style="width:90px"></label>
|
||
${manga.source === 'lc' || chap.local ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
|
||
${chap.hdAvailable ? '<label class="row muted" style="font-size:.85rem" title="Usar la versión reescalada de este capítulo"><input type="checkbox" id="hd"> Versión HD</label>'
|
||
: chap.local && info.upscale?.available ? `<button class="btn small" id="hdone" title="Reescala este capítulo con IA en segundo plano (unos minutos)">${icon('sparkles')} Reescalar a HD</button>` : ''}
|
||
<p class="muted">Modo, ajuste 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]
|
||
}
|
||
}
|
||
function showPaged() {
|
||
let stage = $('.stage', box)
|
||
if (!stage) { box.innerHTML = '<div class="stage"></div>'; stage = $('.stage', box) }
|
||
stage.classList.toggle('two', shown.length === 2)
|
||
stage.innerHTML = shown.map(i => `<img data-i="${i}" src="${pages[i]}" alt="" draggable="false">`).join('')
|
||
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()
|
||
}
|
||
function showEnd() {
|
||
atEnd = true
|
||
resetZoom()
|
||
box.innerHTML = endBlock
|
||
reader.classList.remove('hide-ui')
|
||
}
|
||
|
||
function goTo(n, fromScroll = false, force = false) {
|
||
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
|
||
atEnd = false
|
||
shown = g
|
||
page = g[0]
|
||
setPageLabel()
|
||
if (changed || force || !$('.stage img', box)) showPaged()
|
||
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) }
|
||
const backward = () => {
|
||
if (atEnd) { box.innerHTML = ''; goTo(total - 1, false, true); return }
|
||
if (page > 0) goTo(groupOf(page - 1)[0])
|
||
}
|
||
|
||
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', 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)
|
||
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 })
|
||
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() })
|
||
$('#bright').addEventListener('input', e => { settings.brightness = Number(e.target.value); saveGlobal(); applyFilter() })
|
||
$('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; saveGlobal(); applyFilter() })
|
||
$('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); router() })
|
||
$('#hd')?.addEventListener('change', e => { settings.hd = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); router() })
|
||
$('#hdone')?.addEventListener('click', async e => {
|
||
e.target.closest('button').disabled = true
|
||
try { await api(`/api/upscale/${mangaId}`, { method: 'POST', body: { chapterId } }); toast('En cola: estará en HD en unos minutos') } catch (err) { toast(err.message); e.target.closest('button').disabled = false }
|
||
})
|
||
$('#chap').addEventListener('change', e => { location.hash = readUrl(mangaId, e.target.value) })
|
||
slider.addEventListener('input', () => goTo(Number(slider.value) - 1))
|
||
|
||
// --- 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') }
|
||
}
|
||
|
||
// ---------- Historial ----------
|
||
async function viewHistory() {
|
||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||
const hist = await api('/api/history')
|
||
app.innerHTML = `<h1>Historial de lectura</h1>
|
||
${hist.length ? '' : '<p class="muted">Aún no has leído nada.</p>'}
|
||
<div class="grid">${hist.map(h => {
|
||
const pct = h.total_pages ? Math.round(((h.page + 1) / h.total_pages) * 100) : 0
|
||
return `<a class="card hcard" href="#/manga/${h.manga_id}">
|
||
<img class="cover" loading="lazy" src="${esc(h.cover || '/icon.svg')}" alt="">
|
||
${h.finished ? '<span class="badge corner">Terminado</span>' : h.hasNew ? '<span class="badge corner">Caps. nuevos</span>' : ''}
|
||
<button class="hdel" data-del="${h.manga_id}" title="Borrar del historial" aria-label="Borrar del historial">${icon('trash')}</button>
|
||
<div class="bar" title="${pct}% del capítulo"><i style="width:${pct}%"></i></div>
|
||
<div class="t">${esc(h.manga_title)}</div>
|
||
<div class="sub">${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · ${timeAgo(h.updated_at)}</div>
|
||
</a>`
|
||
}).join('')}</div>`
|
||
app.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', async e => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
if (!confirm('¿Borrar el historial de este manga?')) return
|
||
await api(`/api/history/${b.dataset.del}`, { method: 'DELETE' })
|
||
viewHistory()
|
||
}))
|
||
}
|
||
|
||
// ---------- Descargas ----------
|
||
async function viewDownloads() {
|
||
if (cleanup) { cleanup(); cleanup = null }
|
||
const list = await api('/api/downloads')
|
||
if (location.hash !== '#/downloads') return
|
||
const total = list.reduce((n, d) => n + (d.size || 0), 0)
|
||
app.innerHTML = `<h1>Descargas</h1>
|
||
<p class="muted">Se guardan en <code>/zdata/media/mangas</code> como CBZ (compatibles con Kavita, Komga, Mihon…). Los capítulos descargados se leen desde el disco.${list.length ? ` Total: <b>${fmtSize(total)}</b>.` : ''}</p>
|
||
${list.length ? '' : '<p class="muted">Aún no has descargado nada. Usa “⬇ Descargar” en la ficha de un manga.</p>'}
|
||
<div class="history">${list.map(d => {
|
||
const active = DL_ACTIVE.includes(d.status)
|
||
return `<div class="cont">
|
||
<a class="info" href="#/manga/${d.manga_id}"><b>${esc(d.title)}</b>
|
||
<div class="muted">${esc(downloadLabel(d))}${active ? ` · ${Math.floor(downloadPct(d))}%` : ''} · ${fmtSize(d.size)} · mangas/${esc(d.folder)}</div>
|
||
${active ? `<div class="muted" style="font-size:.8rem">${downloadDetail(d)}</div>` : ''}
|
||
${d.status === 'error' ? `<div class="error" style="font-size:.8rem">${esc(d.error || '')}</div>` : ''}
|
||
<div class="bar dlbar"><i style="width:${downloadPct(d).toFixed(1)}%"></i></div></a>
|
||
<div class="row" style="flex:none">
|
||
${active ? `<button class="btn small" data-cancel="${esc(d.manga_id)}">${icon('stop')} Cancelar</button>` : ''}
|
||
<button class="btn small danger" data-del="${esc(d.manga_id)}" data-title="${esc(d.title)}" data-size="${d.size || 0}">${icon('trash')} Eliminar</button>
|
||
</div></div>`
|
||
}).join('')}</div>`
|
||
app.querySelectorAll('[data-cancel]').forEach(b => b.addEventListener('click', async () => { await api(`/api/download/${b.dataset.cancel}/cancel`, { method: 'POST' }); viewDownloads() }))
|
||
app.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', async () => {
|
||
if (!confirm(`¿Eliminar la copia local de «${b.dataset.title}» (${fmtSize(Number(b.dataset.size))})?\nTu historial de lectura no se toca.`)) return
|
||
await api(`/api/download/${b.dataset.del}`, { method: 'DELETE' })
|
||
toast('Copia local eliminada')
|
||
viewDownloads()
|
||
}))
|
||
if (list.some(d => DL_ACTIVE.includes(d.status))) {
|
||
const t = setTimeout(() => location.hash === '#/downloads' && viewDownloads(), 2000)
|
||
cleanup = () => clearTimeout(t)
|
||
}
|
||
}
|
||
|
||
// ---------- 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="6">
|
||
<button class="btn primary">${icon('key')} Guardar</button></form></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) }
|
||
})
|
||
$('#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) }
|
||
})
|
||
}
|