Novedades, ajustes del lector por manga, doble página, zoom y estadísticas
- Seguimiento de mangas (automático al empezar a leer o con "Seguir"): comprobación cada CHECK_INTERVAL_HOURS, "N caps. nuevos" en el inicio, marcas NUEVO en la ficha, autodescarga si hay copia local y avisos por ntfy o Telegram configurables en Ajustes. - Modo, ajuste y dirección del lector guardados por manga en el servidor, con valores por defecto según el tipo (manhwa/manhua vertical, manga der→izq). - Modo doble página (portada y páginas apaisadas solas; vuelve a una página en pantallas estrechas o en vertical). - Zoom: pellizco, doble toque en el centro, Ctrl+rueda y teclas + - 0. - Opciones del lector en un panel desplegable. - Página de estadísticas: rachas, capítulos/páginas por semana, calendario, géneros favoritos y lo más leído (con migración de las lecturas previas). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+467
-56
@@ -26,6 +26,12 @@ const ICONS = {
|
||||
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"/>',
|
||||
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).
|
||||
@@ -127,6 +133,7 @@ async function router() {
|
||||
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] === 'settings') return viewSettings()
|
||||
return await viewHome(params)
|
||||
} catch (e) {
|
||||
@@ -179,6 +186,39 @@ function continueCard(h) {
|
||||
<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') || ''
|
||||
@@ -211,10 +251,13 @@ async function viewHome(params) {
|
||||
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' : ''}` }))
|
||||
|
||||
api('/api/history').then(hist => {
|
||||
if (!hist.length || q || tag || showCats) return
|
||||
$('#cont').innerHTML = `<h2>Continuar leyendo</h2><div class="continue">${hist.slice(0, 12).map(continueCard).join('')}</div>`
|
||||
}).catch(() => {})
|
||||
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.length ? `<h2>Continuar leyendo</h2><div class="continue">${hist.slice(0, 12).map(continueCard).join('')}</div>` : '')
|
||||
bindNews(fol)
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const res = $('#results')
|
||||
if (showCats) {
|
||||
@@ -253,8 +296,10 @@ 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 } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
|
||||
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, following, 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)
|
||||
@@ -277,6 +322,7 @@ async function viewManga(id, langOverride) {
|
||||
<div class="tags">${manga.tags.map(t => (t.id ? `<a class="tag" href="${tagHref(manga.source, t)}">${esc(t.name)}</a>` : `<span class="tag">${esc(t.name ?? t)}</span>`)).join('')}</div>
|
||||
<p class="desc">${esc(manga.description)}</p>
|
||||
<div class="row">${mainBtn}
|
||||
<button class="btn" id="follow"></button>
|
||||
${progress || reads.length ? `<button class="btn danger" id="drop">${icon('stopRead')} Dejar de leer</button>` : ''}
|
||||
${isLc ? '' : `<select id="lang">${langOpts.map(l => `<option value="${esc(l)}" ${l === lang ? 'selected' : ''}>${esc(LANGS[l] || l)}</option>`).join('')}</select>
|
||||
<a class="btn" href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`}
|
||||
@@ -287,7 +333,7 @@ async function viewManga(id, langOverride) {
|
||||
<p class="error" id="dlerr" style="font-size:.85rem"></p>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Capítulos <span class="muted">(${chapters.length})</span>
|
||||
<h2>Capítulos <span class="muted">(${chapters.length})</span>${newSet.size ? ` <span class="badge">${newSet.size} ${newSet.size === 1 ? 'nuevo' : 'nuevos'}</span>` : ''}
|
||||
${chapters.length > 1 ? `<button class="btn small" id="order" style="float:right">${icon('sort')} Invertir orden</button>` : ''}</h2>
|
||||
${chapters.length ? '<ul class="chapters"></ul>' : isLc ? '<p class="muted">Este manga no tiene capítulos.</p>' : `<p class="muted">No hay capítulos legibles en ${esc(LANGS[lang] || lang)} (MangaDex retira algunos por licencia).
|
||||
Prueba a <a class="tag" href="${altSearch}">buscarlo en LeerCapitulo</a>.
|
||||
@@ -327,8 +373,22 @@ async function viewManga(id, langOverride) {
|
||||
}
|
||||
renderDl(download)
|
||||
cleanup = () => clearTimeout(dlTimer)
|
||||
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 y se borrará el progreso de sus capítulos.`)) return
|
||||
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')
|
||||
viewManga(id, langOverride)
|
||||
@@ -344,7 +404,7 @@ async function viewManga(id, langOverride) {
|
||||
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 = (dlSet.has(c.id) ? `<span title="Descargado">${icon('download')}</span> ` : '') + (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group))
|
||||
const meta = (newSet.has(String(c.num ?? c.id)) ? '<span class="badge">NUEVO</span> ' : '') + (dlSet.has(c.id) ? `<span title="Descargado">${icon('download')}</span> ` : '') + (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group))
|
||||
return `<li><a class="${cls}" href="${readUrl(id, c.id, r && !done ? r.page : (done ? 0 : null))}"><span>${esc(chapLabel(c))}</span><span class="meta">${meta}</span></a></li>`
|
||||
}).join('')
|
||||
}
|
||||
@@ -354,16 +414,27 @@ async function viewManga(id, langOverride) {
|
||||
}
|
||||
|
||||
// ---------- 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)
|
||||
const settings = { mode: 'paged', fit: 'height', dir: 'ltr', saver: false, filter: 'none', brightness: 100, ...store.get('readerSettings', {}) }
|
||||
// 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=${settings.saver ? 1 : 0}`),
|
||||
api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}`),
|
||||
])
|
||||
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
|
||||
@@ -371,12 +442,16 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
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, totalPages: total,
|
||||
chapterTitle: chapter.title, lang: manga.source === 'lc' ? 'es' : chapter.lang || lang, page: lastShown(), totalPages: total,
|
||||
})
|
||||
const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; api('/api/progress', { method: 'POST', body: payload() }).catch(() => {}) }
|
||||
const scheduleSave = () => { clearTimeout(saveTimer); saveTimer = setTimeout(saveNow, 700) }
|
||||
@@ -393,18 +468,23 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
<a class="btn small" href="#/manga/${mangaId}" title="Volver a la ficha">${icon('back')}</a>
|
||||
<div class="ttl"><b>${esc(manga.title)}</b></div>
|
||||
<select id="chap" style="max-width:40vw">${chapOpts}</select>
|
||||
<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>
|
||||
<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' ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
|
||||
<p class="muted">Modo, ajuste y dirección se recuerdan para este manga. Zoom: pellizca o toca dos veces en el centro (Ctrl+rueda o + / − en PC).</p>
|
||||
</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>`
|
||||
@@ -426,39 +506,107 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
${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>`
|
||||
|
||||
const setPageLabel = () => { $('#pnum').textContent = `${page + 1} / ${total}`; slider.value = page + 1 }
|
||||
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
|
||||
|
||||
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()
|
||||
// --- 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()
|
||||
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') {
|
||||
const keep = lastShown()
|
||||
resetZoom()
|
||||
box.className = isPaged() ? `paged ${settings.fit === 'width' ? 'fit-width' : ''} ${isDouble() ? 'double' : ''}` : 'vertical'
|
||||
box.dataset.dir = settings.dir
|
||||
$('#fit').hidden = !isPaged()
|
||||
$('#dir').hidden = !isPaged()
|
||||
if (isPaged()) {
|
||||
box.innerHTML = ''
|
||||
goTo(page)
|
||||
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 => {
|
||||
@@ -483,33 +631,140 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
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() })
|
||||
// --- 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 })
|
||||
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; savePrefs(); render() })
|
||||
$('#fit').addEventListener('change', e => { settings.fit = e.target.value; savePrefs(); render() })
|
||||
$('#dir').addEventListener('change', e => { settings.dir = e.target.value; box.dataset.dir = settings.dir; savePrefs() })
|
||||
$('#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() })
|
||||
$('#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
|
||||
if (!isPaged() || 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) }
|
||||
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
|
||||
@@ -519,15 +774,28 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
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 = () => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,6 +855,114 @@ async function viewDownloads() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 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>
|
||||
@@ -600,7 +976,42 @@ function viewSettings() {
|
||||
<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 => {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<a href="#/" data-icon="home">Inicio</a>
|
||||
<a href="#/history" data-icon="history">Historial</a>
|
||||
<a href="#/downloads" data-icon="download">Descargas</a>
|
||||
<a href="#/stats" data-icon="chart">Estadísticas</a>
|
||||
<a href="#/settings" data-icon="settings">Ajustes</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
[hidden] { display: none !important; }
|
||||
:root {
|
||||
--bg: #14121a;
|
||||
--surface: #1e1b26;
|
||||
@@ -141,3 +142,64 @@ a.tag:hover, .tag.on { background: var(--accent); color: var(--accent-ink); }
|
||||
#topbar nav a .ic { width: 1.35em; height: 1.35em; }
|
||||
.search .btn .lbl { display: none; }
|
||||
}
|
||||
|
||||
/* Lector: escenario de páginas, doble página y zoom */
|
||||
.paged { overflow: hidden; touch-action: none; }
|
||||
.paged.fit-width:not(.zoomed) { overflow: visible; touch-action: pan-y; }
|
||||
.paged .stage { display: flex; justify-content: center; align-items: center; width: 100%; height: 100%; transform-origin: 0 0; will-change: transform; }
|
||||
.paged.fit-width .stage { height: auto; align-items: flex-start; }
|
||||
.paged[data-dir="rtl"] .stage { flex-direction: row-reverse; }
|
||||
.paged .stage.two img { max-width: 50%; }
|
||||
.paged.fit-width .stage.two img { width: 50%; max-width: 700px; }
|
||||
.paged img { -webkit-user-drag: none; user-select: none; }
|
||||
.paged.zoomed { cursor: grab; }
|
||||
|
||||
/* Insignias y botón Seguir */
|
||||
.badge { display: inline-block; font-size: .72rem; font-weight: 700; padding: 1px 7px; border-radius: 999px; background: var(--accent); color: var(--accent-ink); vertical-align: middle; }
|
||||
.btn.on { border-color: var(--accent); color: var(--accent); }
|
||||
h2 .ic { color: var(--accent); }
|
||||
|
||||
/* Estadísticas */
|
||||
:root { --viz-1: #5b3f8c; --viz-2: #7d4fc0; --viz-3: #a067e3; --viz-4: #c993ff; --viz-5: #ecd6ff; }
|
||||
.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin: 16px 0; }
|
||||
.tile { background: var(--surface); border-radius: var(--radius); padding: 14px 16px; }
|
||||
.tile .tv { font-size: 1.6rem; font-weight: 700; line-height: 1.2; }
|
||||
.tile .tl { color: var(--muted); font-size: .88rem; }
|
||||
.tile .ts { color: var(--muted); font-size: .78rem; margin-top: 4px; }
|
||||
.charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; }
|
||||
.panel { background: var(--surface); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 12px; min-width: 0; }
|
||||
.panel h3 { margin: 0 0 10px; font-size: .95rem; }
|
||||
.chart { width: 100%; height: auto; display: block; }
|
||||
.chart .grid { stroke: var(--surface-2); stroke-width: 1; }
|
||||
.chart .axis { fill: var(--muted); font-size: 11px; }
|
||||
.chart .mark { fill: var(--accent); }
|
||||
.chart .hit { fill: transparent; }
|
||||
.chart .col:hover .hit { fill: rgba(255, 255, 255, .04); }
|
||||
.heat-wrap { overflow-x: auto; }
|
||||
.heat { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 14px); grid-auto-columns: 14px; gap: 3px; }
|
||||
.cell { display: inline-block; width: 14px; height: 14px; border-radius: 3px; background: var(--surface-2); }
|
||||
.cell.empty { background: transparent; }
|
||||
.cell.l1 { background: var(--viz-1); } .cell.l2 { background: var(--viz-2); } .cell.l3 { background: var(--viz-3); }
|
||||
.cell.l4 { background: var(--viz-4); } .cell.l5 { background: var(--viz-5); }
|
||||
.heat-legend { display: flex; align-items: center; gap: 3px; font-size: .8rem; margin-top: 8px; }
|
||||
.hbars { display: grid; gap: 8px; }
|
||||
.hbar { display: grid; grid-template-columns: minmax(90px, 140px) 1fr 3em; gap: 8px; align-items: center; font-size: .88rem; }
|
||||
.hbar .hl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hbar .ht { height: 10px; background: var(--surface-2); border-radius: 0 4px 4px 0; }
|
||||
.hbar .ht i { display: block; height: 100%; background: var(--accent); border-radius: 0 4px 4px 0; }
|
||||
.hbar .hv { text-align: right; color: var(--muted); }
|
||||
.toplist { display: grid; gap: 8px; }
|
||||
.toplist .cont { padding: 6px 8px; background: var(--bg); }
|
||||
.toplist .cont img { width: 36px; height: 54px; }
|
||||
.toplist .rank { width: 1.4em; text-align: center; color: var(--muted); font-weight: 700; }
|
||||
details.panel summary { cursor: pointer; color: var(--muted); }
|
||||
table.data { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: .88rem; }
|
||||
table.data th, table.data td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--surface-2); }
|
||||
table.data td:not(:first-child), table.data th:not(:first-child) { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
#tip { position: fixed; z-index: 60; pointer-events: none; background: #000; color: var(--text); padding: 6px 10px; border-radius: 8px; font-size: .82rem; box-shadow: 0 4px 14px rgba(0,0,0,.5); max-width: 280px; }
|
||||
.rpanel { position: fixed; top: 52px; right: 8px; left: auto; z-index: 31; display: grid; gap: 8px; padding: 12px; width: min(320px, calc(100vw - 16px));
|
||||
background: rgba(10, 9, 14, .96); border: 1px solid var(--surface-2); border-radius: var(--radius); box-shadow: 0 8px 24px rgba(0,0,0,.5); }
|
||||
.rpanel select, .rpanel label { width: 100%; }
|
||||
.rpanel p { margin: 0; font-size: .78rem; }
|
||||
.reader.hide-ui .rpanel { display: none; }
|
||||
.chart .axis { font-size: 12px; }
|
||||
|
||||
Reference in New Issue
Block a user