diff --git a/README.md b/README.md index 381be82..e449770 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,15 @@ Lector web de manga autoalojado, con las dos fuentes de `/zdata/media/mangas//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 diff --git a/app/features.js b/app/features.js new file mode 100644 index 0000000..79addcb --- /dev/null +++ b/app/features.js @@ -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 } +} diff --git a/app/public/app.js b/app/public/app.js index f9027b3..fa156f8 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -26,6 +26,12 @@ const ICONS = { grid: '', globe: '', key: '', + bell: '', + bellOff: '', + chart: '', + zoomOut: '', + send: '', + sparkles: '', } const icon = name => `` // 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) {
${timeAgo(h.updated_at)}
` } +// 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 `
+

${icon('sparkles')} Novedades ${fresh.length ? `${fresh.reduce((n, f) => n + f.newCount, 0)}` : ''}

+ Sigues ${fol.follows.length} manga${fol.follows.length === 1 ? '' : 's'} · ${status} +
+ ${fresh.length ? `` + : '

No hay capítulos nuevos en los mangas que sigues.

'}` +} +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 = `

Continuar leyendo

${hist.slice(0, 12).map(continueCard).join('')}
` - }).catch(() => {}) + if (!q && !tag && !showCats) { + Promise.all([api('/api/history'), api('/api/follows')]).then(([hist, fol]) => { + if (!$('#cont')) return + $('#cont').innerHTML = `
${newsSection(fol)}
` + (hist.length ? `

Continuar leyendo

${hist.slice(0, 12).map(continueCard).join('')}
` : '') + bindNews(fol) + }).catch(() => {}) + } const res = $('#results') if (showCats) { @@ -253,8 +296,10 @@ async function viewManga(id, langOverride) { if (cleanup) { cleanup(); cleanup = null } app.innerHTML = '
Cargando…
' 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) {
${manga.tags.map(t => (t.id ? `${esc(t.name)}` : `${esc(t.name ?? t)}`)).join('')}

${esc(manga.description)}

${mainBtn} + ${progress || reads.length ? `` : ''} ${isLc ? '' : ` ${icon('search')} Buscar en LeerCapitulo`} @@ -287,7 +333,7 @@ async function viewManga(id, langOverride) {

-

Capítulos (${chapters.length}) +

Capítulos (${chapters.length})${newSet.size ? ` ${newSet.size} ${newSet.size === 1 ? 'nuevo' : 'nuevos'}` : ''} ${chapters.length > 1 ? `` : ''}

${chapters.length ? '
    ' : isLc ? '

    Este manga no tiene capítulos.

    ' : `

    No hay capítulos legibles en ${esc(LANGS[lang] || lang)} (MangaDex retira algunos por licencia). Prueba a buscarlo en LeerCapitulo. @@ -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) ? `${icon('download')} ` : '') + (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group)) + const meta = (newSet.has(String(c.num ?? c.id)) ? 'NUEVO ' : '') + (dlSet.has(c.id) ? `${icon('download')} ` : '') + (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group)) return `

  • ${esc(chapLabel(c))}${meta}
  • ` }).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 = '
    Cargando capítulo…
    ' 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) { ${icon('back')}
    ${esc(manga.title)}
    - - - + + +
    ${prev ? `${icon('prev')} Cap. ant.` : ''} + ${next ? `Cap. sig. ${icon('next')}` : ''}
    ` @@ -426,39 +506,107 @@ async function viewReader(mangaId, chapterId, startPage) { ${next ? `${icon('next')} Siguiente: ${esc(chapLabel(next))}` : '

    No hay más capítulos disponibles.

    '} ${icon('back')} Volver a la ficha` - 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 = '
    '; stage = $('.stage', box) } + stage.classList.toggle('two', shown.length === 2) + stage.innerHTML = shown.map(i => ``).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) => ``).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 => `${fmtNum(Math.round(v))}`).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 ? `` : '' + const every = W < 420 ? 3 : 2 + const lab = (data.length - 1 - i) % every ? '' : `${label(d)}` + return `${bar}${lab}` + }).join('') + return `${grid}${bars}` +} + +async function viewStats() { + app.innerHTML = '
    Cargando…
    ' + const st = await api('/api/stats') + const t = st.totals + if (!t.pages) { + app.innerHTML = '

    Estadísticas

    Aún no hay lecturas registradas. Lee algún capítulo y vuelve por aquí.

    ' + 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 = '') => `
    ${value}
    ${label}
    ${sub ? `
    ${sub}
    ` : ''}
    ` + // Calendario: columnas = semanas (lunes arriba). + const firstDow = (new Date(`${st.calendar[0].day}T12:00:00`).getDay() + 6) % 7 + const cells = [...Array(firstDow).fill(''), ...st.calendar.map(d => + ``)].join('') + const gmax = Math.max(1, ...st.genres.map(g => g.pages)) + + app.innerHTML = `

    ${icon('chart')} Estadísticas

    +
    + ${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`)} +
    +
    +

    Capítulos terminados por semana

    +

    Páginas leídas por semana

    +
    +

    Actividad (últimas 18 semanas)

    +
    ${cells}
    +
    Menos Más páginas
    +
    +
    +

    Géneros favoritos

    + ${st.genres.length ? `
    ${st.genres.map(g => `
    + ${esc(g.name)}${g.pct}%
    `).join('')}
    +

    Porcentaje de páginas leídas de mangas con ese género (un manga cuenta en todos sus géneros).

    ` + : '

    Abre la ficha de los mangas que lees para registrar sus géneros.

    '} +
    +

    Lo más leído

    + +
    +
    +
    Ver datos por semana + + ${[...st.weeks].reverse().map(w => ``).join('')}
    Semana delCapítulosPáginas
    ${fmtDay(w.start)}${w.chapters}${fmtNum(w.pages)}
    +
    + ` + + // 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 = `

    Ajustes

    @@ -600,7 +976,42 @@ function viewSettings() { +
    ${icon('bell')} Avisos de capítulos nuevos +

    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:

    +
    + ntfy + + + + Telegram (opcional) + + +

    Los tokens guardados no se muestran: deja el campo vacío para conservarlo o escribe «-» para borrarlo.

    +
    +
    ` + 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 => { diff --git a/app/public/index.html b/app/public/index.html index 1184f4d..89502e7 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -15,6 +15,7 @@ Inicio Historial Descargas + Estadísticas Ajustes diff --git a/app/public/style.css b/app/public/style.css index 00671ab..0517efd 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -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; } diff --git a/app/server.js b/app/server.js index cd6624f..986f838 100644 --- a/app/server.js +++ b/app/server.js @@ -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(/]*>([^<]+)/.exec(html)?.[1]) || slug, description: decode(sinopsis?.replace(//g, '\n')), status: fact('Estado'), + type: fact('Tipo') || null, year: null, tags: [...html.matchAll(/class="badge [^"]*" href="\/manga\/\?(genre|theme)=([a-z0-9-]+)">([^<]+) ({ 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') diff --git a/docker-compose.yml b/docker-compose.yml index 7787df0..caa6d2c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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}