Basado en las fuentes de Tanko (MangaDex y LeerCapitulo), con usuarios con contraseña, progreso por página guardado en SQLite, lector por página o vertical, filtros de imagen y proxy de imágenes con URLs firmadas. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
441 lines
24 KiB
JavaScript
441 lines
24 KiB
JavaScript
const app = document.getElementById('app')
|
|
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 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 ----------
|
|
async function router() {
|
|
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] === '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">Entrar</button>
|
|
${signup ? '<button class="btn" value="register">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
|
|
return `<a class="cont" href="${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>
|
|
<div class="muted" style="font-size:.8rem">${timeAgo(h.updated_at)}</div>
|
|
<div class="bar"><i style="width:${pct}%"></i></div></div></a>`
|
|
}
|
|
async function viewHome(params) {
|
|
const q = params.get('q') || ''
|
|
const mode = 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)
|
|
app.innerHTML = `
|
|
<section id="cont"></section>
|
|
<div class="tabs" id="sources">${Object.entries(SOURCES).map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}">${v}</button>`).join('')}</div>
|
|
<form class="search"><input type="search" name="q" placeholder="Buscar en ${esc(SOURCES[source])}…" value="${esc(q)}"><button class="btn primary">Buscar</button></form>
|
|
<label class="muted row" style="font-size:.88rem" ${source === 'md' ? '' : 'hidden'}><input type="checkbox" name="any" ${anyLang ? 'checked' : ''}> Incluir mangas sin traducción a ${esc(LANGS[me.lang] || me.lang)}</label>
|
|
${q ? `<h2>Resultados para “${esc(q)}”</h2>` : `<div class="tabs" id="modes"><button data-m="popular" class="${mode === 'popular' ? 'on' : ''}">Populares</button><button data-m="latest" class="${mode === 'latest' ? 'on' : ''}">Actualizados recientemente</button></div>`}
|
|
<div id="results" class="loading">Cargando…</div>`
|
|
const form = $('form', app)
|
|
const go = (extra = {}) => {
|
|
const p = new URLSearchParams({ source, ...(form.q.value ? { q: form.q.value } : { mode }), ...($('[name=any]').checked ? { any: '1' } : {}), ...extra })
|
|
location.hash = `#/?${p}`
|
|
}
|
|
form.addEventListener('submit', e => { e.preventDefault(); go() })
|
|
$('[name=any]').addEventListener('change', () => go())
|
|
app.querySelectorAll('#modes button').forEach(b => b.addEventListener('click', () => { location.hash = `#/?source=${source}&mode=${b.dataset.m}${anyLang ? '&any=1' : ''}` }))
|
|
app.querySelectorAll('#sources button').forEach(b => b.addEventListener('click', () => go({ source: b.dataset.s })))
|
|
|
|
api('/api/history').then(hist => {
|
|
if (!hist.length || q) return
|
|
$('#cont').innerHTML = `<h2>Continuar leyendo</h2><div class="continue">${hist.slice(0, 12).map(continueCard).join('')}</div>`
|
|
}).catch(() => {})
|
|
|
|
const qs = new URLSearchParams({ q, mode, source, ...(anyLang ? { anyLang: '1' } : {}) })
|
|
const list = await api(`/api/search?${qs}`)
|
|
const res = $('#results')
|
|
res.className = list.length ? 'grid' : 'loading'
|
|
res.innerHTML = list.length ? list.map(mangaCard).join('') : 'Sin resultados.'
|
|
}
|
|
|
|
// ---------- Ficha del manga ----------
|
|
async function viewManga(id, langOverride) {
|
|
app.innerHTML = '<div class="loading">Cargando…</div>'
|
|
const lang = langOverride || store.get(`lang:${id}`, me.lang)
|
|
const { manga, chapters, reads, progress, chapterLangs } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
|
|
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)}">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)}">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)}">Empezar a leer</a>`
|
|
|
|
app.innerHTML = `
|
|
<div class="detail">
|
|
<img class="cover" src="${esc(manga.cover || '/icon.svg')}" alt="">
|
|
<div>
|
|
<h1>${esc(manga.title)}</h1>
|
|
<div class="muted">${esc(manga.authors.join(', '))}${manga.year ? ` · ${manga.year}` : ''}${manga.status ? ` · ${esc(manga.status)}` : ''}</div>
|
|
<div class="tags">${manga.tags.map(t => `<span class="tag">${esc(t)}</span>`).join('')}</div>
|
|
<p class="desc">${esc(manga.description)}</p>
|
|
<div class="row">${mainBtn}
|
|
${isLc ? '' : `<select id="lang">${langOpts.map(l => `<option value="${esc(l)}" ${l === lang ? 'selected' : ''}>${esc(LANGS[l] || l)}</option>`).join('')}</select>
|
|
<a class="btn" href="${altSearch}">Buscar en LeerCapitulo</a>`}
|
|
</div>
|
|
<div class="muted" style="font-size:.85rem;margin-top:6px">Fuente: ${esc(SOURCES[manga.source])}</div>
|
|
</div>
|
|
</div>
|
|
<h2>Capítulos <span class="muted">(${chapters.length})</span>
|
|
${chapters.length > 1 ? '<button class="btn small" id="order" style="float:right">Invertir orden</button>' : ''}</h2>
|
|
${chapters.length ? '<ul class="chapters"></ul>' : isLc ? '<p class="muted">Este manga no tiene capítulos.</p>' : `<p class="muted">No hay capítulos legibles en ${esc(LANGS[lang] || lang)} (MangaDex retira algunos por licencia).
|
|
Prueba a <a class="tag" href="${altSearch}">buscarlo en LeerCapitulo</a>.
|
|
${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `<a class="tag" href="#" data-lang="${esc(l)}">${esc(LANGS[l] || l)}</a>`).join(' ')}` : 'No hay capítulos en ningún idioma.'}</p>`}`
|
|
app.querySelectorAll('[data-lang]').forEach(a => a.addEventListener('click', e => { e.preventDefault(); store.set(`lang:${id}`, a.dataset.lang); viewManga(id, a.dataset.lang) }))
|
|
|
|
const ul = $('.chapters', app)
|
|
let desc = store.get('chapterOrderDesc', true)
|
|
const renderList = () => {
|
|
if (!ul) return
|
|
const list = desc ? [...chapters].reverse() : chapters
|
|
ul.innerHTML = list.map(c => {
|
|
const r = readMap.get(c.id)
|
|
const done = r && r.total_pages && r.page >= r.total_pages - 1
|
|
const cls = progress?.chapter_id === c.id ? 'current' : done ? 'read' : ''
|
|
const meta = r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group)
|
|
return `<li><a class="${cls}" href="${readUrl(id, c.id, r && !done ? r.page : (done ? 0 : null))}"><span>${esc(chapLabel(c))}</span><span class="meta">${meta}</span></a></li>`
|
|
}).join('')
|
|
}
|
|
renderList()
|
|
$('#order')?.addEventListener('click', () => { desc = !desc; store.set('chapterOrderDesc', desc); renderList() })
|
|
$('#lang')?.addEventListener('change', e => { store.set(`lang:${id}`, e.target.value); viewManga(id, e.target.value) })
|
|
}
|
|
|
|
// ---------- Lector ----------
|
|
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)
|
|
const settings = { mode: 'paged', fit: 'height', dir: 'ltr', 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=${settings.saver ? 1 : 0}`),
|
|
])
|
|
const { manga, chapters } = info
|
|
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)
|
|
|
|
// 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, totalPages: total,
|
|
})
|
|
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}">←</a>
|
|
<div class="ttl"><b>${esc(manga.title)}</b></div>
|
|
<select id="chap" style="max-width:40vw">${chapOpts}</select>
|
|
<select id="mode"><option value="paged">Por página</option><option value="vertical">Vertical</option></select>
|
|
<select id="fit"><option value="height">Ajustar alto</option><option value="width">Ajustar ancho</option></select>
|
|
<select id="dir"><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' ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
|
|
</div>
|
|
<div id="pages"></div>
|
|
<div class="rbar bottom">
|
|
${prev ? `<a class="btn small" href="${readUrl(mangaId, prev.id)}">« Cap. ant.</a>` : ''}
|
|
<span id="pnum" class="muted"></span>
|
|
<input type="range" id="slider" min="1" max="${total}" value="${page + 1}">
|
|
${next ? `<a class="btn small" href="${readUrl(mangaId, next.id)}">Cap. sig. »</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
|
|
$('#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
|
|
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)}">Siguiente: ${esc(chapLabel(next))}</a>` : '<p>No hay más capítulos disponibles.</p>'}
|
|
<a class="btn" href="#/manga/${mangaId}">Volver a la ficha</a></div>`
|
|
|
|
const setPageLabel = () => { $('#pnum').textContent = `${page + 1} / ${total}`; slider.value = page + 1 }
|
|
let observer = null
|
|
let atEnd = false
|
|
|
|
function goTo(n, fromScroll = false) {
|
|
if (n >= total && settings.mode === 'paged') { atEnd = true; box.innerHTML = endBlock; reader.classList.remove('hide-ui'); return }
|
|
n = Math.min(Math.max(n, 0), total - 1)
|
|
const changed = n !== page || atEnd
|
|
atEnd = false
|
|
page = n
|
|
setPageLabel()
|
|
if (settings.mode === 'paged') {
|
|
const img = $('img', box) || Object.assign(document.createElement('img'), { alt: '' })
|
|
if (!img.isConnected) { box.innerHTML = ''; box.append(img) }
|
|
img.src = pages[page]
|
|
window.scrollTo(0, 0)
|
|
for (let k = 1; k <= 3; k++) if (pages[page + k]) new Image().src = pages[page + k]
|
|
} else if (!fromScroll) {
|
|
box.querySelectorAll('img')[page]?.scrollIntoView()
|
|
}
|
|
if (changed) scheduleSave()
|
|
}
|
|
|
|
function render() {
|
|
observer?.disconnect()
|
|
box.className = settings.mode === 'paged' ? `paged ${settings.fit === 'width' ? 'fit-width' : ''}` : 'vertical'
|
|
$('#fit').hidden = settings.mode !== 'paged'
|
|
$('#dir').hidden = settings.mode !== 'paged'
|
|
if (settings.mode === 'paged') {
|
|
box.innerHTML = ''
|
|
goTo(page)
|
|
return
|
|
}
|
|
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()
|
|
}
|
|
|
|
const saveSettings = () => store.set('readerSettings', settings)
|
|
$('#mode').addEventListener('change', e => { settings.mode = e.target.value; saveSettings(); render() })
|
|
$('#fit').addEventListener('change', e => { settings.fit = e.target.value; saveSettings(); render() })
|
|
$('#dir').addEventListener('change', e => { settings.dir = e.target.value; saveSettings() })
|
|
$('#filter').addEventListener('change', e => { settings.filter = e.target.value; saveSettings(); applyFilter() })
|
|
$('#bright').addEventListener('input', e => { settings.brightness = Number(e.target.value); saveSettings(); applyFilter() })
|
|
$('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; saveSettings(); applyFilter() })
|
|
$('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveSettings(); history.replaceState(null, '', readUrl(mangaId, chapterId, page)); router() })
|
|
$('#chap').addEventListener('change', e => { location.hash = readUrl(mangaId, e.target.value) })
|
|
slider.addEventListener('input', () => goTo(Number(slider.value) - 1))
|
|
|
|
// Toques: izquierda/derecha cambian de página (según dirección), centro muestra/oculta barras.
|
|
box.addEventListener('click', e => {
|
|
if (e.target.closest('a,button')) return
|
|
if (settings.mode !== 'paged') { reader.classList.toggle('hide-ui'); return }
|
|
const x = e.clientX / window.innerWidth
|
|
if (x > 0.35 && x < 0.65) { reader.classList.toggle('hide-ui'); return }
|
|
const forward = (x >= 0.65) === (settings.dir === 'ltr')
|
|
if (atEnd && !forward) { atEnd = false; box.innerHTML = ''; goTo(total - 1); return }
|
|
if (!atEnd) goTo(page + (forward ? 1 : -1))
|
|
})
|
|
const onKey = e => {
|
|
if (settings.mode !== 'paged' || e.target.matches('input,select')) return
|
|
const fwdKey = settings.dir === 'ltr' ? 'ArrowRight' : 'ArrowLeft'
|
|
const backKey = settings.dir === 'ltr' ? 'ArrowLeft' : 'ArrowRight'
|
|
if (e.key === fwdKey || e.key === ' ') { e.preventDefault(); if (!atEnd) goTo(page + 1) }
|
|
else if (e.key === backKey) { if (atEnd) { atEnd = false; box.innerHTML = ''; goTo(total - 1) } else goTo(page - 1) }
|
|
}
|
|
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 })
|
|
|
|
render()
|
|
cleanup = () => {
|
|
if (saveTimer) saveNow()
|
|
observer?.disconnect()
|
|
document.removeEventListener('visibilitychange', onVis)
|
|
window.removeEventListener('pagehide', beacon)
|
|
document.removeEventListener('keydown', onKey)
|
|
window.removeEventListener('scroll', onScroll)
|
|
}
|
|
}
|
|
|
|
// ---------- 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="history">${hist.map(h => `
|
|
<div class="cont">
|
|
${continueCard(h).replace('class="cont"', 'style="display:flex;gap:12px;flex:1;min-width:0;align-items:center"')}
|
|
<div class="row" style="flex:none">
|
|
<a class="btn small" href="#/manga/${h.manga_id}">Ficha</a>
|
|
<button class="btn small danger" data-del="${h.manga_id}">Borrar</button>
|
|
</div>
|
|
</div>`).join('')}</div>`
|
|
app.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', async () => {
|
|
if (!confirm('¿Borrar el historial de este manga?')) return
|
|
await api(`/api/history/${b.dataset.del}`, { method: 'DELETE' })
|
|
viewHistory()
|
|
}))
|
|
}
|
|
|
|
// ---------- 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">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">Guardar</button></form></section>
|
|
</div>`
|
|
$('#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) }
|
|
})
|
|
}
|