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:
2026-10-31 00:45:19 -06:00
co-authored by Claude Opus 5.5
parent bb25cde388
commit 73fc1d5849
7 changed files with 930 additions and 57 deletions
+9
View File
@@ -15,6 +15,15 @@ Lector web de manga autoalojado, con las dos fuentes de
`/zdata/media/mangas/<Título>/Capitulo NNNN.cbz` (+ `cover.jpg`, `ComicInfo.xml` en cada CBZ), `/zdata/media/mangas/<Título>/Capitulo NNNN.cbz` (+ `cover.jpg`, `ComicInfo.xml` en cada CBZ),
compatible con Kavita/Komga/Mihon. Se reanuda tras reinicios; repetirla baja solo los capítulos compatible con Kavita/Komga/Mihon. Se reanuda tras reinicios; repetirla baja solo los capítulos
nuevos. Los capítulos descargados se leen desde disco y, sin conexión, la ficha usa los datos guardados. nuevos. Los capítulos descargados se leen desde disco y, sin conexión, la ficha usa los datos guardados.
- **Novedades**: los mangas que empiezas a leer (o marcas con "Seguir") se revisan cada
`CHECK_INTERVAL_HOURS` (3 h). El inicio muestra "N caps. nuevos", la ficha marca NUEVO, los
mangas con copia local descargan lo nuevo solos, y se puede avisar por ntfy o Telegram (Ajustes).
- **Lector**: una página, doble página (tablet horizontal/PC; portada y páginas apaisadas van solas)
o vertical. Modo, ajuste y dirección se recuerdan por manga (por defecto: manhwa/manhua en
vertical, manga por página de derecha a izquierda). Zoom con pellizco, doble toque en el centro,
Ctrl+rueda o + / − / 0.
- **Estadísticas**: rachas, capítulos y páginas por semana, calendario de actividad, géneros
favoritos y lo más leído.
- Las imágenes pasan por el servidor (`/api/img`), solo con URLs firmadas por él (clave en `data/secret`). - Las imágenes pasan por el servidor (`/api/img`), solo con URLs firmadas por él (clave en `data/secret`).
## Uso ## Uso
+377
View File
@@ -0,0 +1,377 @@
// Seguimiento de capítulos nuevos (+ notificaciones ntfy/Telegram y autodescarga),
// preferencias del lector por manga y estadísticas de lectura.
const CHECK_HOURS = Number(process.env.CHECK_INTERVAL_HOURS || 3)
const PUBLIC_URL = (process.env.PUBLIC_URL || 'http://192.168.1.141:3352').replace(/\/+$/, '')
// Día local (TZ del contenedor) en formato YYYY-MM-DD.
export const dayOf = ts => new Date(ts).toLocaleDateString('sv-SE')
const sleep = ms => new Promise(r => setTimeout(r, ms))
const chapterKey = c => c.num ?? c.id
// Nombres de género unificados para las estadísticas (LeerCapitulo mezcla inglés y español).
const GENRE_ES = {
action: 'Acción', accion: 'Acción', adventure: 'Aventura', comedy: 'Comedia', drama: 'Drama', fantasy: 'Fantasía',
fantasia: 'Fantasía', romance: 'Romance', horror: 'Terror', terror: 'Terror', mystery: 'Misterio', misterio: 'Misterio',
psychological: 'Psicológico', psicologico: 'Psicológico', 'sci-fi': 'Ciencia ficción', 'science fiction': 'Ciencia ficción',
'ciencia ficcion': 'Ciencia ficción', 'slice of life': 'Recuentos de la vida', 'recuentos de la vida': 'Recuentos de la vida',
sports: 'Deportes', deportes: 'Deportes', supernatural: 'Sobrenatural', sobrenatural: 'Sobrenatural', thriller: 'Thriller',
tragedy: 'Tragedia', tragedia: 'Tragedia', historical: 'Histórico', historico: 'Histórico', mecha: 'Mecha',
'martial arts': 'Artes marciales', 'artes marciales': 'Artes marciales', 'school life': 'Vida escolar', 'vida escolar': 'Vida escolar',
shounen: 'Shounen', shoujo: 'Shoujo', seinen: 'Seinen', josei: 'Josei', isekai: 'Isekai', magic: 'Magia', magia: 'Magia',
harem: 'Harem', ecchi: 'Ecchi', music: 'Música', musica: 'Música', military: 'Militar', militar: 'Militar', medical: 'Médico',
crime: 'Crimen', crimen: 'Crimen', philosophical: 'Filosófico', reincarnation: 'Reencarnación', reencarnacion: 'Reencarnación',
monsters: 'Monstruos', monstruos: 'Monstruos', vampires: 'Vampiros', vampiros: 'Vampiros', zombies: 'Zombis', zombis: 'Zombis',
'video games': 'Videojuegos', videojuegos: 'Videojuegos', 'time travel': 'Viajes en el tiempo', survival: 'Supervivencia',
supervivencia: 'Supervivencia', cooking: 'Cocina', cocina: 'Cocina', 'office workers': 'Oficinistas', villainess: 'Villana',
demons: 'Demonios', demonios: 'Demonios', gore: 'Gore', superhero: 'Superhéroes', 'virtual reality': 'Realidad virtual',
'post-apocalyptic': 'Postapocalíptico', apocaliptico: 'Postapocalíptico', animals: 'Animales', 'gender bender': 'Gender bender',
'boys\' love': 'Boys\' love', yaoi: 'Boys\' love', "girls' love": "Girls' love", yuri: "Girls' love", wuxia: 'Wuxia',
aliens: 'Alienígenas', ninja: 'Ninjas', samurai: 'Samuráis', police: 'Policial', mafia: 'Mafia', delinquents: 'Delincuentes',
'reverse harem': 'Harem inverso', 'traditional games': 'Juegos tradicionales', ghosts: 'Fantasmas', 'monster girls': 'Monster girls',
}
// Etiquetas de formato que no son géneros.
const NOT_GENRE = new Set(['long strip', 'full color', 'web comic', 'official colored', 'fan colored', 'adaptation', 'anthology',
'award winning', 'doujinshi', 'oneshot', '4-koma', 'self-published', 'user created', 'manga', 'manhwa', 'manhua', 'webtoon'])
const genreName = raw => {
const k = String(raw).trim().toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
if (NOT_GENRE.has(k)) return null
return GENRE_ES[k] || String(raw).trim()
}
const MODES = ['paged', 'double', 'vertical']
const FITS = ['height', 'width']
const DIRS = ['ltr', 'rtl']
export function setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID }) {
db.exec(`
CREATE TABLE IF NOT EXISTS follows (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
manga_id TEXT NOT NULL,
title TEXT NOT NULL,
cover TEXT,
lang TEXT,
active INTEGER NOT NULL DEFAULT 1,
known TEXT, -- JSON: números de capítulo ya vistos (null = sin inicializar)
new_keys TEXT NOT NULL DEFAULT '[]', -- JSON: números de capítulo nuevos aún no vistos por el usuario
last_checked INTEGER,
last_new_at INTEGER,
created_at INTEGER NOT NULL,
PRIMARY KEY (user_id, manga_id)
);
CREATE TABLE IF NOT EXISTS manga_prefs (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
manga_id TEXT NOT NULL,
prefs TEXT NOT NULL,
PRIMARY KEY (user_id, manga_id)
);
CREATE TABLE IF NOT EXISTS manga_meta (
manga_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
source TEXT,
type TEXT,
tags TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS page_reads (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
chapter_id TEXT NOT NULL,
page INTEGER NOT NULL,
manga_id TEXT NOT NULL,
day TEXT NOT NULL,
PRIMARY KEY (user_id, chapter_id, page)
);
CREATE INDEX IF NOT EXISTS idx_page_reads_day ON page_reads(user_id, day);
CREATE TABLE IF NOT EXISTS chapter_done (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
chapter_id TEXT NOT NULL,
manga_id TEXT NOT NULL,
day TEXT NOT NULL,
PRIMARY KEY (user_id, chapter_id)
);
CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT);
`)
if (!db.prepare('PRAGMA table_info(users)').all().some(c => c.name === 'notify')) db.exec('ALTER TABLE users ADD COLUMN notify TEXT')
// Migración única: estadísticas e historial previos a esta versión.
if (!db.prepare("SELECT 1 FROM kv WHERE k = 'migr_features_v1'").get()) {
const insPage = db.prepare('INSERT OR IGNORE INTO page_reads (user_id, chapter_id, page, manga_id, day) VALUES (?, ?, ?, ?, ?)')
const insDone = db.prepare('INSERT OR IGNORE INTO chapter_done (user_id, chapter_id, manga_id, day) VALUES (?, ?, ?, ?)')
for (const r of db.prepare('SELECT * FROM chapter_reads').all()) {
const day = dayOf(r.updated_at)
for (let p = 0; p <= Math.min(r.page, 500); p++) insPage.run(r.user_id, r.chapter_id, p, r.manga_id, day)
if (r.total_pages && r.page >= r.total_pages - 1) insDone.run(r.user_id, r.chapter_id, r.manga_id, day)
}
db.prepare(`INSERT OR IGNORE INTO follows (user_id, manga_id, title, cover, lang, created_at)
SELECT user_id, manga_id, manga_title, cover, lang, updated_at FROM progress`).run()
db.prepare("INSERT INTO kv (k, v) VALUES ('migr_features_v1', ?)").run(String(Date.now()))
}
function saveMeta(manga) {
db.prepare(`INSERT INTO manga_meta (manga_id, title, source, type, tags, updated_at) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (manga_id) DO UPDATE SET title = excluded.title, source = excluded.source, type = excluded.type,
tags = excluded.tags, updated_at = excluded.updated_at`)
.run(manga.id, manga.title, manga.source, manga.type || null, JSON.stringify((manga.tags || []).map(t => t.name ?? t)), Date.now())
}
// ---------- Notificaciones ----------
const getNotify = userId => {
try { return JSON.parse(db.prepare('SELECT notify FROM users WHERE id = ?').get(userId)?.notify || '{}') } catch { return {} }
}
async function sendNotification(cfg, { title, message, click }) {
const errors = []
if (cfg.ntfyUrl && cfg.ntfyTopic) {
try {
const headers = { 'Content-Type': 'application/json' }
if (cfg.ntfyToken) headers.Authorization = cfg.ntfyToken.includes(':') ? `Basic ${Buffer.from(cfg.ntfyToken).toString('base64')}` : `Bearer ${cfg.ntfyToken}`
// Publicación JSON: admite UTF-8 en el título (en cabeceras no).
const r = await fetch(cfg.ntfyUrl, {
method: 'POST', headers, signal: AbortSignal.timeout(15e3),
body: JSON.stringify({ topic: cfg.ntfyTopic, title, message, click, tags: ['books'] }),
})
if (!r.ok) errors.push(`ntfy: HTTP ${r.status} ${(await r.text()).slice(0, 120)}`)
} catch (e) { errors.push(`ntfy: ${e.message}`) }
}
if (cfg.tgToken && cfg.tgChat) {
try {
const r = await fetch(`https://api.telegram.org/bot${cfg.tgToken}/sendMessage`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(15e3),
body: JSON.stringify({ chat_id: cfg.tgChat, text: `${title}\n${message}\n${click}`, disable_web_page_preview: true }),
})
if (!r.ok) errors.push(`Telegram: HTTP ${r.status} ${(await r.text()).slice(0, 120)}`)
} catch (e) { errors.push(`Telegram: ${e.message}`) }
}
return errors
}
const hasNotify = cfg => !!((cfg.ntfyUrl && cfg.ntfyTopic) || (cfg.tgToken && cfg.tgChat))
// ---------- Comprobación de capítulos nuevos ----------
const state = { checking: null, lastCheckAt: null, nextCheckAt: null }
async function checkManga(mangaId, lang) {
const { manga, chapters } = await fetchMangaFull(mangaId, lang || 'es-la')
saveMeta(manga)
if (!chapters.length) return 0 // respuesta vacía: no tocar lo conocido
const keys = chapters.map(chapterKey)
const byKey = new Map(chapters.map(c => [chapterKey(c), c]))
let anyNew = 0
const now = Date.now()
for (const f of db.prepare('SELECT * FROM follows WHERE active = 1 AND manga_id = ? AND lang IS ?').all(mangaId, lang ?? null)) {
if (f.known == null) {
db.prepare('UPDATE follows SET known = ?, last_checked = ? WHERE user_id = ? AND manga_id = ?').run(JSON.stringify(keys), now, f.user_id, mangaId)
continue
}
const known = new Set(JSON.parse(f.known))
const fresh = keys.filter(k => !known.has(k))
const pending = [...new Set([...JSON.parse(f.new_keys), ...fresh])]
db.prepare(`UPDATE follows SET known = ?, new_keys = ?, last_checked = ?, last_new_at = COALESCE(?, last_new_at), title = ?
WHERE user_id = ? AND manga_id = ?`)
.run(JSON.stringify([...known, ...fresh]), JSON.stringify(pending), now, fresh.length ? now : null, manga.title, f.user_id, mangaId)
if (!fresh.length) continue
anyNew += fresh.length
const cfg = getNotify(f.user_id)
if (hasNotify(cfg)) {
const nums = fresh.map(k => byKey.get(k)?.num).filter(Boolean)
const message = `${fresh.length === 1 ? '1 capítulo nuevo' : `${fresh.length} capítulos nuevos`}${nums.length ? `: ${nums.slice(0, 8).join(', ')}${nums.length > 8 ? '…' : ''}` : ''}`
const errs = await sendNotification(cfg, { title: manga.title, message, click: `${PUBLIC_URL}/#/manga/${mangaId}` })
if (errs.length) console.warn('Notificación', f.user_id, errs.join('; '))
}
}
// Autodescarga: si el manga ya tiene copia local, bajar también lo nuevo.
const dl = anyNew && downloadInfo(mangaId)
if (dl && !['queued', 'downloading'].includes(dl.status)) enqueueDownload(mangaId, dl.title, dl.lang)
return anyNew
}
function checkFollows() {
if (state.checking) return state.checking
state.checking = (async () => {
const groups = db.prepare('SELECT manga_id, lang FROM follows WHERE active = 1 GROUP BY manga_id, lang').all()
let found = 0
for (const g of groups) {
try { found += await checkManga(g.manga_id, g.lang) } catch (e) { console.warn('Comprobación', g.manga_id, e.message) }
await sleep(g.manga_id.startsWith('lc~') ? 1500 : 2500)
}
state.lastCheckAt = Date.now()
if (groups.length) console.log(`Comprobados ${groups.length} mangas seguidos: ${found} capítulo(s) nuevo(s)`)
})().finally(() => {
state.checking = null
state.nextCheckAt = Date.now() + CHECK_HOURS * 3600e3
})
return state.checking
}
state.nextCheckAt = Date.now() + 90e3
setTimeout(() => {
checkFollows()
setInterval(checkFollows, CHECK_HOURS * 3600e3)
}, 90e3)
// ---------- Hooks llamados desde server.js ----------
function onProgress(userId, b, page, total, prevPage) {
const day = dayOf(Date.now())
const from = prevPage != null && page > prevPage && page - prevPage <= 300 ? prevPage + 1 : page
const ins = db.prepare('INSERT OR IGNORE INTO page_reads (user_id, chapter_id, page, manga_id, day) VALUES (?, ?, ?, ?, ?)')
for (let p = from; p <= page; p++) ins.run(userId, b.chapterId, p, b.mangaId, day)
if (total && page >= total - 1) {
db.prepare('INSERT OR IGNORE INTO chapter_done (user_id, chapter_id, manga_id, day) VALUES (?, ?, ?, ?)').run(userId, b.chapterId, b.mangaId, day)
}
// Seguir automáticamente lo que se empieza a leer (salvo que se haya dejado de seguir).
db.prepare('INSERT OR IGNORE INTO follows (user_id, manga_id, title, cover, lang, created_at) VALUES (?, ?, ?, ?, ?, ?)')
.run(userId, b.mangaId, String(b.mangaTitle || '').slice(0, 300), b.cover ? String(b.cover).slice(0, 1000) : null,
b.mangaId.startsWith('lc~') ? 'es' : b.lang || null, Date.now())
}
// Datos extra de la ficha; marca como vistos los capítulos nuevos.
function mangaExtras(userId, manga) {
saveMeta(manga)
const f = db.prepare('SELECT active, new_keys FROM follows WHERE user_id = ? AND manga_id = ?').get(userId, manga.id)
const newKeys = f?.active ? JSON.parse(f.new_keys) : []
if (newKeys.length) db.prepare("UPDATE follows SET new_keys = '[]' WHERE user_id = ? AND manga_id = ?").run(userId, manga.id)
const prefs = db.prepare('SELECT prefs FROM manga_prefs WHERE user_id = ? AND manga_id = ?').get(userId, manga.id)
return { following: !!f?.active, newKeys, prefs: prefs ? JSON.parse(prefs.prefs) : null }
}
function unfollow(userId, mangaId) {
db.prepare('UPDATE follows SET active = 0 WHERE user_id = ? AND manga_id = ?').run(userId, mangaId)
}
// ---------- Estadísticas ----------
function stats(userId) {
const pagesByDay = new Map(db.prepare('SELECT day, COUNT(*) n FROM page_reads WHERE user_id = ? GROUP BY day').all(userId).map(r => [r.day, r.n]))
const chapsByDay = new Map(db.prepare('SELECT day, COUNT(*) n FROM chapter_done WHERE user_id = ? GROUP BY day').all(userId).map(r => [r.day, r.n]))
const today = dayOf(Date.now())
const addDays = (d, n) => { const x = new Date(`${d}T12:00:00`); x.setDate(x.getDate() + n); return x.toLocaleDateString('sv-SE') }
// Rachas: días consecutivos con alguna página leída.
const days = [...pagesByDay.keys()].sort()
let best = 0, run = 0, prev = null
for (const d of days) {
run = prev && addDays(prev, 1) === d ? run + 1 : 1
best = Math.max(best, run)
prev = d
}
let current = 0
for (let d = pagesByDay.has(today) ? today : addDays(today, -1); pagesByDay.has(d); d = addDays(d, -1)) current++
// Semanas (lunes a domingo), las últimas 12.
const monday = d => { const x = new Date(`${d}T12:00:00`); x.setDate(x.getDate() - ((x.getDay() + 6) % 7)); return x.toLocaleDateString('sv-SE') }
const weeks = []
for (let i = 11, start = monday(today); i >= 0; i--) {
const ws = addDays(start, -7 * i)
let pages = 0, chapters = 0
for (let k = 0; k < 7; k++) { const d = addDays(ws, k); pages += pagesByDay.get(d) || 0; chapters += chapsByDay.get(d) || 0 }
weeks.push({ start: ws, pages, chapters })
}
// Calendario: últimas 18 semanas día a día.
const calStart = addDays(monday(today), -7 * 17)
const calendar = []
for (let d = calStart; d <= today; d = addDays(d, 1)) calendar.push({ day: d, pages: pagesByDay.get(d) || 0, chapters: chapsByDay.get(d) || 0 })
// Géneros ponderados por páginas leídas de cada manga.
const perManga = db.prepare(`SELECT r.manga_id, COUNT(*) pages, m.tags, m.title, m.type FROM page_reads r
LEFT JOIN manga_meta m ON m.manga_id = r.manga_id WHERE r.user_id = ? GROUP BY r.manga_id`).all(userId)
const genreMap = new Map()
let withMeta = 0
for (const m of perManga) {
if (!m.tags) continue
withMeta += m.pages
const names = [...new Set(JSON.parse(m.tags).map(genreName).filter(Boolean))]
for (const g of names) genreMap.set(g, (genreMap.get(g) || 0) + m.pages)
}
const genres = [...genreMap].sort((a, b) => b[1] - a[1]).slice(0, 10)
.map(([name, pages]) => ({ name, pages, pct: withMeta ? Math.round((pages / withMeta) * 100) : 0 }))
const chapByManga = new Map(db.prepare('SELECT manga_id, COUNT(*) n FROM chapter_done WHERE user_id = ? GROUP BY manga_id').all(userId).map(r => [r.manga_id, r.n]))
const progress = new Map(db.prepare('SELECT manga_id, manga_title, cover FROM progress WHERE user_id = ?').all(userId).map(r => [r.manga_id, r]))
const topMangas = perManga.sort((a, b) => b.pages - a.pages).slice(0, 8).map(m => ({
id: m.manga_id, title: m.title || progress.get(m.manga_id)?.manga_title || m.manga_id, cover: progress.get(m.manga_id)?.cover || null,
pages: m.pages, chapters: chapByManga.get(m.manga_id) || 0,
}))
const types = new Map()
for (const m of perManga) if (m.type) types.set(m.type, (types.get(m.type) || 0) + m.pages)
return {
totals: {
pages: [...pagesByDay.values()].reduce((a, b) => a + b, 0),
chapters: [...chapsByDay.values()].reduce((a, b) => a + b, 0),
mangas: perManga.length,
activeDays: days.length,
thisWeekPages: weeks.at(-1).pages,
thisWeekChapters: weeks.at(-1).chapters,
},
streak: { current, best, readToday: pagesByDay.has(today) },
weeks, calendar, genres, topMangas,
types: [...types].sort((a, b) => b[1] - a[1]).map(([name, pages]) => ({ name, pages })),
}
}
// ---------- Rutas ----------
async function route(p, method, q, req, res, user) {
let m
if (p === '/api/follows' && method === 'GET') {
const list = db.prepare('SELECT manga_id, title, cover, lang, new_keys, last_checked, last_new_at FROM follows WHERE user_id = ? AND active = 1 ORDER BY COALESCE(last_new_at, 0) DESC, created_at DESC').all(user.id)
return json(res, 200, {
follows: list.map(f => ({ ...f, newCount: JSON.parse(f.new_keys).length, new_keys: undefined })),
checking: !!state.checking, lastCheckAt: state.lastCheckAt, nextCheckAt: state.nextCheckAt, intervalHours: CHECK_HOURS,
})
}
if (p === '/api/follows/check' && method === 'POST') {
checkFollows()
return json(res, 200, { checking: true })
}
if ((m = /^\/api\/follow\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
const b = await readBody(req)
const lang = m[1].startsWith('lc~') ? 'es' : /^[a-z]{2}(-[a-z]{2})?$/.test(b.lang || '') ? b.lang : user.lang
db.prepare(`INSERT INTO follows (user_id, manga_id, title, cover, lang, active, created_at) VALUES (?, ?, ?, ?, ?, 1, ?)
ON CONFLICT (user_id, manga_id) DO UPDATE SET active = 1, lang = excluded.lang, title = excluded.title, cover = excluded.cover,
known = CASE WHEN follows.lang IS excluded.lang THEN follows.known ELSE NULL END`)
.run(user.id, m[1], String(b.title || '').slice(0, 300), b.cover ? String(b.cover).slice(0, 1000) : null, lang, Date.now())
checkManga(m[1], lang).catch(e => console.warn('Seguir', m[1], e.message)) // inicializa lo conocido
return json(res, 200, { following: true })
}
if ((m = /^\/api\/follow\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') {
unfollow(user.id, m[1])
return json(res, 200, { following: false })
}
if ((m = /^\/api\/prefs\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
const b = await readBody(req)
if (!MODES.includes(b.mode) || !FITS.includes(b.fit) || !DIRS.includes(b.dir)) throw new HttpError(400, 'Preferencias inválidas')
db.prepare('INSERT OR REPLACE INTO manga_prefs (user_id, manga_id, prefs) VALUES (?, ?, ?)')
.run(user.id, m[1], JSON.stringify({ mode: b.mode, fit: b.fit, dir: b.dir }))
return json(res, 200, { ok: true })
}
if (p === '/api/stats' && method === 'GET') return json(res, 200, stats(user.id))
if (p === '/api/notify' && method === 'GET') {
const cfg = getNotify(user.id)
return json(res, 200, { ntfyUrl: cfg.ntfyUrl || '', ntfyTopic: cfg.ntfyTopic || '', hasNtfyToken: !!cfg.ntfyToken, tgChat: cfg.tgChat || '', hasTgToken: !!cfg.tgToken })
}
if (p === '/api/notify' && method === 'POST') {
const b = await readBody(req)
const old = getNotify(user.id)
const str = (v, max) => String(v ?? '').trim().slice(0, max)
const cfg = {
ntfyUrl: str(b.ntfyUrl, 300).replace(/\/+$/, ''),
ntfyTopic: str(b.ntfyTopic, 64),
// Campo de token vacío = conservar el guardado; "-" = borrarlo.
ntfyToken: b.ntfyToken === '-' ? '' : str(b.ntfyToken, 300) || old.ntfyToken || '',
tgToken: b.tgToken === '-' ? '' : str(b.tgToken, 100) || old.tgToken || '',
tgChat: str(b.tgChat, 40),
}
if (cfg.ntfyUrl && !/^https?:\/\/[^\s/]+/.test(cfg.ntfyUrl)) throw new HttpError(400, 'URL de ntfy inválida')
if (cfg.ntfyTopic && !/^[\w-]{1,64}$/.test(cfg.ntfyTopic)) throw new HttpError(400, 'Tema de ntfy inválido (letras, números, - y _)')
if (cfg.tgToken && !/^\d+:[\w-]{20,}$/.test(cfg.tgToken)) throw new HttpError(400, 'Token de Telegram inválido')
if (cfg.tgChat && !/^-?\d+$|^@\w+$/.test(cfg.tgChat)) throw new HttpError(400, 'Chat de Telegram inválido')
db.prepare('UPDATE users SET notify = ? WHERE id = ?').run(JSON.stringify(cfg), user.id)
return json(res, 200, { ok: true })
}
if (p === '/api/notify/test' && method === 'POST') {
const cfg = getNotify(user.id)
if (!hasNotify(cfg)) throw new HttpError(400, 'No hay ningún canal de notificación configurado')
const errors = await sendNotification(cfg, { title: 'Manga Reader', message: 'Notificación de prueba: los avisos de capítulos nuevos llegarán aquí.', click: PUBLIC_URL })
if (errors.length) throw new HttpError(502, errors.join(' · '))
return json(res, 200, { ok: true })
}
return false
}
return { route, onProgress, mangaExtras, unfollow, checkFollows }
}
+467 -56
View File
@@ -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"/>', 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"/>', 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"/>', 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>` 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). // 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] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1)
if (parts[0] === 'history') return await viewHistory() if (parts[0] === 'history') return await viewHistory()
if (parts[0] === 'downloads') return await viewDownloads() if (parts[0] === 'downloads') return await viewDownloads()
if (parts[0] === 'stats') return await viewStats()
if (parts[0] === 'settings') return viewSettings() if (parts[0] === 'settings') return viewSettings()
return await viewHome(params) return await viewHome(params)
} catch (e) { } catch (e) {
@@ -179,6 +186,39 @@ function continueCard(h) {
<div class="muted" style="font-size:.8rem">${timeAgo(h.updated_at)}</div> <div class="muted" style="font-size:.8rem">${timeAgo(h.updated_at)}</div>
<div class="bar"><i style="width:${pct}%"></i></div></div></a>` <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)}` const tagHref = (source, t) => `#/?source=${source}&tag=${encodeURIComponent(t.id)}&tagName=${encodeURIComponent(t.name)}`
async function viewHome(params) { async function viewHome(params) {
const q = params.get('q') || '' 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('#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' : ''}` })) 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 (!q && !tag && !showCats) {
if (!hist.length || q || tag || showCats) return Promise.all([api('/api/history'), api('/api/follows')]).then(([hist, fol]) => {
$('#cont').innerHTML = `<h2>Continuar leyendo</h2><div class="continue">${hist.slice(0, 12).map(continueCard).join('')}</div>` if (!$('#cont')) return
}).catch(() => {}) $('#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') const res = $('#results')
if (showCats) { if (showCats) {
@@ -253,8 +296,10 @@ async function viewManga(id, langOverride) {
if (cleanup) { cleanup(); cleanup = null } if (cleanup) { cleanup(); cleanup = null }
app.innerHTML = '<div class="loading">Cargando…</div>' app.innerHTML = '<div class="loading">Cargando…</div>'
const lang = langOverride || store.get(`lang:${id}`, me.lang) 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 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 readMap = new Map(reads.map(r => [r.chapter_id, r]))
const isLc = manga.source === 'lc' const isLc = manga.source === 'lc'
const langOpts = [...new Set([lang, me.lang, ...chapterLangs, ...manga.languages])].filter(Boolean) 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> <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> <p class="desc">${esc(manga.description)}</p>
<div class="row">${mainBtn} <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>` : ''} ${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> ${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>`} <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> <p class="error" id="dlerr" style="font-size:.85rem"></p>
</div> </div>
</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 > 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). ${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>. Prueba a <a class="tag" href="${altSearch}">buscarlo en LeerCapitulo</a>.
@@ -327,8 +373,22 @@ async function viewManga(id, langOverride) {
} }
renderDl(download) renderDl(download)
cleanup = () => clearTimeout(dlTimer) 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 () => { $('#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' }) await api(`/api/history/${id}`, { method: 'DELETE' })
toast('Quitado del historial') toast('Quitado del historial')
viewManga(id, langOverride) viewManga(id, langOverride)
@@ -344,7 +404,7 @@ async function viewManga(id, langOverride) {
const r = readMap.get(c.id) const r = readMap.get(c.id)
const done = r && r.total_pages && r.page >= r.total_pages - 1 const done = r && r.total_pages && r.page >= r.total_pages - 1
const cls = progress?.chapter_id === c.id ? 'current' : done ? 'read' : '' 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>` 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('') }).join('')
} }
@@ -354,16 +414,27 @@ async function viewManga(id, langOverride) {
} }
// ---------- Lector ---------- // ---------- 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) { async function viewReader(mangaId, chapterId, startPage) {
document.body.classList.add('reading') document.body.classList.add('reading')
app.innerHTML = '<div class="reader"><div class="loading">Cargando capítulo…</div></div>' app.innerHTML = '<div class="reader"><div class="loading">Cargando capítulo…</div></div>'
const lang = store.get(`lang:${mangaId}`, me.lang) 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([ const [info, chap] = await Promise.all([
api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`), 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 { manga, chapters } = info
const settings = { ...globalSettings, ...defaultPrefs(manga), ...(info.prefs || {}) }
const idx = chapters.findIndex(c => c.id === chapterId) const idx = chapters.findIndex(c => c.id === chapterId)
const chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' } const chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' }
const prev = idx > 0 ? chapters[idx - 1] : null const prev = idx > 0 ? chapters[idx - 1] : null
@@ -371,12 +442,16 @@ async function viewReader(mangaId, chapterId, startPage) {
const pages = chap.pages const pages = chap.pages
const total = pages.length const total = pages.length
let page = Math.min(Math.max(startPage >= 0 ? startPage : chap.savedPage || 0, 0), total - 1) 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). // Guardado del progreso (con debounce y al salir de la página).
let saveTimer = null let saveTimer = null
const payload = () => ({ const payload = () => ({
mangaId, chapterId, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapter.num ?? null, 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 saveNow = () => { clearTimeout(saveTimer); saveTimer = null; api('/api/progress', { method: 'POST', body: payload() }).catch(() => {}) }
const scheduleSave = () => { clearTimeout(saveTimer); saveTimer = setTimeout(saveNow, 700) } 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> <a class="btn small" href="#/manga/${mangaId}" title="Volver a la ficha">${icon('back')}</a>
<div class="ttl"><b>${esc(manga.title)}</b></div> <div class="ttl"><b>${esc(manga.title)}</b></div>
<select id="chap" style="max-width:40vw">${chapOpts}</select> <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> <button class="btn small" id="ropts" title="Opciones de lectura">${icon('settings')}</button>
<select id="fit"><option value="height">Ajustar alto</option><option value="width">Ajustar ancho</option></select> </div>
<select id="dir"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select> <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> <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> <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>'} ${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>
<div id="pages"></div> <div id="pages"></div>
<div class="rbar bottom"> <div class="rbar bottom">
${prev ? `<a class="btn small" href="${readUrl(mangaId, prev.id)}">${icon('prev')} Cap. ant.</a>` : ''} ${prev ? `<a class="btn small" href="${readUrl(mangaId, prev.id)}">${icon('prev')} Cap. ant.</a>` : ''}
<span id="pnum" class="muted"></span> <span id="pnum" class="muted"></span>
<input type="range" id="slider" min="1" max="${total}" value="${page + 1}"> <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>` : ''} ${next ? `<a class="btn small" href="${readUrl(mangaId, next.id)}">Cap. sig. ${icon('next')}</a>` : ''}
</div> </div>
</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>'} ${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>` <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 observer = null
let atEnd = false let atEnd = false
function goTo(n, fromScroll = false) { // --- Doble página: la 1.ª va sola (portada) y las apaisadas (ya dobles) también.
if (n >= total && settings.mode === 'paged') { atEnd = true; box.innerHTML = endBlock; reader.classList.remove('hide-ui'); return } const wide = new Set()
n = Math.min(Math.max(n, 0), total - 1) const isWide = img => img.naturalWidth > img.naturalHeight * 1.1
const changed = n !== page || atEnd function groupOf(i) {
atEnd = false if (!isDouble()) return [i]
page = n for (let s = 0; s < total;) {
setPageLabel() const g = s === 0 || wide.has(s) || wide.has(s + 1) || s + 1 >= total ? [s] : [s, s + 1]
if (settings.mode === 'paged') { if (g.includes(i)) return g
const img = $('img', box) || Object.assign(document.createElement('img'), { alt: '' }) s += g.length
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()
} }
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() 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() { function render() {
observer?.disconnect() observer?.disconnect()
box.className = settings.mode === 'paged' ? `paged ${settings.fit === 'width' ? 'fit-width' : ''}` : 'vertical' const keep = lastShown()
$('#fit').hidden = settings.mode !== 'paged' resetZoom()
$('#dir').hidden = settings.mode !== 'paged' box.className = isPaged() ? `paged ${settings.fit === 'width' ? 'fit-width' : ''} ${isDouble() ? 'double' : ''}` : 'vertical'
if (settings.mode === 'paged') { box.dataset.dir = settings.dir
$('#fit').hidden = !isPaged()
$('#dir').hidden = !isPaged()
if (isPaged()) {
box.innerHTML = '' box.innerHTML = ''
goTo(page) atEnd = false
shown = []
goTo(keep)
return 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.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 })) box.querySelectorAll('img').forEach(img => img.addEventListener('load', () => img.setAttribute('data-loaded', ''), { once: true }))
const lazy = new IntersectionObserver(entries => entries.forEach(e => { const lazy = new IntersectionObserver(entries => entries.forEach(e => {
@@ -483,33 +631,140 @@ async function viewReader(mangaId, chapterId, startPage) {
setPageLabel() setPageLabel()
} }
const saveSettings = () => store.set('readerSettings', settings) // --- Zoom (modos por página): pellizco, doble toque/clic en el centro, Ctrl+rueda y teclas + - 0.
$('#mode').addEventListener('change', e => { settings.mode = e.target.value; saveSettings(); render() }) const zoom = { s: 1, x: 0, y: 0 }
$('#fit').addEventListener('change', e => { settings.fit = e.target.value; saveSettings(); render() }) const stageEl = () => $('.stage', box)
$('#dir').addEventListener('change', e => { settings.dir = e.target.value; saveSettings() }) function applyZoom() {
$('#filter').addEventListener('change', e => { settings.filter = e.target.value; saveSettings(); applyFilter() }) const st = stageEl()
$('#bright').addEventListener('input', e => { settings.brightness = Number(e.target.value); saveSettings(); applyFilter() }) if (st) st.style.transform = zoom.s === 1 ? '' : `translate(${zoom.x}px, ${zoom.y}px) scale(${zoom.s})`
$('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; saveSettings(); applyFilter() }) box.classList.toggle('zoomed', zoom.s > 1)
$('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveSettings(); history.replaceState(null, '', readUrl(mangaId, chapterId, page)); router() }) $('#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) }) $('#chap').addEventListener('change', e => { location.hash = readUrl(mangaId, e.target.value) })
slider.addEventListener('input', () => goTo(Number(slider.value) - 1)) 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 => { 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 fwdKey = settings.dir === 'ltr' ? 'ArrowRight' : 'ArrowLeft'
const backKey = settings.dir === 'ltr' ? 'ArrowLeft' : 'ArrowRight' const backKey = settings.dir === 'ltr' ? 'ArrowLeft' : 'ArrowRight'
if (e.key === fwdKey || e.key === ' ') { e.preventDefault(); if (!atEnd) goTo(page + 1) } const [cx, cy] = [box.clientWidth / 2, Math.min(box.clientHeight, window.innerHeight) / 2]
else if (e.key === backKey) { if (atEnd) { atEnd = false; box.innerHTML = ''; goTo(total - 1) } else goTo(page - 1) } 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) document.addEventListener('keydown', onKey)
let lastY = 0 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 } if (Math.abs(y - lastY) > 30) { reader.classList.toggle('hide-ui', y > lastY); lastY = y }
} }
window.addEventListener('scroll', onScroll, { passive: true }) 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() render()
cleanup = () => { cleanup = () => {
if (saveTimer) saveNow() if (saveTimer) saveNow()
observer?.disconnect() observer?.disconnect()
clearTimeout(uiTimer)
document.removeEventListener('visibilitychange', onVis) document.removeEventListener('visibilitychange', onVis)
window.removeEventListener('pagehide', beacon) window.removeEventListener('pagehide', beacon)
document.removeEventListener('keydown', onKey) document.removeEventListener('keydown', onKey)
window.removeEventListener('scroll', onScroll) 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 ---------- // ---------- Ajustes ----------
function viewSettings() { function viewSettings() {
app.innerHTML = `<h1>Ajustes</h1> 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="old" placeholder="Contraseña actual" autocomplete="current-password" required>
<input type="password" name="new" placeholder="Nueva contraseña" autocomplete="new-password" required minlength="6"> <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> <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>` </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() }) $('#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') }) $('#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 => { $('#pw').addEventListener('submit', async e => {
+1
View File
@@ -15,6 +15,7 @@
<a href="#/" data-icon="home">Inicio</a> <a href="#/" data-icon="home">Inicio</a>
<a href="#/history" data-icon="history">Historial</a> <a href="#/history" data-icon="history">Historial</a>
<a href="#/downloads" data-icon="download">Descargas</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> <a href="#/settings" data-icon="settings">Ajustes</a>
</nav> </nav>
</header> </header>
+62
View File
@@ -1,3 +1,4 @@
[hidden] { display: none !important; }
:root { :root {
--bg: #14121a; --bg: #14121a;
--surface: #1e1b26; --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; } #topbar nav a .ic { width: 1.35em; height: 1.35em; }
.search .btn .lbl { display: none; } .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; }
+10 -1
View File
@@ -6,6 +6,7 @@ import path from 'node:path'
import crypto from 'node:crypto' import crypto from 'node:crypto'
import { DatabaseSync } from 'node:sqlite' import { DatabaseSync } from 'node:sqlite'
import { writeCbz, readCbzIndex, sendCbzEntry, imageType } from './cbz.js' import { writeCbz, readCbzIndex, sendCbzEntry, imageType } from './cbz.js'
import { setupFeatures } from './features.js'
const PORT = Number(process.env.PORT || 3352) const PORT = Number(process.env.PORT || 3352)
const DATA_DIR = process.env.DATA_DIR || '/app/data' const DATA_DIR = process.env.DATA_DIR || '/app/data'
@@ -170,6 +171,7 @@ function mangaSummary(m, lang) {
tags: (a.tags || []).map(t => ({ id: t.id, name: pick(t.attributes.name, ['es', 'en']) })).slice(0, 12), tags: (a.tags || []).map(t => ({ id: t.id, name: pick(t.attributes.name, ['es', 'en']) })).slice(0, 12),
authors: (m.relationships || []).filter(r => r.type === 'author' && r.attributes).map(r => r.attributes.name), authors: (m.relationships || []).filter(r => r.type === 'author' && r.attributes).map(r => r.attributes.name),
languages: a.availableTranslatedLanguages || [], languages: a.availableTranslatedLanguages || [],
type: { ja: 'Manga', ko: 'Manhwa', zh: 'Manhua', 'zh-hk': 'Manhua' }[a.originalLanguage] || 'Cómic',
cover, cover,
} }
} }
@@ -353,6 +355,7 @@ async function lcManga(id) {
title: decode(/<h1[^>]*>([^<]+)/.exec(html)?.[1]) || slug, title: decode(/<h1[^>]*>([^<]+)/.exec(html)?.[1]) || slug,
description: decode(sinopsis?.replace(/<br\s*\/?>/g, '\n')), description: decode(sinopsis?.replace(/<br\s*\/?>/g, '\n')),
status: fact('Estado'), status: fact('Estado'),
type: fact('Tipo') || null,
year: null, year: null,
tags: [...html.matchAll(/class="badge [^"]*" href="\/manga\/\?(genre|theme)=([a-z0-9-]+)">([^<]+)</g)].map(m => ({ id: `${m[1]}:${m[2]}`, name: decode(m[3]) })).slice(0, 12), tags: [...html.matchAll(/class="badge [^"]*" href="\/manga\/\?(genre|theme)=([a-z0-9-]+)">([^<]+)</g)].map(m => ({ id: `${m[1]}:${m[2]}`, name: decode(m[3]) })).slice(0, 12),
authors: fact('Autor') ? [fact('Autor')] : [], authors: fact('Autor') ? [fact('Autor')] : [],
@@ -674,6 +677,7 @@ async function route(req, res) {
// Todo lo demás requiere sesión. // Todo lo demás requiere sesión.
const user = requireUser(req) const user = requireUser(req)
if ((await features.route(p, method, q, req, res, user)) !== false) return
if (p === '/api/settings' && method === 'POST') { if (p === '/api/settings' && method === 'POST') {
const body = await readBody(req) const body = await readBody(req)
@@ -725,7 +729,7 @@ async function route(req, res) {
const reads = db.prepare('SELECT chapter_id, page, total_pages FROM chapter_reads WHERE user_id = ? AND manga_id = ?').all(user.id, m[1]) const reads = db.prepare('SELECT chapter_id, page, total_pages FROM chapter_reads WHERE user_id = ? AND manga_id = ?').all(user.id, m[1])
const progress = db.prepare('SELECT * FROM progress WHERE user_id = ? AND manga_id = ?').get(user.id, m[1]) ?? null const progress = db.prepare('SELECT * FROM progress WHERE user_id = ? AND manga_id = ?').get(user.id, m[1]) ?? null
const chapterLangs = chapters.length || manga.source === 'lc' || offline ? [] : await getChapterLangs(m[1]).catch(() => []) const chapterLangs = chapters.length || manga.source === 'lc' || offline ? [] : await getChapterLangs(m[1]).catch(() => [])
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true) }) return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), ...features.mangaExtras(user.id, manga) })
} }
if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') { if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') {
const local = localChapter(m[1]) const local = localChapter(m[1])
@@ -783,6 +787,7 @@ async function route(req, res) {
const page = Math.max(0, Number(b.page) | 0) const page = Math.max(0, Number(b.page) | 0)
const total = Math.max(0, Number(b.totalPages) | 0) const total = Math.max(0, Number(b.totalPages) | 0)
const now = Date.now() const now = Date.now()
const prevPage = db.prepare('SELECT page FROM chapter_reads WHERE user_id = ? AND chapter_id = ?').get(user.id, b.chapterId)?.page
db.prepare(`INSERT INTO progress (user_id, manga_id, manga_title, cover, chapter_id, chapter_num, chapter_title, lang, page, total_pages, updated_at) db.prepare(`INSERT INTO progress (user_id, manga_id, manga_title, cover, chapter_id, chapter_num, chapter_title, lang, page, total_pages, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id, manga_id) DO UPDATE SET manga_title = excluded.manga_title, cover = excluded.cover, ON CONFLICT (user_id, manga_id) DO UPDATE SET manga_title = excluded.manga_title, cover = excluded.cover,
@@ -794,17 +799,21 @@ async function route(req, res) {
db.prepare(`INSERT INTO chapter_reads (user_id, chapter_id, manga_id, page, total_pages, updated_at) VALUES (?, ?, ?, ?, ?, ?) db.prepare(`INSERT INTO chapter_reads (user_id, chapter_id, manga_id, page, total_pages, updated_at) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id, chapter_id) DO UPDATE SET page = excluded.page, total_pages = excluded.total_pages, updated_at = excluded.updated_at`) ON CONFLICT (user_id, chapter_id) DO UPDATE SET page = excluded.page, total_pages = excluded.total_pages, updated_at = excluded.updated_at`)
.run(user.id, b.chapterId, b.mangaId, page, total, now) .run(user.id, b.chapterId, b.mangaId, page, total, now)
features.onProgress(user.id, b, page, total, prevPage)
return json(res, 200, { ok: true }) return json(res, 200, { ok: true })
} }
if ((m = /^\/api\/history\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') { if ((m = /^\/api\/history\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') {
db.prepare('DELETE FROM progress WHERE user_id = ? AND manga_id = ?').run(user.id, m[1]) db.prepare('DELETE FROM progress WHERE user_id = ? AND manga_id = ?').run(user.id, m[1])
db.prepare('DELETE FROM chapter_reads WHERE user_id = ? AND manga_id = ?').run(user.id, m[1]) db.prepare('DELETE FROM chapter_reads WHERE user_id = ? AND manga_id = ?').run(user.id, m[1])
features.unfollow(user.id, m[1])
return json(res, 200, { ok: true }) return json(res, 200, { ok: true })
} }
throw new HttpError(404, 'No encontrado') throw new HttpError(404, 'No encontrado')
} }
const features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID })
// Migración: portadas guardadas antes de firmar las URLs del proxy. // Migración: portadas guardadas antes de firmar las URLs del proxy.
for (const row of db.prepare("SELECT user_id, manga_id, cover FROM progress WHERE cover LIKE '/api/img?u=%' AND cover NOT LIKE '%&s=%'").all()) { for (const row of db.prepare("SELECT user_id, manga_id, cover FROM progress WHERE cover LIKE '/api/img?u=%' AND cover NOT LIKE '%&s=%'").all()) {
const u = new URLSearchParams(row.cover.slice('/api/img?'.length)).get('u') const u = new URLSearchParams(row.cover.slice('/api/img?'.length)).get('u')
+4
View File
@@ -8,6 +8,10 @@ services:
- PORT=3352 - PORT=3352
- DATA_DIR=/app/data - DATA_DIR=/app/data
- MANGA_DIR=/mangas - MANGA_DIR=/mangas
# Cada cuántas horas se buscan capítulos nuevos de los mangas seguidos.
- CHECK_INTERVAL_HOURS=${CHECK_INTERVAL_HOURS:-3}
# URL con la que se abre la web desde las notificaciones.
- PUBLIC_URL=${PUBLIC_URL:-http://192.168.1.141:3352}
# Pon "false" cuando ya hayas creado las cuentas que necesites. # Pon "false" cuando ya hayas creado las cuentas que necesites.
# (Si no hay ningún usuario, el primero siempre puede registrarse.) # (Si no hay ningún usuario, el primero siempre puede registrarse.)
- ALLOW_SIGNUP=${ALLOW_SIGNUP:-true} - ALLOW_SIGNUP=${ALLOW_SIGNUP:-true}