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:
+377
@@ -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
@@ -26,6 +26,12 @@ const ICONS = {
|
||||
grid: '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>',
|
||||
globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18z"/>',
|
||||
key: '<circle cx="8" cy="15" r="4"/><path d="m11 12 9-9M17 6l3 3"/>',
|
||||
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.9 1.9 0 0 0 3.4 0"/>',
|
||||
bellOff: '<path d="M8.7 3A6 6 0 0 1 18 8a21 21 0 0 0 .6 5"/><path d="M17 17H3s3-2 3-9a4.7 4.7 0 0 1 .3-2"/><path d="M10.3 21a1.9 1.9 0 0 0 3.4 0"/><path d="m2 2 20 20"/>',
|
||||
chart: '<path d="M3 3v18h18"/><path d="M7 16v-4M12 16V8M17 16v-7"/>',
|
||||
zoomOut: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5M8 11h6"/>',
|
||||
send: '<path d="M22 2 11 13"/><path d="M22 2 15 22l-4-9-9-4z"/>',
|
||||
sparkles: '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>',
|
||||
}
|
||||
const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`
|
||||
// Iconos del menú superior (index.html).
|
||||
@@ -127,6 +133,7 @@ async function router() {
|
||||
if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1)
|
||||
if (parts[0] === 'history') return await viewHistory()
|
||||
if (parts[0] === 'downloads') return await viewDownloads()
|
||||
if (parts[0] === 'stats') return await viewStats()
|
||||
if (parts[0] === 'settings') return viewSettings()
|
||||
return await viewHome(params)
|
||||
} catch (e) {
|
||||
@@ -179,6 +186,39 @@ function continueCard(h) {
|
||||
<div class="muted" style="font-size:.8rem">${timeAgo(h.updated_at)}</div>
|
||||
<div class="bar"><i style="width:${pct}%"></i></div></div></a>`
|
||||
}
|
||||
// Sección "Novedades": mangas seguidos con capítulos nuevos.
|
||||
function newsSection(fol) {
|
||||
const fresh = fol.follows.filter(f => f.newCount > 0)
|
||||
const status = fol.checking ? 'Comprobando…' : fol.lastCheckAt ? `Última comprobación ${timeAgo(fol.lastCheckAt)}` : `Se comprueba cada ${fol.intervalHours} h`
|
||||
if (!fol.follows.length) return ''
|
||||
return `<div class="row" style="justify-content:space-between;margin-top:8px">
|
||||
<h2 style="margin:12px 0">${icon('sparkles')} Novedades ${fresh.length ? `<span class="badge">${fresh.reduce((n, f) => n + f.newCount, 0)}</span>` : ''}</h2>
|
||||
<span class="row muted" style="font-size:.85rem">Sigues ${fol.follows.length} manga${fol.follows.length === 1 ? '' : 's'} · ${status}
|
||||
<button class="btn small" id="checknow" ${fol.checking ? 'disabled' : ''}>${icon('refresh')} Comprobar ahora</button></span></div>
|
||||
${fresh.length ? `<div class="continue">${fresh.map(f => `<a class="cont" href="#/manga/${f.manga_id}">
|
||||
<img src="${esc(f.cover || '/icon.svg')}" alt="" loading="lazy">
|
||||
<div class="info"><b>${esc(f.title)}</b>
|
||||
<div><span class="badge">${f.newCount} ${f.newCount === 1 ? 'cap. nuevo' : 'caps. nuevos'}</span></div>
|
||||
<div class="muted" style="font-size:.8rem">${f.last_new_at ? timeAgo(f.last_new_at) : ''}</div></div></a>`).join('')}</div>`
|
||||
: '<p class="muted" style="margin-top:0">No hay capítulos nuevos en los mangas que sigues.</p>'}`
|
||||
}
|
||||
function bindNews() {
|
||||
$('#checknow')?.addEventListener('click', async e => {
|
||||
e.target.closest('button').disabled = true
|
||||
await api('/api/follows/check', { method: 'POST' })
|
||||
toast('Comprobando capítulos nuevos…')
|
||||
// Refrescar la sección mientras dura la comprobación.
|
||||
const poll = async () => {
|
||||
const fol = await api('/api/follows').catch(() => null)
|
||||
if (!fol || !$('#news')) return
|
||||
$('#news').innerHTML = newsSection(fol)
|
||||
bindNews()
|
||||
if (fol.checking) setTimeout(poll, 3000)
|
||||
else toast('Comprobación terminada')
|
||||
}
|
||||
setTimeout(poll, 2000)
|
||||
})
|
||||
}
|
||||
const tagHref = (source, t) => `#/?source=${source}&tag=${encodeURIComponent(t.id)}&tagName=${encodeURIComponent(t.name)}`
|
||||
async function viewHome(params) {
|
||||
const q = params.get('q') || ''
|
||||
@@ -211,10 +251,13 @@ async function viewHome(params) {
|
||||
app.querySelectorAll('#modes button').forEach(b => b.addEventListener('click', () => { location.hash = `#/?source=${source}&mode=${b.dataset.m}${anyLang ? '&any=1' : ''}` }))
|
||||
app.querySelectorAll('#sources button').forEach(b => b.addEventListener('click', () => { location.hash = `#/?source=${b.dataset.s}&mode=${mode}${anyLang ? '&any=1' : ''}` }))
|
||||
|
||||
api('/api/history').then(hist => {
|
||||
if (!hist.length || q || tag || showCats) return
|
||||
$('#cont').innerHTML = `<h2>Continuar leyendo</h2><div class="continue">${hist.slice(0, 12).map(continueCard).join('')}</div>`
|
||||
}).catch(() => {})
|
||||
if (!q && !tag && !showCats) {
|
||||
Promise.all([api('/api/history'), api('/api/follows')]).then(([hist, fol]) => {
|
||||
if (!$('#cont')) return
|
||||
$('#cont').innerHTML = `<div id="news">${newsSection(fol)}</div>` + (hist.length ? `<h2>Continuar leyendo</h2><div class="continue">${hist.slice(0, 12).map(continueCard).join('')}</div>` : '')
|
||||
bindNews(fol)
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const res = $('#results')
|
||||
if (showCats) {
|
||||
@@ -253,8 +296,10 @@ async function viewManga(id, langOverride) {
|
||||
if (cleanup) { cleanup(); cleanup = null }
|
||||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||||
const lang = langOverride || store.get(`lang:${id}`, me.lang)
|
||||
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
|
||||
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, following, newKeys = [] } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
|
||||
const dlSet = new Set(downloaded)
|
||||
const newSet = new Set(newKeys.map(String))
|
||||
let isFollowing = following
|
||||
const readMap = new Map(reads.map(r => [r.chapter_id, r]))
|
||||
const isLc = manga.source === 'lc'
|
||||
const langOpts = [...new Set([lang, me.lang, ...chapterLangs, ...manga.languages])].filter(Boolean)
|
||||
@@ -277,6 +322,7 @@ async function viewManga(id, langOverride) {
|
||||
<div class="tags">${manga.tags.map(t => (t.id ? `<a class="tag" href="${tagHref(manga.source, t)}">${esc(t.name)}</a>` : `<span class="tag">${esc(t.name ?? t)}</span>`)).join('')}</div>
|
||||
<p class="desc">${esc(manga.description)}</p>
|
||||
<div class="row">${mainBtn}
|
||||
<button class="btn" id="follow"></button>
|
||||
${progress || reads.length ? `<button class="btn danger" id="drop">${icon('stopRead')} Dejar de leer</button>` : ''}
|
||||
${isLc ? '' : `<select id="lang">${langOpts.map(l => `<option value="${esc(l)}" ${l === lang ? 'selected' : ''}>${esc(LANGS[l] || l)}</option>`).join('')}</select>
|
||||
<a class="btn" href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`}
|
||||
@@ -287,7 +333,7 @@ async function viewManga(id, langOverride) {
|
||||
<p class="error" id="dlerr" style="font-size:.85rem"></p>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Capítulos <span class="muted">(${chapters.length})</span>
|
||||
<h2>Capítulos <span class="muted">(${chapters.length})</span>${newSet.size ? ` <span class="badge">${newSet.size} ${newSet.size === 1 ? 'nuevo' : 'nuevos'}</span>` : ''}
|
||||
${chapters.length > 1 ? `<button class="btn small" id="order" style="float:right">${icon('sort')} Invertir orden</button>` : ''}</h2>
|
||||
${chapters.length ? '<ul class="chapters"></ul>' : isLc ? '<p class="muted">Este manga no tiene capítulos.</p>' : `<p class="muted">No hay capítulos legibles en ${esc(LANGS[lang] || lang)} (MangaDex retira algunos por licencia).
|
||||
Prueba a <a class="tag" href="${altSearch}">buscarlo en LeerCapitulo</a>.
|
||||
@@ -327,8 +373,22 @@ async function viewManga(id, langOverride) {
|
||||
}
|
||||
renderDl(download)
|
||||
cleanup = () => clearTimeout(dlTimer)
|
||||
const renderFollow = () => {
|
||||
const b = $('#follow')
|
||||
b.innerHTML = isFollowing ? `${icon('bell')} Siguiendo` : `${icon('bellOff')} Seguir`
|
||||
b.classList.toggle('on', isFollowing)
|
||||
b.title = isFollowing ? 'Recibes avisos de capítulos nuevos. Pulsa para dejar de seguir.' : 'Avisarme cuando salgan capítulos nuevos'
|
||||
b.hidden = offline
|
||||
}
|
||||
renderFollow()
|
||||
$('#follow').addEventListener('click', async () => {
|
||||
const r = await api(`/api/follow/${id}`, isFollowing ? { method: 'DELETE' } : { method: 'POST', body: { title: manga.title, cover: manga.cover, lang } })
|
||||
isFollowing = r.following
|
||||
renderFollow()
|
||||
toast(isFollowing ? 'Te avisaremos de los capítulos nuevos' : 'Ya no sigues este manga')
|
||||
})
|
||||
$('#drop')?.addEventListener('click', async () => {
|
||||
if (!confirm(`¿Dejar de leer «${manga.title}»?\nSe quitará del historial y se borrará el progreso de sus capítulos.`)) return
|
||||
if (!confirm(`¿Dejar de leer «${manga.title}»?\nSe quitará del historial, se borrará el progreso de sus capítulos y dejarás de seguirlo.`)) return
|
||||
await api(`/api/history/${id}`, { method: 'DELETE' })
|
||||
toast('Quitado del historial')
|
||||
viewManga(id, langOverride)
|
||||
@@ -344,7 +404,7 @@ async function viewManga(id, langOverride) {
|
||||
const r = readMap.get(c.id)
|
||||
const done = r && r.total_pages && r.page >= r.total_pages - 1
|
||||
const cls = progress?.chapter_id === c.id ? 'current' : done ? 'read' : ''
|
||||
const meta = (dlSet.has(c.id) ? `<span title="Descargado">${icon('download')}</span> ` : '') + (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group))
|
||||
const meta = (newSet.has(String(c.num ?? c.id)) ? '<span class="badge">NUEVO</span> ' : '') + (dlSet.has(c.id) ? `<span title="Descargado">${icon('download')}</span> ` : '') + (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group))
|
||||
return `<li><a class="${cls}" href="${readUrl(id, c.id, r && !done ? r.page : (done ? 0 : null))}"><span>${esc(chapLabel(c))}</span><span class="meta">${meta}</span></a></li>`
|
||||
}).join('')
|
||||
}
|
||||
@@ -354,16 +414,27 @@ async function viewManga(id, langOverride) {
|
||||
}
|
||||
|
||||
// ---------- Lector ----------
|
||||
const READ_MODES = { paged: 'Una página', double: 'Doble página', vertical: 'Vertical' }
|
||||
// Ajustes por defecto según el tipo de obra (se usan hasta que el usuario cambie algo en ese manga).
|
||||
function defaultPrefs(manga) {
|
||||
if (['Manhwa', 'Manhua', 'Webtoon'].includes(manga.type)) return { mode: 'vertical', fit: 'width', dir: 'ltr' }
|
||||
if (manga.type === 'Manga') return { mode: 'paged', fit: 'height', dir: 'rtl' }
|
||||
const legacy = store.get('readerSettings', {})
|
||||
return { mode: READ_MODES[legacy.mode] ? legacy.mode : 'paged', fit: legacy.fit === 'width' ? 'width' : 'height', dir: legacy.dir === 'rtl' ? 'rtl' : 'ltr' }
|
||||
}
|
||||
|
||||
async function viewReader(mangaId, chapterId, startPage) {
|
||||
document.body.classList.add('reading')
|
||||
app.innerHTML = '<div class="reader"><div class="loading">Cargando capítulo…</div></div>'
|
||||
const lang = store.get(`lang:${mangaId}`, me.lang)
|
||||
const settings = { mode: 'paged', fit: 'height', dir: 'ltr', saver: false, filter: 'none', brightness: 100, ...store.get('readerSettings', {}) }
|
||||
// Filtro, brillo y ahorro de datos son globales (navegador); modo, ajuste y dirección van por manga (servidor).
|
||||
const globalSettings = { saver: false, filter: 'none', brightness: 100, ...store.get('readerSettings', {}) }
|
||||
const [info, chap] = await Promise.all([
|
||||
api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`),
|
||||
api(`/api/chapter/${chapterId}?saver=${settings.saver ? 1 : 0}`),
|
||||
api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}`),
|
||||
])
|
||||
const { manga, chapters } = info
|
||||
const settings = { ...globalSettings, ...defaultPrefs(manga), ...(info.prefs || {}) }
|
||||
const idx = chapters.findIndex(c => c.id === chapterId)
|
||||
const chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' }
|
||||
const prev = idx > 0 ? chapters[idx - 1] : null
|
||||
@@ -371,12 +442,16 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
const pages = chap.pages
|
||||
const total = pages.length
|
||||
let page = Math.min(Math.max(startPage >= 0 ? startPage : chap.savedPage || 0, 0), total - 1)
|
||||
let shown = [page] // páginas visibles (2 en doble página)
|
||||
const isPaged = () => settings.mode !== 'vertical'
|
||||
const isDouble = () => settings.mode === 'double' && window.innerWidth >= 700 && window.innerWidth > window.innerHeight
|
||||
const lastShown = () => (isPaged() ? shown.at(-1) : page)
|
||||
|
||||
// Guardado del progreso (con debounce y al salir de la página).
|
||||
let saveTimer = null
|
||||
const payload = () => ({
|
||||
mangaId, chapterId, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapter.num ?? null,
|
||||
chapterTitle: chapter.title, lang: manga.source === 'lc' ? 'es' : chapter.lang || lang, page, totalPages: total,
|
||||
chapterTitle: chapter.title, lang: manga.source === 'lc' ? 'es' : chapter.lang || lang, page: lastShown(), totalPages: total,
|
||||
})
|
||||
const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; api('/api/progress', { method: 'POST', body: payload() }).catch(() => {}) }
|
||||
const scheduleSave = () => { clearTimeout(saveTimer); saveTimer = setTimeout(saveNow, 700) }
|
||||
@@ -393,18 +468,23 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
<a class="btn small" href="#/manga/${mangaId}" title="Volver a la ficha">${icon('back')}</a>
|
||||
<div class="ttl"><b>${esc(manga.title)}</b></div>
|
||||
<select id="chap" style="max-width:40vw">${chapOpts}</select>
|
||||
<select id="mode"><option value="paged">Por página</option><option value="vertical">Vertical</option></select>
|
||||
<select id="fit"><option value="height">Ajustar alto</option><option value="width">Ajustar ancho</option></select>
|
||||
<select id="dir"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select>
|
||||
<button class="btn small" id="ropts" title="Opciones de lectura">${icon('settings')}</button>
|
||||
</div>
|
||||
<div class="rpanel" hidden>
|
||||
<select id="mode" title="Modo de lectura (se recuerda para este manga)">${Object.entries(READ_MODES).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
|
||||
<select id="fit" title="Ajuste (se recuerda para este manga)"><option value="height">Ajustar alto</option><option value="width">Ajustar ancho</option></select>
|
||||
<select id="dir" title="Dirección (se recuerda para este manga)"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select>
|
||||
<select id="filter" title="Filtro">${Object.entries(FILTERS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
|
||||
<label class="row muted" style="font-size:.85rem" title="Brillo">☀ <input type="range" id="bright" min="40" max="120" step="5" style="width:90px"></label>
|
||||
${manga.source === 'lc' ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
|
||||
<p class="muted">Modo, ajuste y dirección se recuerdan para este manga. Zoom: pellizca o toca dos veces en el centro (Ctrl+rueda o + / − en PC).</p>
|
||||
</div>
|
||||
<div id="pages"></div>
|
||||
<div class="rbar bottom">
|
||||
${prev ? `<a class="btn small" href="${readUrl(mangaId, prev.id)}">${icon('prev')} Cap. ant.</a>` : ''}
|
||||
<span id="pnum" class="muted"></span>
|
||||
<input type="range" id="slider" min="1" max="${total}" value="${page + 1}">
|
||||
<button class="btn small" id="zoomreset" hidden title="Quitar zoom">${icon('zoomOut')}</button>
|
||||
${next ? `<a class="btn small" href="${readUrl(mangaId, next.id)}">Cap. sig. ${icon('next')}</a>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
@@ -426,39 +506,107 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
${next ? `<a class="btn primary" href="${readUrl(mangaId, next.id)}">${icon('next')} Siguiente: ${esc(chapLabel(next))}</a>` : '<p>No hay más capítulos disponibles.</p>'}
|
||||
<a class="btn" href="#/manga/${mangaId}">${icon('back')} Volver a la ficha</a></div>`
|
||||
|
||||
const setPageLabel = () => { $('#pnum').textContent = `${page + 1} / ${total}`; slider.value = page + 1 }
|
||||
if (!info.prefs && !store.get(`autoPrefs:${mangaId}`)) {
|
||||
store.set(`autoPrefs:${mangaId}`, 1)
|
||||
if (manga.type) toast(`${manga.type}: ${READ_MODES[settings.mode].toLowerCase()}${settings.mode === 'vertical' ? '' : `, ${settings.dir === 'rtl' ? 'der → izq' : 'izq → der'}`}. Lo que cambies se recordará.`)
|
||||
}
|
||||
|
||||
const setPageLabel = () => {
|
||||
$('#pnum').textContent = `${shown.length > 1 ? `${shown[0] + 1}-${shown[1] + 1}` : lastShown() + 1} / ${total}`
|
||||
slider.value = lastShown() + 1
|
||||
}
|
||||
let observer = null
|
||||
let atEnd = false
|
||||
|
||||
function goTo(n, fromScroll = false) {
|
||||
if (n >= total && settings.mode === 'paged') { atEnd = true; box.innerHTML = endBlock; reader.classList.remove('hide-ui'); return }
|
||||
n = Math.min(Math.max(n, 0), total - 1)
|
||||
const changed = n !== page || atEnd
|
||||
atEnd = false
|
||||
page = n
|
||||
setPageLabel()
|
||||
if (settings.mode === 'paged') {
|
||||
const img = $('img', box) || Object.assign(document.createElement('img'), { alt: '' })
|
||||
if (!img.isConnected) { box.innerHTML = ''; box.append(img) }
|
||||
img.src = pages[page]
|
||||
window.scrollTo(0, 0)
|
||||
for (let k = 1; k <= 3; k++) if (pages[page + k]) new Image().src = pages[page + k]
|
||||
} else if (!fromScroll) {
|
||||
box.querySelectorAll('img')[page]?.scrollIntoView()
|
||||
// --- Doble página: la 1.ª va sola (portada) y las apaisadas (ya dobles) también.
|
||||
const wide = new Set()
|
||||
const isWide = img => img.naturalWidth > img.naturalHeight * 1.1
|
||||
function groupOf(i) {
|
||||
if (!isDouble()) return [i]
|
||||
for (let s = 0; s < total;) {
|
||||
const g = s === 0 || wide.has(s) || wide.has(s + 1) || s + 1 >= total ? [s] : [s, s + 1]
|
||||
if (g.includes(i)) return g
|
||||
s += g.length
|
||||
}
|
||||
return [i]
|
||||
}
|
||||
function preload() {
|
||||
for (let k = 1; k <= 4; k++) {
|
||||
const i = shown.at(-1) + k
|
||||
if (i >= total) break
|
||||
const im = new Image()
|
||||
im.onload = () => isWide(im) && wide.add(i)
|
||||
im.src = pages[i]
|
||||
}
|
||||
}
|
||||
function showPaged() {
|
||||
let stage = $('.stage', box)
|
||||
if (!stage) { box.innerHTML = '<div class="stage"></div>'; stage = $('.stage', box) }
|
||||
stage.classList.toggle('two', shown.length === 2)
|
||||
stage.innerHTML = shown.map(i => `<img data-i="${i}" src="${pages[i]}" alt="" draggable="false">`).join('')
|
||||
stage.querySelectorAll('img').forEach(img => img.addEventListener('load', () => {
|
||||
const i = Number(img.dataset.i)
|
||||
if (isWide(img) && !wide.has(i)) {
|
||||
wide.add(i)
|
||||
if (shown.length === 2 && shown.includes(i)) goTo(shown[0], false, true) // reagrupar
|
||||
}
|
||||
}, { once: true }))
|
||||
resetZoom()
|
||||
window.scrollTo(0, 0)
|
||||
preload()
|
||||
}
|
||||
function showEnd() {
|
||||
atEnd = true
|
||||
resetZoom()
|
||||
box.innerHTML = endBlock
|
||||
reader.classList.remove('hide-ui')
|
||||
}
|
||||
|
||||
function goTo(n, fromScroll = false, force = false) {
|
||||
if (isPaged()) {
|
||||
if (n >= total) return showEnd()
|
||||
n = Math.min(Math.max(n, 0), total - 1)
|
||||
const g = groupOf(n)
|
||||
const changed = atEnd || g[0] !== shown[0] || g.length !== shown.length
|
||||
atEnd = false
|
||||
shown = g
|
||||
page = g[0]
|
||||
setPageLabel()
|
||||
if (changed || force || !$('.stage img', box)) showPaged()
|
||||
if (changed) scheduleSave()
|
||||
return
|
||||
}
|
||||
n = Math.min(Math.max(n, 0), total - 1)
|
||||
const changed = n !== page
|
||||
page = n
|
||||
shown = [n]
|
||||
setPageLabel()
|
||||
if (!fromScroll) box.querySelectorAll('img')[page]?.scrollIntoView()
|
||||
if (changed) scheduleSave()
|
||||
}
|
||||
const forward = () => { if (!atEnd) goTo(shown.at(-1) + 1) }
|
||||
const backward = () => {
|
||||
if (atEnd) { box.innerHTML = ''; goTo(total - 1, false, true); return }
|
||||
if (page > 0) goTo(groupOf(page - 1)[0])
|
||||
}
|
||||
|
||||
function render() {
|
||||
observer?.disconnect()
|
||||
box.className = settings.mode === 'paged' ? `paged ${settings.fit === 'width' ? 'fit-width' : ''}` : 'vertical'
|
||||
$('#fit').hidden = settings.mode !== 'paged'
|
||||
$('#dir').hidden = settings.mode !== 'paged'
|
||||
if (settings.mode === 'paged') {
|
||||
const keep = lastShown()
|
||||
resetZoom()
|
||||
box.className = isPaged() ? `paged ${settings.fit === 'width' ? 'fit-width' : ''} ${isDouble() ? 'double' : ''}` : 'vertical'
|
||||
box.dataset.dir = settings.dir
|
||||
$('#fit').hidden = !isPaged()
|
||||
$('#dir').hidden = !isPaged()
|
||||
if (isPaged()) {
|
||||
box.innerHTML = ''
|
||||
goTo(page)
|
||||
atEnd = false
|
||||
shown = []
|
||||
goTo(keep)
|
||||
return
|
||||
}
|
||||
page = keep
|
||||
shown = [keep]
|
||||
box.innerHTML = pages.map((src, i) => `<img data-i="${i}" ${i < page + 3 ? `src="${src}"` : `data-src="${src}"`} alt="">`).join('') + endBlock
|
||||
box.querySelectorAll('img').forEach(img => img.addEventListener('load', () => img.setAttribute('data-loaded', ''), { once: true }))
|
||||
const lazy = new IntersectionObserver(entries => entries.forEach(e => {
|
||||
@@ -483,33 +631,140 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
setPageLabel()
|
||||
}
|
||||
|
||||
const saveSettings = () => store.set('readerSettings', settings)
|
||||
$('#mode').addEventListener('change', e => { settings.mode = e.target.value; saveSettings(); render() })
|
||||
$('#fit').addEventListener('change', e => { settings.fit = e.target.value; saveSettings(); render() })
|
||||
$('#dir').addEventListener('change', e => { settings.dir = e.target.value; saveSettings() })
|
||||
$('#filter').addEventListener('change', e => { settings.filter = e.target.value; saveSettings(); applyFilter() })
|
||||
$('#bright').addEventListener('input', e => { settings.brightness = Number(e.target.value); saveSettings(); applyFilter() })
|
||||
$('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; saveSettings(); applyFilter() })
|
||||
$('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveSettings(); history.replaceState(null, '', readUrl(mangaId, chapterId, page)); router() })
|
||||
// --- Zoom (modos por página): pellizco, doble toque/clic en el centro, Ctrl+rueda y teclas + - 0.
|
||||
const zoom = { s: 1, x: 0, y: 0 }
|
||||
const stageEl = () => $('.stage', box)
|
||||
function applyZoom() {
|
||||
const st = stageEl()
|
||||
if (st) st.style.transform = zoom.s === 1 ? '' : `translate(${zoom.x}px, ${zoom.y}px) scale(${zoom.s})`
|
||||
box.classList.toggle('zoomed', zoom.s > 1)
|
||||
$('#zoomreset').hidden = zoom.s === 1
|
||||
}
|
||||
function clampPan() {
|
||||
const st = stageEl()
|
||||
if (!st) return
|
||||
const W = st.offsetWidth, H = st.offsetHeight
|
||||
zoom.x = Math.min(0, Math.max(W - W * zoom.s, zoom.x))
|
||||
zoom.y = Math.min(0, Math.max(H - H * zoom.s, zoom.y))
|
||||
}
|
||||
// (px, py): punto en coordenadas del contenedor que queda fijo al cambiar la escala.
|
||||
function zoomAt(s, px, py) {
|
||||
s = Math.min(5, Math.max(1, s))
|
||||
const cx = (px - zoom.x) / zoom.s, cy = (py - zoom.y) / zoom.s
|
||||
zoom.s = s
|
||||
zoom.x = px - cx * s
|
||||
zoom.y = py - cy * s
|
||||
if (s === 1) { zoom.x = 0; zoom.y = 0 }
|
||||
clampPan()
|
||||
applyZoom()
|
||||
}
|
||||
function resetZoom() { zoom.s = 1; zoom.x = 0; zoom.y = 0; applyZoom() }
|
||||
const local = (cx, cy) => { const r = box.getBoundingClientRect(); return [cx - r.left, cy - r.top] }
|
||||
|
||||
const toggleUi = () => { reader.classList.toggle('hide-ui'); if (reader.classList.contains('hide-ui')) $('.rpanel', reader).hidden = true }
|
||||
const pts = new Map()
|
||||
let gesture = null, tap = null, lastTap = { t: 0, x: 0, y: 0 }, uiTimer = null
|
||||
function onTap(cx, cy) {
|
||||
const now = Date.now()
|
||||
const dbl = now - lastTap.t < 300 && Math.hypot(cx - lastTap.x, cy - lastTap.y) < 40
|
||||
lastTap = dbl ? { t: 0, x: 0, y: 0 } : { t: now, x: cx, y: cy }
|
||||
const xr = cx / window.innerWidth
|
||||
const center = xr > 0.3 && xr < 0.7
|
||||
if (dbl && (center || zoom.s > 1)) {
|
||||
clearTimeout(uiTimer)
|
||||
if (zoom.s > 1) resetZoom()
|
||||
else zoomAt(2.5, ...local(cx, cy))
|
||||
return
|
||||
}
|
||||
if (zoom.s > 1) return // con zoom, un toque no pasa página
|
||||
if (center) { clearTimeout(uiTimer); uiTimer = setTimeout(toggleUi, 280); return }
|
||||
;(xr >= 0.7) === (settings.dir === 'ltr') ? forward() : backward()
|
||||
}
|
||||
box.addEventListener('pointerdown', e => {
|
||||
if (!isPaged() || atEnd || e.target.closest('a,button,select,input')) return
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return
|
||||
pts.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||||
if (pts.size === 1) {
|
||||
tap = { x: e.clientX, y: e.clientY, t: Date.now(), moved: false }
|
||||
gesture = zoom.s > 1 ? { type: 'pan', x: e.clientX, y: e.clientY } : null
|
||||
if (gesture) box.setPointerCapture?.(e.pointerId)
|
||||
} else if (pts.size === 2) {
|
||||
const [a, b] = [...pts.values()]
|
||||
gesture = { type: 'pinch', d: Math.hypot(a.x - b.x, a.y - b.y), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 }
|
||||
tap = null
|
||||
}
|
||||
})
|
||||
box.addEventListener('pointermove', e => {
|
||||
if (!pts.has(e.pointerId)) return
|
||||
pts.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||||
if (tap && Math.hypot(e.clientX - tap.x, e.clientY - tap.y) > 10) tap.moved = true
|
||||
if (gesture?.type === 'pinch' && pts.size >= 2) {
|
||||
const [a, b] = [...pts.values()]
|
||||
const d = Math.hypot(a.x - b.x, a.y - b.y), mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2
|
||||
zoom.x += mx - gesture.mx
|
||||
zoom.y += my - gesture.my
|
||||
zoomAt(zoom.s * (d / gesture.d), ...local(mx, my))
|
||||
Object.assign(gesture, { d, mx, my })
|
||||
} else if (gesture?.type === 'pan') {
|
||||
zoom.x += e.clientX - gesture.x
|
||||
zoom.y += e.clientY - gesture.y
|
||||
gesture.x = e.clientX
|
||||
gesture.y = e.clientY
|
||||
clampPan()
|
||||
applyZoom()
|
||||
}
|
||||
})
|
||||
const endPointer = e => {
|
||||
if (!pts.delete(e.pointerId)) return
|
||||
if (pts.size === 1 && zoom.s > 1) {
|
||||
const [p] = [...pts.values()]
|
||||
gesture = { type: 'pan', x: p.x, y: p.y }
|
||||
} else if (pts.size === 0) {
|
||||
if (e.type === 'pointerup' && tap && !tap.moved && Date.now() - tap.t < 350) onTap(e.clientX, e.clientY)
|
||||
gesture = null
|
||||
tap = null
|
||||
}
|
||||
}
|
||||
box.addEventListener('pointerup', endPointer)
|
||||
box.addEventListener('pointercancel', endPointer)
|
||||
box.addEventListener('wheel', e => {
|
||||
if (!isPaged() || atEnd || !e.ctrlKey) return // Ctrl+rueda o pellizco del trackpad
|
||||
e.preventDefault()
|
||||
zoomAt(zoom.s * Math.exp(-e.deltaY / 200), ...local(e.clientX, e.clientY))
|
||||
}, { passive: false })
|
||||
// En modo vertical el zoom es el nativo del navegador; un toque muestra/oculta las barras.
|
||||
box.addEventListener('click', e => {
|
||||
if (isPaged() || e.target.closest('a,button')) return
|
||||
reader.classList.toggle('hide-ui')
|
||||
})
|
||||
$('#zoomreset').addEventListener('click', resetZoom)
|
||||
$('#ropts').addEventListener('click', () => { const p = $('.rpanel', reader); p.hidden = !p.hidden; $('#ropts').classList.toggle('on', !p.hidden) })
|
||||
|
||||
const saveGlobal = () => store.set('readerSettings', { saver: settings.saver, filter: settings.filter, brightness: settings.brightness })
|
||||
const savePrefs = () => {
|
||||
api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } })
|
||||
.then(() => toast('Guardado para este manga')).catch(err => toast(err.message))
|
||||
}
|
||||
$('#mode').addEventListener('change', e => { settings.mode = e.target.value; savePrefs(); render() })
|
||||
$('#fit').addEventListener('change', e => { settings.fit = e.target.value; savePrefs(); render() })
|
||||
$('#dir').addEventListener('change', e => { settings.dir = e.target.value; box.dataset.dir = settings.dir; savePrefs() })
|
||||
$('#filter').addEventListener('change', e => { settings.filter = e.target.value; saveGlobal(); applyFilter() })
|
||||
$('#bright').addEventListener('input', e => { settings.brightness = Number(e.target.value); saveGlobal(); applyFilter() })
|
||||
$('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; saveGlobal(); applyFilter() })
|
||||
$('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); router() })
|
||||
$('#chap').addEventListener('change', e => { location.hash = readUrl(mangaId, e.target.value) })
|
||||
slider.addEventListener('input', () => goTo(Number(slider.value) - 1))
|
||||
|
||||
// Toques: izquierda/derecha cambian de página (según dirección), centro muestra/oculta barras.
|
||||
box.addEventListener('click', e => {
|
||||
if (e.target.closest('a,button')) return
|
||||
if (settings.mode !== 'paged') { reader.classList.toggle('hide-ui'); return }
|
||||
const x = e.clientX / window.innerWidth
|
||||
if (x > 0.35 && x < 0.65) { reader.classList.toggle('hide-ui'); return }
|
||||
const forward = (x >= 0.65) === (settings.dir === 'ltr')
|
||||
if (atEnd && !forward) { atEnd = false; box.innerHTML = ''; goTo(total - 1); return }
|
||||
if (!atEnd) goTo(page + (forward ? 1 : -1))
|
||||
})
|
||||
const onKey = e => {
|
||||
if (settings.mode !== 'paged' || e.target.matches('input,select')) return
|
||||
if (!isPaged() || e.target.matches('input,select')) return
|
||||
const fwdKey = settings.dir === 'ltr' ? 'ArrowRight' : 'ArrowLeft'
|
||||
const backKey = settings.dir === 'ltr' ? 'ArrowLeft' : 'ArrowRight'
|
||||
if (e.key === fwdKey || e.key === ' ') { e.preventDefault(); if (!atEnd) goTo(page + 1) }
|
||||
else if (e.key === backKey) { if (atEnd) { atEnd = false; box.innerHTML = ''; goTo(total - 1) } else goTo(page - 1) }
|
||||
const [cx, cy] = [box.clientWidth / 2, Math.min(box.clientHeight, window.innerHeight) / 2]
|
||||
if (e.key === fwdKey || e.key === ' ') { e.preventDefault(); forward() }
|
||||
else if (e.key === backKey) backward()
|
||||
else if (e.key === '+' || e.key === '=') zoomAt(zoom.s * 1.25, cx, cy)
|
||||
else if (e.key === '-') zoomAt(zoom.s / 1.25, cx, cy)
|
||||
else if (e.key === '0') resetZoom()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
let lastY = 0
|
||||
@@ -519,15 +774,28 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
if (Math.abs(y - lastY) > 30) { reader.classList.toggle('hide-ui', y > lastY); lastY = y }
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
// Al girar la tablet o redimensionar, rehacer la agrupación de doble página.
|
||||
let resizeTimer = null
|
||||
const onResize = () => {
|
||||
clearTimeout(resizeTimer)
|
||||
resizeTimer = setTimeout(() => {
|
||||
if (!isPaged() || atEnd) return
|
||||
box.classList.toggle('double', isDouble())
|
||||
goTo(lastShown(), false, true)
|
||||
}, 200)
|
||||
}
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
render()
|
||||
cleanup = () => {
|
||||
if (saveTimer) saveNow()
|
||||
observer?.disconnect()
|
||||
clearTimeout(uiTimer)
|
||||
document.removeEventListener('visibilitychange', onVis)
|
||||
window.removeEventListener('pagehide', beacon)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
window.removeEventListener('resize', onResize)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,6 +855,114 @@ async function viewDownloads() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Estadísticas ----------
|
||||
const fmtNum = n => n.toLocaleString('es')
|
||||
const fmtDay = d => new Date(`${d}T12:00:00`).toLocaleDateString('es', { day: 'numeric', month: 'short' })
|
||||
const niceMax = v => { const p = 10 ** Math.floor(Math.log10(v)); return [1, 2, 2.5, 5, 10].map(m => m * p).find(m => m >= v) }
|
||||
|
||||
// Barras verticales de una sola serie; cada columna tiene una zona de hover más grande que la barra.
|
||||
function barChart(data, { value, tip, label }, W = 560) {
|
||||
const H = 190, L = 34, B = 24, T = 8
|
||||
const max = niceMax(Math.max(1, ...data.map(value)))
|
||||
const cw = (W - L) / data.length
|
||||
const bw = Math.max(4, Math.min(28, cw - 6))
|
||||
const y = v => T + (H - T - B) * (1 - v / max)
|
||||
const ticks = Number.isInteger(max / 2) ? [0, max / 2, max] : [0, max]
|
||||
const grid = ticks.map(v => `<line x1="${L}" x2="${W}" y1="${y(v)}" y2="${y(v)}" class="grid"/><text x="${L - 6}" y="${y(v) + 4}" text-anchor="end" class="axis">${fmtNum(Math.round(v))}</text>`).join('')
|
||||
const bars = data.map((d, i) => {
|
||||
const v = value(d)
|
||||
const x = L + i * cw + (cw - bw) / 2
|
||||
const top = y(v), base = y(0), r = Math.min(4, bw / 2, base - top)
|
||||
const bar = v > 0 ? `<path class="mark" d="M${x},${base}V${top + r}Q${x},${top} ${x + r},${top}H${x + bw - r}Q${x + bw},${top} ${x + bw},${top + r}V${base}Z"/>` : ''
|
||||
const every = W < 420 ? 3 : 2
|
||||
const lab = (data.length - 1 - i) % every ? '' : `<text x="${i === data.length - 1 ? x + bw : x + bw / 2}" y="${H - 6}" text-anchor="${i === data.length - 1 ? 'end' : 'middle'}" class="axis">${label(d)}</text>`
|
||||
return `<g class="col" data-tip="${esc(tip(d))}"><rect x="${L + i * cw}" y="${T}" width="${cw}" height="${H - T - B}" class="hit"/>${bar}${lab}</g>`
|
||||
}).join('')
|
||||
return `<svg viewBox="0 0 ${W} ${H}" class="chart" role="img">${grid}${bars}</svg>`
|
||||
}
|
||||
|
||||
async function viewStats() {
|
||||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||||
const st = await api('/api/stats')
|
||||
const t = st.totals
|
||||
if (!t.pages) {
|
||||
app.innerHTML = '<h1>Estadísticas</h1><p class="muted">Aún no hay lecturas registradas. Lee algún capítulo y vuelve por aquí.</p>'
|
||||
return
|
||||
}
|
||||
const maxDay = Math.max(1, ...st.calendar.map(d => d.pages))
|
||||
const level = p => (p === 0 ? 0 : Math.min(5, Math.ceil((p / maxDay) * 5)))
|
||||
const weekLabel = w => fmtDay(w.start)
|
||||
const tile = (value, label, sub = '') => `<div class="tile"><div class="tv">${value}</div><div class="tl">${label}</div>${sub ? `<div class="ts">${sub}</div>` : ''}</div>`
|
||||
// Calendario: columnas = semanas (lunes arriba).
|
||||
const firstDow = (new Date(`${st.calendar[0].day}T12:00:00`).getDay() + 6) % 7
|
||||
const cells = [...Array(firstDow).fill('<i class="cell empty"></i>'), ...st.calendar.map(d =>
|
||||
`<i class="cell l${level(d.pages)}" data-tip="${esc(`${fmtDay(d.day)}: ${d.pages ? `${fmtNum(d.pages)} págs.` : 'sin lectura'}${d.chapters ? ` · ${d.chapters} caps.` : ''}`)}"></i>`)].join('')
|
||||
const gmax = Math.max(1, ...st.genres.map(g => g.pages))
|
||||
|
||||
app.innerHTML = `<h1>${icon('chart')} Estadísticas</h1>
|
||||
<div class="tiles">
|
||||
${tile(`${st.streak.current} ${st.streak.current === 1 ? 'día' : 'días'}`, 'Racha actual', st.streak.current && !st.streak.readToday ? 'Lee hoy para mantenerla' : st.streak.readToday ? 'Hoy ya leíste ✓' : '')}
|
||||
${tile(`${st.streak.best} ${st.streak.best === 1 ? 'día' : 'días'}`, 'Mejor racha')}
|
||||
${tile(fmtNum(t.thisWeekChapters), 'Capítulos esta semana', `${fmtNum(t.thisWeekPages)} páginas`)}
|
||||
${tile(fmtNum(t.chapters), 'Capítulos terminados')}
|
||||
${tile(fmtNum(t.pages), 'Páginas leídas')}
|
||||
${tile(fmtNum(t.mangas), 'Mangas', `${t.activeDays} ${t.activeDays === 1 ? 'día' : 'días'} con lectura`)}
|
||||
</div>
|
||||
<div class="charts">
|
||||
<section class="panel"><h3>Capítulos terminados por semana</h3><div class="chart-slot" data-chart="chapters"></div></section>
|
||||
<section class="panel"><h3>Páginas leídas por semana</h3><div class="chart-slot" data-chart="pages"></div></section>
|
||||
</div>
|
||||
<section class="panel"><h3>Actividad (últimas 18 semanas)</h3>
|
||||
<div class="heat-wrap"><div class="heat">${cells}</div></div>
|
||||
<div class="heat-legend muted">Menos <i class="cell l0"></i><i class="cell l1"></i><i class="cell l2"></i><i class="cell l3"></i><i class="cell l4"></i><i class="cell l5"></i> Más páginas</div>
|
||||
</section>
|
||||
<div class="charts">
|
||||
<section class="panel"><h3>Géneros favoritos</h3>
|
||||
${st.genres.length ? `<div class="hbars">${st.genres.map(g => `<div class="hbar" data-tip="${esc(`${g.name}: ${fmtNum(g.pages)} págs. (${g.pct}% de lo leído)`)}">
|
||||
<span class="hl">${esc(g.name)}</span><span class="ht"><i style="width:${(g.pages / gmax) * 100}%"></i></span><span class="hv">${g.pct}%</span></div>`).join('')}</div>
|
||||
<p class="muted" style="font-size:.8rem">Porcentaje de páginas leídas de mangas con ese género (un manga cuenta en todos sus géneros).</p>`
|
||||
: '<p class="muted">Abre la ficha de los mangas que lees para registrar sus géneros.</p>'}
|
||||
</section>
|
||||
<section class="panel"><h3>Lo más leído</h3>
|
||||
<div class="toplist">${st.topMangas.map((m, i) => `<a class="cont" href="#/manga/${m.id}"><span class="rank">${i + 1}</span>
|
||||
<img src="${esc(m.cover || '/icon.svg')}" alt="" loading="lazy">
|
||||
<div class="info"><b>${esc(m.title)}</b><div class="muted">${fmtNum(m.pages)} págs. · ${m.chapters} caps.</div></div></a>`).join('')}</div>
|
||||
</section>
|
||||
</div>
|
||||
<details class="panel"><summary>Ver datos por semana</summary>
|
||||
<table class="data"><thead><tr><th>Semana del</th><th>Capítulos</th><th>Páginas</th></tr></thead>
|
||||
<tbody>${[...st.weeks].reverse().map(w => `<tr><td>${fmtDay(w.start)}</td><td>${w.chapters}</td><td>${fmtNum(w.pages)}</td></tr>`).join('')}</tbody></table>
|
||||
</details>
|
||||
<div id="tip" hidden></div>`
|
||||
|
||||
// Las gráficas se dibujan al ancho real del panel para que el texto no encoja en el móvil.
|
||||
const charts = {
|
||||
chapters: { value: w => w.chapters, label: weekLabel, tip: w => `Semana del ${fmtDay(w.start)}: ${w.chapters} caps.` },
|
||||
pages: { value: w => w.pages, label: weekLabel, tip: w => `Semana del ${fmtDay(w.start)}: ${fmtNum(w.pages)} págs.` },
|
||||
}
|
||||
const drawCharts = () => app.querySelectorAll('.chart-slot').forEach(el => { el.innerHTML = barChart(st.weeks, charts[el.dataset.chart], Math.max(260, el.clientWidth)) })
|
||||
drawCharts()
|
||||
let rt = null
|
||||
const onResize = () => { clearTimeout(rt); rt = setTimeout(drawCharts, 150) }
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
// Tooltip compartido para barras, celdas y géneros.
|
||||
const tipEl = $('#tip')
|
||||
const move = e => {
|
||||
const target = e.target.closest('[data-tip]')
|
||||
if (!target) { tipEl.hidden = true; return }
|
||||
tipEl.textContent = target.dataset.tip
|
||||
tipEl.hidden = false
|
||||
const r = tipEl.getBoundingClientRect()
|
||||
tipEl.style.left = `${Math.min(window.innerWidth - r.width - 8, Math.max(8, e.clientX - r.width / 2))}px`
|
||||
tipEl.style.top = `${Math.max(8, e.clientY - r.height - 12)}px`
|
||||
}
|
||||
app.addEventListener('pointermove', move)
|
||||
app.addEventListener('pointerdown', move)
|
||||
app.addEventListener('pointerleave', () => (tipEl.hidden = true))
|
||||
cleanup = () => { app.removeEventListener('pointermove', move); app.removeEventListener('pointerdown', move); window.removeEventListener('resize', onResize) }
|
||||
}
|
||||
|
||||
// ---------- Ajustes ----------
|
||||
function viewSettings() {
|
||||
app.innerHTML = `<h1>Ajustes</h1>
|
||||
@@ -600,7 +976,42 @@ function viewSettings() {
|
||||
<input type="password" name="old" placeholder="Contraseña actual" autocomplete="current-password" required>
|
||||
<input type="password" name="new" placeholder="Nueva contraseña" autocomplete="new-password" required minlength="6">
|
||||
<button class="btn primary">${icon('key')} Guardar</button></form></section>
|
||||
<section><b>${icon('bell')} Avisos de capítulos nuevos</b>
|
||||
<p class="muted" style="margin:0;font-size:.88rem">El servidor revisa cada pocas horas los mangas que sigues (los que empiezas a leer se siguen solos).
|
||||
Si un manga tiene copia local, los capítulos nuevos se descargan automáticamente. Además puedes recibir una notificación:</p>
|
||||
<form id="notify" style="display:grid;gap:8px">
|
||||
<b style="font-size:.9rem">ntfy</b>
|
||||
<input type="text" name="ntfyUrl" placeholder="Servidor, p. ej. http://192.168.1.141:4546" autocomplete="off">
|
||||
<input type="text" name="ntfyTopic" placeholder="Tema, p. ej. mangas" autocomplete="off">
|
||||
<input type="password" name="ntfyToken" placeholder="Token (tk_…) o usuario:contraseña" autocomplete="new-password">
|
||||
<b style="font-size:.9rem">Telegram <span class="muted" style="font-weight:normal">(opcional)</span></b>
|
||||
<input type="password" name="tgToken" placeholder="Token del bot (de @BotFather)" autocomplete="new-password">
|
||||
<input type="text" name="tgChat" placeholder="Chat ID" autocomplete="off">
|
||||
<p class="muted" style="margin:0;font-size:.8rem">Los tokens guardados no se muestran: deja el campo vacío para conservarlo o escribe «-» para borrarlo.</p>
|
||||
<div class="row"><button class="btn primary">${icon('check')} Guardar</button><button type="button" class="btn" id="ntest">${icon('send')} Enviar prueba</button></div>
|
||||
</form></section>
|
||||
</div>`
|
||||
api('/api/notify').then(n => {
|
||||
const f = $('#notify')
|
||||
if (!f) return
|
||||
f.ntfyUrl.value = n.ntfyUrl
|
||||
f.ntfyTopic.value = n.ntfyTopic
|
||||
f.tgChat.value = n.tgChat
|
||||
if (n.hasNtfyToken) f.ntfyToken.placeholder = 'Token guardado ✓ (vacío = conservar, «-» = borrar)'
|
||||
if (n.hasTgToken) f.tgToken.placeholder = 'Token guardado ✓ (vacío = conservar, «-» = borrar)'
|
||||
}).catch(() => {})
|
||||
$('#notify').addEventListener('submit', async e => {
|
||||
e.preventDefault()
|
||||
const f = e.target
|
||||
try {
|
||||
await api('/api/notify', { method: 'POST', body: Object.fromEntries(['ntfyUrl', 'ntfyTopic', 'ntfyToken', 'tgToken', 'tgChat'].map(k => [k, f[k].value])) })
|
||||
toast('Avisos guardados')
|
||||
viewSettings()
|
||||
} catch (err) { toast(err.message) }
|
||||
})
|
||||
$('#ntest').addEventListener('click', async () => {
|
||||
try { await api('/api/notify/test', { method: 'POST' }); toast('Notificación de prueba enviada') } catch (err) { toast(err.message) }
|
||||
})
|
||||
$('#logout').addEventListener('click', async () => { await api('/api/logout', { method: 'POST' }); me = null; location.hash = '#/login'; router() })
|
||||
$('#lang').addEventListener('change', async e => { await api('/api/settings', { method: 'POST', body: { lang: e.target.value } }); me.lang = e.target.value; toast('Idioma guardado') })
|
||||
$('#pw').addEventListener('submit', async e => {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<a href="#/" data-icon="home">Inicio</a>
|
||||
<a href="#/history" data-icon="history">Historial</a>
|
||||
<a href="#/downloads" data-icon="download">Descargas</a>
|
||||
<a href="#/stats" data-icon="chart">Estadísticas</a>
|
||||
<a href="#/settings" data-icon="settings">Ajustes</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
[hidden] { display: none !important; }
|
||||
:root {
|
||||
--bg: #14121a;
|
||||
--surface: #1e1b26;
|
||||
@@ -141,3 +142,64 @@ a.tag:hover, .tag.on { background: var(--accent); color: var(--accent-ink); }
|
||||
#topbar nav a .ic { width: 1.35em; height: 1.35em; }
|
||||
.search .btn .lbl { display: none; }
|
||||
}
|
||||
|
||||
/* Lector: escenario de páginas, doble página y zoom */
|
||||
.paged { overflow: hidden; touch-action: none; }
|
||||
.paged.fit-width:not(.zoomed) { overflow: visible; touch-action: pan-y; }
|
||||
.paged .stage { display: flex; justify-content: center; align-items: center; width: 100%; height: 100%; transform-origin: 0 0; will-change: transform; }
|
||||
.paged.fit-width .stage { height: auto; align-items: flex-start; }
|
||||
.paged[data-dir="rtl"] .stage { flex-direction: row-reverse; }
|
||||
.paged .stage.two img { max-width: 50%; }
|
||||
.paged.fit-width .stage.two img { width: 50%; max-width: 700px; }
|
||||
.paged img { -webkit-user-drag: none; user-select: none; }
|
||||
.paged.zoomed { cursor: grab; }
|
||||
|
||||
/* Insignias y botón Seguir */
|
||||
.badge { display: inline-block; font-size: .72rem; font-weight: 700; padding: 1px 7px; border-radius: 999px; background: var(--accent); color: var(--accent-ink); vertical-align: middle; }
|
||||
.btn.on { border-color: var(--accent); color: var(--accent); }
|
||||
h2 .ic { color: var(--accent); }
|
||||
|
||||
/* Estadísticas */
|
||||
:root { --viz-1: #5b3f8c; --viz-2: #7d4fc0; --viz-3: #a067e3; --viz-4: #c993ff; --viz-5: #ecd6ff; }
|
||||
.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin: 16px 0; }
|
||||
.tile { background: var(--surface); border-radius: var(--radius); padding: 14px 16px; }
|
||||
.tile .tv { font-size: 1.6rem; font-weight: 700; line-height: 1.2; }
|
||||
.tile .tl { color: var(--muted); font-size: .88rem; }
|
||||
.tile .ts { color: var(--muted); font-size: .78rem; margin-top: 4px; }
|
||||
.charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; }
|
||||
.panel { background: var(--surface); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 12px; min-width: 0; }
|
||||
.panel h3 { margin: 0 0 10px; font-size: .95rem; }
|
||||
.chart { width: 100%; height: auto; display: block; }
|
||||
.chart .grid { stroke: var(--surface-2); stroke-width: 1; }
|
||||
.chart .axis { fill: var(--muted); font-size: 11px; }
|
||||
.chart .mark { fill: var(--accent); }
|
||||
.chart .hit { fill: transparent; }
|
||||
.chart .col:hover .hit { fill: rgba(255, 255, 255, .04); }
|
||||
.heat-wrap { overflow-x: auto; }
|
||||
.heat { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 14px); grid-auto-columns: 14px; gap: 3px; }
|
||||
.cell { display: inline-block; width: 14px; height: 14px; border-radius: 3px; background: var(--surface-2); }
|
||||
.cell.empty { background: transparent; }
|
||||
.cell.l1 { background: var(--viz-1); } .cell.l2 { background: var(--viz-2); } .cell.l3 { background: var(--viz-3); }
|
||||
.cell.l4 { background: var(--viz-4); } .cell.l5 { background: var(--viz-5); }
|
||||
.heat-legend { display: flex; align-items: center; gap: 3px; font-size: .8rem; margin-top: 8px; }
|
||||
.hbars { display: grid; gap: 8px; }
|
||||
.hbar { display: grid; grid-template-columns: minmax(90px, 140px) 1fr 3em; gap: 8px; align-items: center; font-size: .88rem; }
|
||||
.hbar .hl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hbar .ht { height: 10px; background: var(--surface-2); border-radius: 0 4px 4px 0; }
|
||||
.hbar .ht i { display: block; height: 100%; background: var(--accent); border-radius: 0 4px 4px 0; }
|
||||
.hbar .hv { text-align: right; color: var(--muted); }
|
||||
.toplist { display: grid; gap: 8px; }
|
||||
.toplist .cont { padding: 6px 8px; background: var(--bg); }
|
||||
.toplist .cont img { width: 36px; height: 54px; }
|
||||
.toplist .rank { width: 1.4em; text-align: center; color: var(--muted); font-weight: 700; }
|
||||
details.panel summary { cursor: pointer; color: var(--muted); }
|
||||
table.data { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: .88rem; }
|
||||
table.data th, table.data td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--surface-2); }
|
||||
table.data td:not(:first-child), table.data th:not(:first-child) { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
#tip { position: fixed; z-index: 60; pointer-events: none; background: #000; color: var(--text); padding: 6px 10px; border-radius: 8px; font-size: .82rem; box-shadow: 0 4px 14px rgba(0,0,0,.5); max-width: 280px; }
|
||||
.rpanel { position: fixed; top: 52px; right: 8px; left: auto; z-index: 31; display: grid; gap: 8px; padding: 12px; width: min(320px, calc(100vw - 16px));
|
||||
background: rgba(10, 9, 14, .96); border: 1px solid var(--surface-2); border-radius: var(--radius); box-shadow: 0 8px 24px rgba(0,0,0,.5); }
|
||||
.rpanel select, .rpanel label { width: 100%; }
|
||||
.rpanel p { margin: 0; font-size: .78rem; }
|
||||
.reader.hide-ui .rpanel { display: none; }
|
||||
.chart .axis { font-size: 12px; }
|
||||
|
||||
+10
-1
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user