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),
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.
- **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`).
## 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 }
}
+466 -55
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"/>',
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>`
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 => {
+1
View File
@@ -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>
+62
View File
@@ -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; }
+10 -1
View File
@@ -6,6 +6,7 @@ import path from 'node:path'
import crypto from 'node:crypto'
import { DatabaseSync } from 'node:sqlite'
import { writeCbz, readCbzIndex, sendCbzEntry, imageType } from './cbz.js'
import { setupFeatures } from './features.js'
const PORT = Number(process.env.PORT || 3352)
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),
authors: (m.relationships || []).filter(r => r.type === 'author' && r.attributes).map(r => r.attributes.name),
languages: a.availableTranslatedLanguages || [],
type: { ja: 'Manga', ko: 'Manhwa', zh: 'Manhua', 'zh-hk': 'Manhua' }[a.originalLanguage] || 'Cómic',
cover,
}
}
@@ -353,6 +355,7 @@ async function lcManga(id) {
title: decode(/<h1[^>]*>([^<]+)/.exec(html)?.[1]) || slug,
description: decode(sinopsis?.replace(/<br\s*\/?>/g, '\n')),
status: fact('Estado'),
type: fact('Tipo') || 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),
authors: fact('Autor') ? [fact('Autor')] : [],
@@ -674,6 +677,7 @@ async function route(req, res) {
// Todo lo demás requiere sesión.
const user = requireUser(req)
if ((await features.route(p, method, q, req, res, user)) !== false) return
if (p === '/api/settings' && method === 'POST') {
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 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(() => [])
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') {
const local = localChapter(m[1])
@@ -783,6 +787,7 @@ async function route(req, res) {
const page = Math.max(0, Number(b.page) | 0)
const total = Math.max(0, Number(b.totalPages) | 0)
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
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 (?, ?, ?, ?, ?, ?)
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)
features.onProgress(user.id, b, page, total, prevPage)
return json(res, 200, { ok: true })
}
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 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 })
}
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.
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')
+4
View File
@@ -8,6 +8,10 @@ services:
- PORT=3352
- DATA_DIR=/app/data
- 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.
# (Si no hay ningún usuario, el primero siempre puede registrarse.)
- ALLOW_SIGNUP=${ALLOW_SIGNUP:-true}