diff --git a/.env.example b/.env.example index 4f5caff..2db61a7 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,14 @@ RENDER_GID=992 TZ=America/El_Salvador CHECK_INTERVAL_HOURS=3 + +# --- Exponer a internet --- +# Baneo de IP: tras 6 intentos fallidos (contraseña o registro cerrado) en 60 min, 24 h bloqueada. +# La red local, loopback y Tailscale (100.64.0.0/10) nunca se bloquean. BAN_IGNORE: más IPs, separadas por comas. +BAN_MAX_FAILS=6 +BAN_WINDOW_MIN=60 +BAN_HOURS=24 +# BAN_IGNORE=203.0.113.7 +# Si hay un proxy inverso delante (recomendado, con HTTPS): cabecera con la IP real del cliente. +# Caddy/Nginx: x-forwarded-for · Cloudflare Tunnel: cf-connecting-ip. Sin proxy, déjalo vacío. +# REAL_IP_HEADER=x-forwarded-for diff --git a/README.md b/README.md index 9013ca5..5f1c5f3 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,17 @@ Lector web de manga autoalojado, con las dos fuentes de (capítulo y página en curso, siguientes, tiempo restante, errores con "Reintentar", "Detener"), recortes y portadas pendientes, y debajo la biblioteca descargada con su tamaño; todo en tarjetas verticales con portada, como el historial. +- **Librería** (antes "Historial"): pestañas Leyendo, Siguiendo, Terminados, En el dispositivo y + listas propias (Favoritos, Pendientes, Abandonados y las que crees). Desde la ficha, el botón + "Listas" añade o quita el manga de cada lista. +- **Recomendado para ti** (inicio): populares de MangaDex con capítulos en tu idioma dentro de tus 3 + géneros más leídos, sin lo que ya tienes en la librería. +- **Descarga anticipada** (Ajustes): al pasar la mitad de un capítulo, el servidor baja los N + siguientes (por defecto 2) a la copia local y, si se marca, los reescala a HD. +- **App instalable y lectura sin conexión**: manifest + service worker. En la ficha, "En este + dispositivo" guarda capítulos (páginas incluidas) en el navegador para leerlos sin internet; el + progreso leído sin red se envía al volver. **Requiere HTTPS** (o `localhost`): por `http://IP:puerto` + el navegador no permite service workers. Por ejemplo `tailscale serve` o un proxy con certificado. - **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`). @@ -59,6 +70,23 @@ Sin `.env` arranca igual: puerto 3352 en todas las interfaces y descargas en `./ El primer usuario puede registrarse siempre; después el registro queda cerrado salvo `ALLOW_SIGNUP=true` en `.env`. +## Exponerlo a internet + +- **Pon HTTPS delante** (Caddy, Nginx, Cloudflare Tunnel o `tailscale funnel`): sin HTTPS la contraseña + y la cookie viajan en claro. Con HTTPS la cookie lleva `Secure` y se envía HSTS. +- Con un proxy inverso, define `REAL_IP_HEADER` en `.env` (`x-forwarded-for` en Caddy/Nginx, + `cf-connecting-ip` en Cloudflare) para que el baneo vea la IP real. Solo se cree esa cabecera si la + conexión llega desde una IP privada (el proxy), así que desde internet no se puede falsificar. +- **Baneo de IP**: 6 intentos fallidos (contraseña incorrecta, registro con el registro cerrado, + contraseña actual incorrecta) en 60 min bloquean la IP 24 h para toda la web. Ajustable con + `BAN_MAX_FAILS`, `BAN_WINDOW_MIN`, `BAN_HOURS`; red local, loopback y Tailscale nunca se bloquean. + En Ajustes se ven las IPs bloqueadas y se pueden desbloquear. +- Registro cerrado por defecto (`ALLOW_SIGNUP=false`); contraseñas de 8+ caracteres; cambiar la + contraseña cierra las demás sesiones. +- Cabeceras: CSP estricta (sin scripts en línea), `X-Frame-Options: DENY`, `nosniff`, `Referrer-Policy`. +- El proxy de imágenes solo descarga URLs firmadas por el servidor y nunca hacia IPs privadas + (comprobado también en cada redirección). Los errores internos no se muestran al cliente. + ## Qué guardar / mover a otro servidor | Qué | Dónde | Contenido | diff --git a/app/features.js b/app/features.js index 6e6ba07..91adefa 100644 --- a/app/features.js +++ b/app/features.js @@ -32,7 +32,7 @@ const GENRE_ES = { // 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 => { +export 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() @@ -374,5 +374,5 @@ export function setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInf return false } - return { route, onProgress, mangaExtras, unfollow, checkFollows } + return { route, onProgress, mangaExtras, unfollow, checkFollows, stats } } diff --git a/app/library.js b/app/library.js new file mode 100644 index 0000000..f36561c --- /dev/null +++ b/app/library.js @@ -0,0 +1,166 @@ +// Librería: listas propias del usuario, recomendaciones según sus géneros y descarga anticipada +// de los capítulos siguientes a lo que se está leyendo. +const PREFETCH_DEFAULT = { n: 2, hd: false } +const DEFAULT_LISTS = ['Favoritos', 'Pendientes', 'Abandonados'] +const REC_TTL = 6 * 3600e3 +import { genreName } from './features.js' +const norm = s => String(s).trim().toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '') + +export function setupLibrary({ db, HttpError, json, readBody, MANGA_ID, fetchMangaFull, enqueueDownload, upscale, stats, searchManga, mdCategories }) { + db.exec(` + CREATE TABLE IF NOT EXISTS lists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS list_items ( + list_id INTEGER NOT NULL REFERENCES lists(id) ON DELETE CASCADE, + manga_id TEXT NOT NULL, + title TEXT NOT NULL, + cover TEXT, + added_at INTEGER NOT NULL, + PRIMARY KEY (list_id, manga_id) + ); + `) + if (!db.prepare('PRAGMA table_info(users)').all().some(c => c.name === 'prefetch')) db.exec('ALTER TABLE users ADD COLUMN prefetch TEXT') + + // ---------- Listas ---------- + function listsOf(userId) { + let rows = db.prepare('SELECT id, name FROM lists WHERE user_id = ? ORDER BY id').all(userId) + if (!rows.length && !db.prepare('SELECT 1 FROM kv WHERE k = ?').get(`lists_init_${userId}`)) { + const ins = db.prepare('INSERT INTO lists (user_id, name, created_at) VALUES (?, ?, ?)') + for (const n of DEFAULT_LISTS) ins.run(userId, n, Date.now()) + db.prepare('INSERT INTO kv (k, v) VALUES (?, ?)').run(`lists_init_${userId}`, '1') + rows = db.prepare('SELECT id, name FROM lists WHERE user_id = ? ORDER BY id').all(userId) + } + const count = db.prepare('SELECT COUNT(*) n FROM list_items WHERE list_id = ?') + return rows.map(l => ({ ...l, count: count.get(l.id).n })) + } + const ownList = (userId, id) => { + const l = db.prepare('SELECT * FROM lists WHERE id = ? AND user_id = ?').get(id, userId) + if (!l) throw new HttpError(404, 'Lista no encontrada') + return l + } + // Listas que contienen un manga (para la ficha). + const listsWith = (userId, mangaId) => db.prepare(`SELECT l.id FROM lists l JOIN list_items i ON i.list_id = l.id + WHERE l.user_id = ? AND i.manga_id = ?`).all(userId, mangaId).map(r => r.id) + + // ---------- Descarga anticipada ---------- + function prefetchCfg(userId) { + try { return { ...PREFETCH_DEFAULT, ...JSON.parse(db.prepare('SELECT prefetch FROM users WHERE id = ?').get(userId)?.prefetch || '{}') } } catch { return { ...PREFETCH_DEFAULT } } + } + const recent = new Map() // `${manga}|${chapter}` -> ts, para no repetir en cada guardado de progreso + // Al pasar la mitad de un capítulo, encola los N siguientes (y, si se pidió, su versión HD). + function onProgress(userId, b, page, total) { + const cfg = prefetchCfg(userId) + if (!cfg.n || !total || page < Math.floor(total / 2)) return + const key = `${b.mangaId}|${b.chapterId}|${cfg.n}|${cfg.hd}` + if (Date.now() - (recent.get(key) || 0) < 30 * 60e3) return + recent.set(key, Date.now()) + if (recent.size > 2000) recent.delete(recent.keys().next().value) + const lang = b.mangaId.startsWith('lc~') ? 'es' : b.lang + fetchMangaFull(b.mangaId, lang).then(({ manga, chapters }) => { + const i = chapters.findIndex(c => c.id === b.chapterId) + if (i < 0) return + const next = chapters.slice(i + 1, i + 1 + cfg.n).map(c => c.id) + if (!next.length) return + const have = new Set(db.prepare('SELECT chapter_id FROM downloaded_chapters WHERE manga_id = ?').all(b.mangaId).map(r => r.chapter_id)) + const missing = next.filter(id => !have.has(id)) + if (missing.length) { + enqueueDownload(b.mangaId, manga.title, lang, missing) + console.log(`Descarga anticipada: ${manga.title} → ${missing.length} cap(s).`) + } + if (cfg.hd && upscale.available()) upscale.request(next) + }).catch(e => console.warn('Descarga anticipada:', e.message)) + } + + // ---------- Recomendaciones ---------- + const recCache = new Map() // userId -> { at, data } + // Populares de MangaDex (por seguidores) en tus 3 géneros principales, con capítulos en tu idioma. + // LeerCapitulo no ordena sus géneros por popularidad, por eso la fuente es MangaDex. + async function recommendations(userId, refresh) { + const hit = recCache.get(userId) + if (hit && !refresh && Date.now() - hit.at < REC_TTL) return hit.data + const lang = db.prepare('SELECT lang FROM users WHERE id = ?').get(userId)?.lang || 'es-la' + const genres = stats(userId).genres.slice(0, 5) + const cats = (await mdCategories().catch(() => [])).filter(c => c.group === 'Géneros' || c.group === 'Temáticas') + const byName = new Map(cats.map(c => [norm(genreName(c.name) || c.name), c])) + const picked = genres.map(g => ({ g, cat: byName.get(norm(g.name)) })).filter(x => x.cat).slice(0, 3) + const mine = db.prepare(`SELECT manga_id, manga_title title FROM progress WHERE user_id = ? UNION SELECT manga_id, title FROM follows WHERE user_id = ? + UNION SELECT i.manga_id, i.title FROM list_items i JOIN lists l ON l.id = i.list_id WHERE l.user_id = ?`).all(userId, userId, userId) + const knownIds = new Set(mine.map(r => r.manga_id)) + const knownTitles = new Set(mine.map(r => norm(r.title).replace(/[^a-z0-9]/g, ''))) + // Puntuación: aparecer en varios de tus géneros y salir arriba en cada lista. + const score = new Map() + const page = refresh ? 1 + Math.floor(Math.random() * 3) : 1 // "otras recomendaciones": otra página + const results = await Promise.all(picked.map(({ cat }) => searchManga('', lang, 'popular', cat.id, page).catch(() => []))) + results.forEach((list, gi) => list.forEach((m, rank) => { + if (!m.id || knownIds.has(m.id) || knownTitles.has(norm(m.title).replace(/[^a-z0-9]/g, ''))) return + const s = score.get(m.id) || { m, pts: 0, why: [] } + s.pts += (picked.length - gi) * 2 + Math.max(0, 30 - rank) / 10 + 3 + s.why.push(picked[gi].g.name) + score.set(m.id, s) + })) + const items = [...score.values()].sort((a, b) => b.pts - a.pts).slice(0, 18) + .map(({ m, why }) => ({ ...m, sub: `Por ${why.slice(0, 2).join(' y ')}` })) + const data = { genres: picked.map(x => ({ name: x.g.name, id: x.cat.id, source: 'md' })), items } + recCache.set(userId, { at: Date.now(), data }) + return data + } + + // ---------- Rutas ---------- + async function route(p, method, q, req, res, user) { + let m + if (p === '/api/lists' && method === 'GET') return json(res, 200, listsOf(user.id)), true + if (p === '/api/lists' && method === 'POST') { + const b = await readBody(req) + const name = String(b.name || '').trim().slice(0, 60) + if (!name) throw new HttpError(400, 'Ponle un nombre a la lista') + if (db.prepare('SELECT COUNT(*) n FROM lists WHERE user_id = ?').get(user.id).n >= 50) throw new HttpError(400, 'Demasiadas listas') + const id = Number(db.prepare('INSERT INTO lists (user_id, name, created_at) VALUES (?, ?, ?)').run(user.id, name, Date.now()).lastInsertRowid) + return json(res, 200, { id, name, count: 0 }), true + } + if ((m = /^\/api\/lists\/(\d+)$/.exec(p))) { + const l = ownList(user.id, Number(m[1])) + if (method === 'GET') { + const items = db.prepare('SELECT manga_id, title, cover, added_at FROM list_items WHERE list_id = ? ORDER BY added_at DESC').all(l.id) + return json(res, 200, { id: l.id, name: l.name, items }), true + } + if (method === 'PATCH') { + const name = String((await readBody(req)).name || '').trim().slice(0, 60) + if (!name) throw new HttpError(400, 'Ponle un nombre a la lista') + db.prepare('UPDATE lists SET name = ? WHERE id = ?').run(name, l.id) + return json(res, 200, { ok: true }), true + } + if (method === 'DELETE') { db.prepare('DELETE FROM lists WHERE id = ?').run(l.id); return json(res, 200, { ok: true }), true } + } + if ((m = /^\/api\/lists\/(\d+)\/items$/.exec(p)) && method === 'POST') { + const l = ownList(user.id, Number(m[1])) + const b = await readBody(req) + if (!MANGA_ID.test(b.mangaId || '')) throw new HttpError(400, 'Manga inválido') + const cover = String(b.cover || '').slice(0, 1000) + db.prepare(`INSERT INTO list_items (list_id, manga_id, title, cover, added_at) VALUES (?, ?, ?, ?, ?) + ON CONFLICT (list_id, manga_id) DO UPDATE SET title = excluded.title, cover = excluded.cover`) + .run(l.id, b.mangaId, String(b.title || '').slice(0, 300) || b.mangaId, cover.startsWith('/api/') ? cover : null, Date.now()) + return json(res, 200, { lists: listsWith(user.id, b.mangaId) }), true + } + if ((m = /^\/api\/lists\/(\d+)\/items\/([^/]+)$/.exec(p)) && method === 'DELETE') { + const l = ownList(user.id, Number(m[1])) + const mangaId = decodeURIComponent(m[2]) + db.prepare('DELETE FROM list_items WHERE list_id = ? AND manga_id = ?').run(l.id, mangaId) + return json(res, 200, { lists: listsWith(user.id, mangaId) }), true + } + if (p === '/api/prefetch' && method === 'GET') return json(res, 200, prefetchCfg(user.id)), true + if (p === '/api/prefetch' && method === 'POST') { + const b = await readBody(req) + const n = Math.min(10, Math.max(0, Number(b.n) | 0)) + db.prepare('UPDATE users SET prefetch = ? WHERE id = ?').run(JSON.stringify({ n, hd: !!b.hd }), user.id) + return json(res, 200, prefetchCfg(user.id)), true + } + if (p === '/api/recommendations' && method === 'GET') return json(res, 200, await recommendations(user.id, q.get('refresh') === '1')), true + return false + } + + return { route, onProgress, listsOf, listsWith } +} diff --git a/app/public/app.js b/app/public/app.js index 0ea07cc..7bda588 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -38,6 +38,11 @@ const ICONS = { scissors: '', image: '', queue: '', + library: '', + bookmark: '', + phone: '', + plus: '', + pencil: '', sparkles: '', } const icon = name => `` @@ -89,6 +94,13 @@ let cleanup = null // ---------- Utilidades ---------- const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])) const $ = (sel, root = document) => root.querySelector(sel) +// Portadas que no cargan: icono de la app (la CSP no permite onerror="…" en el HTML). +document.addEventListener('error', e => { + const img = e.target + if (img.tagName !== 'IMG' || !img.hasAttribute('data-fallback') || img.dataset.failed) return + img.dataset.failed = '1' + img.src = '/icon.svg' +}, true) const store = { get(k, d) { try { return JSON.parse(localStorage.getItem(k)) ?? d } catch { return d } }, set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)) } catch {} }, @@ -121,6 +133,107 @@ function timeAgo(ts) { const chapLabel = c => (c.num ? `Cap. ${c.num}` : 'Oneshot') + (c.title ? ` — ${c.title}` : '') const readUrl = (mangaId, chapterId, page) => `#/read/${mangaId}/${chapterId}${page != null ? `?page=${page + 1}` : ''}` +// ---------- Sin conexión (capítulos guardados en este dispositivo) ---------- +// Requiere service worker, que el navegador solo permite con HTTPS (o en localhost). +const OFFLINE_OK = 'serviceWorker' in navigator && window.isSecureContext && 'caches' in window +const OFF_CACHE = 'offline-v1' +if (OFFLINE_OK) navigator.serviceWorker.register('/sw.js').catch(() => {}) +const offIdx = () => store.get('offlineChapters', {}) // chapterId -> { mangaId, mangaTitle, cover, lang, label, num, pages, bytes, at } +const offHas = id => !!offIdx()[id] +const offState = { running: null, queue: [], listeners: new Set() } +const offEmit = () => offState.listeners.forEach(f => { try { f() } catch {} }) +// Capítulos guardados agrupados por manga. +function offByManga() { + const g = new Map() + for (const [chapterId, c] of Object.entries(offIdx())) { + const m = g.get(c.mangaId) || { mangaId: c.mangaId, title: c.mangaTitle, cover: c.cover, chapters: [], bytes: 0, at: 0 } + m.chapters.push({ chapterId, ...c }); m.bytes += c.bytes || 0; m.at = Math.max(m.at, c.at) + g.set(c.mangaId, m) + } + return [...g.values()].sort((a, b) => b.at - a.at) +} +// items: [{ mangaId, mangaTitle, cover, lang, chapterId, label, num }] +function offEnqueue(items) { + for (const it of items) if (!offHas(it.chapterId) && !offState.queue.some(q => q.chapterId === it.chapterId) && offState.running?.chapterId !== it.chapterId) offState.queue.push(it) + navigator.storage?.persist?.().catch(() => {}) + offEmit() + offRun() +} +async function offRun() { + if (offState.running) return + while (offState.queue.length) { + const it = offState.queue.shift() + offState.running = { ...it, done: 0, total: 0 } + offEmit() + try { await offSaveChapter(it) } catch (err) { toast(`No se pudo guardar ${it.label}: ${err.message}`) } + } + offState.running = null + offEmit() +} +async function offSaveChapter(it) { + const cache = await caches.open(OFF_CACHE) + const hd = store.get('readerSettings', {}).hd !== false + const chapUrl = `/api/chapter/${it.chapterId}?saver=0&hd=${hd ? 1 : 0}` + const res = await fetch(chapUrl) + if (!res.ok) throw new Error(`Error ${res.status}`) + const data = await res.clone().json() + await cache.put(chapUrl, res) + const mUrl = `/api/manga/${it.mangaId}?lang=${encodeURIComponent(it.lang || '')}` + const mres = await fetch(mUrl).catch(() => null) + if (mres?.ok) await cache.put(mUrl, mres) + if (it.cover) { const c = await fetch(it.cover).catch(() => null); if (c?.ok) await cache.put(it.cover, c) } + offState.running.total = data.pages.length + let bytes = 0, i = 0 + const worker = async () => { + while (i < data.pages.length) { + const u = data.pages[i++] + const r = await fetch(u) + if (!r.ok) throw new Error(`página ${r.status}`) + const blob = await r.blob() + bytes += blob.size + await cache.put(u, new Response(blob, { headers: { 'Content-Type': r.headers.get('Content-Type') || 'image/jpeg' } })) + offState.running.done++ + offEmit() + } + } + await Promise.all([worker(), worker(), worker()]) + const idx = offIdx() + idx[it.chapterId] = { mangaId: it.mangaId, mangaTitle: it.mangaTitle, cover: it.cover, lang: it.lang, label: it.label, num: it.num ?? null, pages: data.pages.length, bytes, at: Date.now() } + store.set('offlineChapters', idx) +} +async function offRemove(chapterIds) { + const cache = await caches.open(OFF_CACHE) + const idx = offIdx() + for (const id of chapterIds) { + const hit = await cache.match(`/api/chapter/${id}`, { ignoreSearch: true }) + if (hit) { + const data = await hit.json().catch(() => ({ pages: [] })) + await Promise.all((data.pages || []).map(u => cache.delete(u))) + await cache.delete(`/api/chapter/${id}`, { ignoreSearch: true }) + } + const mangaId = idx[id]?.mangaId + delete idx[id] + if (mangaId && !Object.values(idx).some(c => c.mangaId === mangaId)) await cache.delete(`/api/manga/${mangaId}`, { ignoreSearch: true }) + } + store.set('offlineChapters', idx) + offEmit() +} + +// Progreso leído sin conexión: se guarda aquí y se envía al volver la red. +function queueProgress(p) { const q = store.get('pendingProgress', {}); q[p.mangaId] = p; store.set('pendingProgress', q) } +async function flushProgress() { + const q = store.get('pendingProgress', {}) + for (const [k, p] of Object.entries(q)) { + try { await api('/api/progress', { method: 'POST', body: p }); delete q[k] } catch { if (!navigator.onLine) break; delete q[k] } // con red y error: descartarlo + } + store.set('pendingProgress', q) +} +window.addEventListener('online', flushProgress) +setTimeout(flushProgress, 3000) +// Instalar como app (Chrome/Edge/Android ofrecen el aviso; se guarda para el botón de Ajustes). +let installPrompt = null +window.addEventListener('beforeinstallprompt', e => { e.preventDefault(); installPrompt = e }) + // ---------- Router ---------- let prevHash = '', curHash = '' // para volver atrás sin pasar por el lector async function router() { @@ -142,7 +255,8 @@ async function router() { if (parts[0] === 'login') return (location.hash = '#/') if (parts[0] === 'manga') return await viewManga(parts[1]) if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1) - if (parts[0] === 'history') return await viewHistory() + if (parts[0] === 'history') return (location.hash = '#/library') // antiguo nombre + if (parts[0] === 'library') return await viewLibrary(params) if (parts[0] === 'downloads') return (location.hash = '#/queues') // antiguo nombre if (parts[0] === 'queues') return await viewQueues() if (parts[0] === 'stats') return await viewStats() @@ -235,6 +349,20 @@ function bindNews() { }) } const tagHref = (source, t) => `#/?source=${source}&tag=${encodeURIComponent(t.id)}&tagName=${encodeURIComponent(t.name)}` +// Recomendaciones según los géneros que más lees (las calcula el servidor, caché de 6 h). +async function loadRecs(refresh = false) { + if (!$('#recs')) return + const r = await api(`/api/recommendations${refresh ? '?refresh=1' : ''}`).catch(() => null) + // La portada pudo redibujarse mientras tanto: usar la sección actual. + const box = $('#recs') + if (!box) return + if (!r?.items?.length) { box.innerHTML = ''; return } + box.innerHTML = `

${icon('sparkles')} Recomendado para ti

+ Según lo que más lees: ${r.genres.map(g => `${esc(g.name)}`).join(', ')} +
+
${r.items.map(mangaCard).join('')}
` + $('#recref', box).addEventListener('click', e => { e.currentTarget.disabled = true; loadRecs(true) }) +} async function viewHome(params) { const q = params.get('q') || '' const tag = params.get('tag') || '' @@ -246,6 +374,7 @@ async function viewHome(params) { const showCats = mode === 'cats' && !tag app.innerHTML = `
+
${Object.entries(SOURCES).map(([k, v]) => ``).join('')}
@@ -272,6 +401,7 @@ async function viewHome(params) { $('#cont').innerHTML = `
${newsSection(fol)}
` + (hist = hist.filter(h => !h.finished), hist.length ? `

Continuar leyendo

${hist.slice(0, 12).map(continueCard).join('')}
` : '') bindNews(fol) }).catch(() => {}) + loadRecs() } const res = $('#results') @@ -311,7 +441,7 @@ 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, upscale, following, clips: clipCount = 0, newKeys = [] } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`) + const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, clips: clipCount = 0, lists: inLists = [], newKeys = [] } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`) const dlSet = new Set(downloaded) const newSet = new Set(newKeys.map(String)) let isFollowing = following @@ -343,11 +473,12 @@ async function viewManga(id, langOverride) {

${esc(manga.description)}

${mainBtn} + ${offline ? '' : ``} ${menuItems ? `` : ''}
Fuente: ${esc(SOURCES[manga.source])}${download ? `Carpeta: mangas/${esc(download.folder)}` : ''}${clipCount ? `${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}` : ''}
${offline ? '

Sin conexión con la fuente: mostrando solo los capítulos descargados.

' : ''} -
+
@@ -406,6 +537,18 @@ async function viewManga(id, langOverride) { }, }, } + if (OFFLINE_OK) KINDS.off = { + verb: 'Guardar', ic: 'phone', modes: { range: 'Rango', pick: 'Elegir' }, + candidates: () => chapters.filter(c => !offHas(c.id) && !offState.queue.some(q => q.chapterId === c.id) && offState.running?.chapterId !== c.id), + rangeBase: () => chapters, + mark: c => (offHas(c.id) ? ' (en el dispositivo)' : ''), + summary: ids => plural(ids.length, 'capítulo'), + offTitle: () => 'Ya está en el dispositivo', + run: async ids => { + offEnqueue(ids.map(cid => { const c = chapters.find(x => x.id === cid); return { mangaId: id, mangaTitle: manga.title, cover: manga.cover, lang, chapterId: cid, label: chapLabel(c), num: c.num } })) + toast(`Guardando ${plural(ids.length, 'capítulo')} en este dispositivo`) + }, + } const selIds = () => { if (!sel) return [] const K = KINDS[sel.kind] @@ -456,6 +599,7 @@ async function viewManga(id, langOverride) { if (wasPick !== (next?.mode === 'pick') || (wasPick && prevKind !== next?.kind)) renderList() $('#dlopen')?.classList.toggle('on', sel?.kind === 'dl') $('#hdopen')?.classList.toggle('on', sel?.kind === 'hd') + $('#offopen')?.classList.toggle('on', sel?.kind === 'off') updateSummary() } function renderSelForm(kind) { @@ -612,6 +756,89 @@ async function viewManga(id, langOverride) { if (changed) refreshSel() } + // --- En este dispositivo (sin conexión). + const renderOff = () => { + const box = $('#off') + if (!box || !OFFLINE_OK) return + const mine = Object.entries(offIdx()).filter(([, c]) => c.mangaId === id) + const bytes = mine.reduce((n, [, c]) => n + (c.bytes || 0), 0) + const run = offState.running?.mangaId === id ? offState.running : null + const queued = offState.queue.filter(q => q.mangaId === id).length + box.hidden = false + // Siguientes 3 sin guardar a partir de donde vas (o desde el principio). + const from = Math.max(0, chapters.findIndex(c => c.id === progress?.chapter_id)) + const nextIds = chapters.slice(from).filter(c => !offHas(c.id)).slice(0, 3) + let body = '' + if (run || queued) { + const pct = run?.total ? (run.done / run.total) * 100 : 0 + body = `
+
${run ? `${esc(run.label)} · pág. ${run.done}/${run.total || '?'}` : 'Esperando…'}${queued ? ` · ${queued} más` : ''}
` + } else if (!mine.length) body = '
Guarda capítulos en este móvil o PC para leerlos sin internet (también los no descargados en el servidor).
' + const actions = [ + nextIds.length && !run ? `` : '', + KINDS.off.candidates().length ? `` : '', + mine.length ? `` : '', + ].join('') + if (!$('#offtop')) box.innerHTML = '
' + $('#offtop').innerHTML = secHead('phone', 'En este dispositivo', mine.length ? `${plural(mine.length, 'capítulo')} · ${fmtSize(bytes)}` : '') + body + `
${actions}
` + $('#offnext')?.addEventListener('click', () => KINDS.off.run(nextIds.map(c => c.id))) + $('#offopen')?.addEventListener('click', () => setSel(sel?.kind === 'off' ? null : { kind: 'off', mode: 'range' })) + $('#offdel')?.addEventListener('click', async () => { + if (!confirm(`¿Borrar de este dispositivo los ${plural(mine.length, 'capítulo')} guardados de «${manga.title}» (${fmtSize(bytes)})?`)) return + await offRemove(mine.map(([cid]) => cid)) + toast('Borrado del dispositivo') + }) + } + let offRefreshAt = 0 + const onOff = () => { + if (!$('#off')) return + renderOff() + // La lista y el selector solo cuando cambia qué está guardado (no en cada página descargada). + const n = Object.keys(offIdx()).length + offState.queue.length + (offState.running ? 1 : 0) + if (n !== offRefreshAt) { offRefreshAt = n; refreshSel() } + } + if (OFFLINE_OK) offState.listeners.add(onOff) + + // --- Listas propias (menú "Listas"). + let listSet = new Set(inLists) + const renderListsBtn = () => { + const b = $('#lbtn') + if (!b) return + b.classList.toggle('on', listSet.size > 0) + b.innerHTML = `${icon('bookmark')} ${listSet.size ? `En ${plural(listSet.size, 'lista')}` : 'Listas'}` + } + renderListsBtn() + async function renderListsMenu() { + const box = $('#lmlist') + if (!box) return + const lists = await api('/api/lists').catch(() => []) + box.innerHTML = lists.map(l => ``).join('') + + `${icon('library')} Ver mi librería` + box.querySelectorAll('[data-list]').forEach(x => x.addEventListener('change', async () => { + const lid = Number(x.dataset.list) + try { + const r = x.checked + ? await api(`/api/lists/${lid}/items`, { method: 'POST', body: { mangaId: id, title: manga.title, cover: manga.cover } }) + : await api(`/api/lists/${lid}/items/${encodeURIComponent(id)}`, { method: 'DELETE' }) + listSet = new Set(r.lists) + renderListsBtn() + } catch (err) { toast(err.message); x.checked = !x.checked } + })) + $('#lnew').addEventListener('click', async () => { + const name = prompt('Nombre de la nueva lista:') + if (!name?.trim()) return + try { + const l = await api('/api/lists', { method: 'POST', body: { name } }) + const r = await api(`/api/lists/${l.id}/items`, { method: 'POST', body: { mangaId: id, title: manga.title, cover: manga.cover } }) + listSet = new Set(r.lists) + renderListsBtn() + renderListsMenu() + toast(`Añadido a «${l.name}»`) + } catch (err) { toast(err.message) } + }) + } + $('#lmenu')?.addEventListener('toggle', e => { if (e.target.open) renderListsMenu() }) + const renderFollow = () => { const b = $('#follow') b.innerHTML = isFollowing ? `${icon('bell')} Siguiendo` : `${icon('bellOff')} Seguir` @@ -635,10 +862,9 @@ async function viewManga(id, langOverride) { location.hash = back ? prevHash : '#/' }) // Cerrar el menú ⋯ al pulsar fuera. - const menu = $('.menu', app) - const closeMenu = e => { if (menu && !menu.contains(e.target)) menu.open = false } + const closeMenu = e => app.querySelectorAll('.menu[open]').forEach(menu => { if (!menu.contains(e.target)) menu.open = false }) document.addEventListener('click', closeMenu) - cleanup = () => { clearTimeout(dlTimer); clearTimeout(hdTimer); document.removeEventListener('click', closeMenu) } + cleanup = () => { clearTimeout(dlTimer); clearTimeout(hdTimer); document.removeEventListener('click', closeMenu); offState.listeners.delete(onOff) } app.querySelectorAll('[data-lang]').forEach(a => a.addEventListener('click', e => { e.preventDefault(); store.set(`lang:${id}`, a.dataset.lang); viewManga(id, a.dataset.lang) })) const ul = $('.chapters', app) @@ -647,6 +873,7 @@ async function viewManga(id, langOverride) { + (dlSet.has(c.id) ? `${icon('download')} ` : wantedSet.has(c.id) ? `${icon('download')}… ` : '') + (hdSet.has(c.id) ? 'HD ' : hdJobs[c.id] ? `HD${hdJobs[c.id] === 'error' ? '!' : '…'} ` : '') + + (OFFLINE_OK && offHas(c.id) ? `${icon('phone')} ` : '') + (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group)) function renderList() { if (!ul) return @@ -685,6 +912,7 @@ async function viewManga(id, langOverride) { renderList() renderDl(download) renderHd(upscale) + renderOff() $('#order')?.addEventListener('click', () => { desc = !desc; lastPick = null; store.set('chapterOrderDesc', desc); renderList() }) $('#lang')?.addEventListener('change', e => { store.set(`lang:${id}`, e.target.value); viewManga(id, e.target.value) }) } @@ -730,9 +958,9 @@ async function viewReader(mangaId, chapterId, startPage) { chapterTitle: chapter.title, lang: manga.source === 'lc' ? 'es' : chapter.lang || lang, page: lastShown(), totalPages: total, isLast: idx >= 0 && !next, // al terminarlo, el manga sale de "Continuar leyendo" }) - const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; api('/api/progress', { method: 'POST', body: payload() }).catch(() => {}) } + const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; const p = payload(); api('/api/progress', { method: 'POST', body: p }).catch(() => queueProgress(p)) } const scheduleSave = () => { clearTimeout(saveTimer); saveTimer = setTimeout(saveNow, 700) } - const beacon = () => { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; navigator.sendBeacon('/api/progress', JSON.stringify(payload())) } } + const beacon = () => { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; navigator.onLine ? navigator.sendBeacon('/api/progress', JSON.stringify(payload())) : queueProgress(payload()) } } const onVis = () => document.visibilityState === 'hidden' && beacon() document.addEventListener('visibilitychange', onVis) window.addEventListener('pagehide', beacon) @@ -758,6 +986,7 @@ async function viewReader(mangaId, chapterId, startPage) { ${manga.source === 'lc' || chap.local ? '' : ''} ${chap.hdAvailable ? '' : chap.local && info.upscale?.available ? `` : ''} + ${OFFLINE_OK ? `` : ''}

Modo, ajuste y dirección se recuerdan para este manga. ${icon('scroll')} en la barra de arriba cambia entre páginas y vertical. Zoom: pellizca o toca dos veces en el centro (Ctrl+rueda o + / − en PC).

@@ -1055,6 +1284,14 @@ async function viewReader(mangaId, chapterId, startPage) { try { await api(`/api/upscale/${mangaId}`, { method: 'POST', body: { chapterId } }); toast('En cola: estará en HD en unos minutos') } catch (err) { toast(err.message); e.target.closest('button').disabled = false } }) $('#chap').addEventListener('change', e => { location.hash = readUrl(mangaId, e.target.value) }) + $('#offone')?.addEventListener('click', e => { + const b = e.target.closest('button') + b.disabled = true + b.innerHTML = `${icon('phone')} Guardando…` + const done = () => { if (offHas(chapterId) && b.isConnected) { b.innerHTML = `${icon('phone')} En el dispositivo`; offState.listeners.delete(done) } } + offState.listeners.add(done) + offEnqueue([{ mangaId, mangaTitle: manga.title, cover: manga.cover, lang, chapterId, label: chapLabel(chapter), num: chapter.num }]) + }) slider.addEventListener('input', () => goTo(Number(slider.value) - 1)) // --- Recortes: se arrastra un rectángulo sobre la página; se recorta del original (no de la versión HD) @@ -1331,30 +1568,117 @@ async function viewClips(params) { cleanup = () => { clearTimeout(timer); document.removeEventListener('keydown', onKey); document.body.classList.remove('noscroll') } } -// ---------- Historial ---------- -async function viewHistory() { +// ---------- Librería ---------- +// Pestañas: Leyendo, Siguiendo, Terminados, En el dispositivo y las listas propias. +const libCard = ({ href, cover, title, sub, pct, badge, del }) => ` + + ${badge ? `${badge}` : ''} + ${del ? `` : ''} +
+
${esc(title)}
+ ${sub ? `
${sub}
` : ''} +
` +async function viewLibrary(params) { + const tab = params.get('tab') || 'reading' app.innerHTML = '
Cargando…
' - const hist = await api('/api/history') - app.innerHTML = `

Historial de lectura

- ${hist.length ? '' : '

Aún no has leído nada.

'} - ` - app.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', async e => { + const [hist, fol, lists] = await Promise.all([api('/api/history').catch(() => null), api('/api/follows').catch(() => null), api('/api/lists').catch(() => [])]) + if (!location.hash.startsWith('#/library')) return + const online = !!hist + const dev = OFFLINE_OK ? offByManga() : [] + const reading = (hist || []).filter(h => !h.finished) + const finished = (hist || []).filter(h => h.finished) + const follows = fol?.follows || [] + const tabs = [ + ['reading', 'Leyendo', 'book', reading.length], + ['following', 'Siguiendo', 'bell', follows.length], + ['finished', 'Terminados', 'check', finished.length], + ...(OFFLINE_OK && (dev.length || tab === 'device') ? [['device', 'En el dispositivo', 'phone', dev.length]] : []), + ...lists.map(l => [`list-${l.id}`, l.name, 'bookmark', l.count]), + ] + const curList = tab.startsWith('list-') ? lists.find(l => `list-${l.id}` === tab) : null + app.innerHTML = `

Librería

+
${tabs.map(([k, label, ic, n]) => ``).join('')} + ${online ? `` : ''}
+ ${curList ? `
Lista propia · añade mangas desde su ficha con ${icon('bookmark')} Listas +
` : ''} +
Cargando…
` + app.querySelectorAll('[data-tab]').forEach(b => b.addEventListener('click', () => { location.hash = `#/library?tab=${b.dataset.tab}` })) + const body = $('#libbody') + const grid = (cards, empty) => (cards.length ? `
${cards.join('')}
` : `

${empty}

`) + const histCard = h => libCard({ + href: `#/manga/${h.manga_id}`, cover: h.cover, title: h.manga_title, + pct: h.total_pages ? Math.round(((h.page + 1) / h.total_pages) * 100) : 0, + badge: h.finished ? (h.hasNew ? 'Caps. nuevos' : '') : h.hasNew ? 'Caps. nuevos' : '', + sub: `${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · ${timeAgo(h.updated_at)}`, + del: { attrs: `data-delhist="${h.manga_id}"`, title: 'Borrar del historial' }, + }) + if (!online && tab !== 'device') { + body.innerHTML = `

Sin conexión con el servidor.${dev.length ? ' Puedes leer lo guardado en este dispositivo:' : ''}

` + + (dev.length ? `

${icon('phone')} En el dispositivo

` : '') + } else if (tab === 'reading') body.innerHTML = grid(reading.map(histCard), 'No estás leyendo nada ahora mismo.') + else if (tab === 'finished') body.innerHTML = grid(finished.map(histCard), 'Aún no has terminado ningún manga.') + else if (tab === 'following') { + body.innerHTML = grid(follows.map(f => libCard({ + href: `#/manga/${f.manga_id}`, cover: f.cover, title: f.title, badge: f.newCount ? `${f.newCount} nuevo${f.newCount > 1 ? 's' : ''}` : '', + sub: f.last_new_at ? `Último capítulo nuevo ${timeAgo(f.last_new_at)}` : 'Sin novedades aún', + del: { attrs: `data-unfollow="${f.manga_id}"`, title: 'Dejar de seguir', icon: 'bellOff' }, + })), 'No sigues ningún manga. Pulsa «Seguir» en su ficha para recibir avisos de capítulos nuevos.') + } else if (tab === 'device') { + const total = dev.reduce((n, m) => n + m.bytes, 0) + body.innerHTML = (dev.length ? `

${plural(dev.reduce((n, m) => n + m.chapters.length, 0), 'capítulo')} · ${fmtSize(total)} en este dispositivo. Se leen sin internet.

` : '') + + grid(dev.map(m => { + const last = m.chapters.sort((a, b) => (Number(a.num) || 0) - (Number(b.num) || 0)) + return libCard({ + href: `#/manga/${m.mangaId}`, cover: m.cover, title: m.title, + sub: `${plural(m.chapters.length, 'capítulo')} · ${fmtSize(m.bytes)}
${esc(last.map(c => c.num ?? '·').slice(0, 8).join(', '))}${last.length > 8 ? '…' : ''}`, + del: { attrs: `data-deldev="${m.mangaId}"`, title: 'Borrar del dispositivo' }, + }) + }), 'No hay nada guardado en este dispositivo. En la ficha de un manga, «En este dispositivo» guarda capítulos para leerlos sin internet.') + } else if (curList) { + const l = await api(`/api/lists/${curList.id}`).catch(err => (toast(err.message), { items: [] })) + body.innerHTML = grid(l.items.map(it => libCard({ + href: `#/manga/${it.manga_id}`, cover: it.cover, title: it.title, sub: `Añadido ${timeAgo(it.added_at)}`, + del: { attrs: `data-unlist="${it.manga_id}"`, title: 'Quitar de la lista', icon: 'x' }, + })), 'Esta lista está vacía. Añade mangas desde su ficha con el botón «Listas».') + } else body.innerHTML = '

Esa lista ya no existe.

' + + const reload = () => viewLibrary(params) + body.addEventListener('click', async e => { + const b = e.target.closest('button.hdel') + if (!b) return e.preventDefault() e.stopPropagation() - if (!confirm('¿Borrar el historial de este manga?')) return - await api(`/api/history/${b.dataset.del}`, { method: 'DELETE' }) - viewHistory() - })) + try { + if (b.dataset.delhist) { + if (!confirm('¿Borrar el historial de este manga?')) return + await api(`/api/history/${b.dataset.delhist}`, { method: 'DELETE' }) + } else if (b.dataset.unfollow) { + await api(`/api/follow/${b.dataset.unfollow}`, { method: 'DELETE' }) + toast('Ya no sigues este manga') + } else if (b.dataset.deldev) { + const m = dev.find(x => x.mangaId === b.dataset.deldev) + if (!confirm(`¿Borrar de este dispositivo los ${plural(m.chapters.length, 'capítulo')} de «${m.title}» (${fmtSize(m.bytes)})?`)) return + await offRemove(m.chapters.map(c => c.chapterId)) + } else if (b.dataset.unlist) { + await api(`/api/lists/${curList.id}/items/${encodeURIComponent(b.dataset.unlist)}`, { method: 'DELETE' }) + } + reload() + } catch (err) { toast(err.message) } + }) + $('#newlist')?.addEventListener('click', async () => { + const name = prompt('Nombre de la nueva lista:') + if (!name?.trim()) return + try { const l = await api('/api/lists', { method: 'POST', body: { name } }); location.hash = `#/library?tab=list-${l.id}` } catch (err) { toast(err.message) } + }) + $('#lren')?.addEventListener('click', async () => { + const name = prompt('Nuevo nombre de la lista:', curList.name) + if (!name?.trim()) return + try { await api(`/api/lists/${curList.id}`, { method: 'PATCH', body: { name } }); reload() } catch (err) { toast(err.message) } + }) + $('#ldel')?.addEventListener('click', async () => { + if (!confirm(`¿Eliminar la lista «${curList.name}»? Los mangas no se tocan.`)) return + try { await api(`/api/lists/${curList.id}`, { method: 'DELETE' }); location.hash = '#/library' } catch (err) { toast(err.message) } + }) } // ---------- Descargas ---------- @@ -1402,7 +1726,7 @@ async function viewQueues() { // Tarjeta vertical (como en Historial): portada, barra de progreso, título, detalles y acciones. const qcard = ({ key, id, cover, badge, pct, title, lines, actions = '', del = '' }) => `
- + ${badge ? `${badge}` : ''}${del}
${esc(title)} @@ -1600,8 +1924,22 @@ function viewSettings() {
Cambiar contraseña
- +
+
${icon('download')} Descarga anticipada +

Al pasar la mitad de un capítulo, el servidor descarga los siguientes en segundo plano para que abran al instante (y estén en la copia local).

+
+ + +
+
${icon('phone')} App y lectura sin conexión + ${OFFLINE_OK ? `

Capítulos guardados en este dispositivo: …. Se guardan desde la ficha de cada manga («En este dispositivo») o desde el lector.

+
${icon('library')} Ver lo guardado + +
` + : `

Instalar la app y leer sin conexión necesita que la página se abra por HTTPS (el navegador no lo permite en http://${esc(location.host)}). Por ejemplo con tailscale serve o un proxy inverso con certificado.

`} +
+
${icon('key')} Seguridad: IPs bloqueadas
Cargando…
${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:

@@ -1635,6 +1973,39 @@ function viewSettings() { viewSettings() } catch (err) { toast(err.message) } }) + const renderBans = async () => { + const b = await api('/api/bans').catch(() => null) + const box = $('#bans') + if (!box || !b) return + box.innerHTML = `

Tras ${b.maxFails} intentos fallidos en ${b.windowMin} min (contraseña incorrecta o registro cerrado), la IP queda bloqueada ${b.banHours} h. La red local y Tailscale no se bloquean nunca.

+ ${b.bans.length ? `
${b.bans.map(x => `
${esc(x.ip)}hasta ${new Date(x.until).toLocaleString('es')} · ${esc(x.reason || '')} +
`).join('')}
` : '

Ninguna IP bloqueada.

'} +

Tu IP vista por el servidor: ${esc(b.you.ip)}${b.you.protected ? ' (red local, nunca se bloquea)' : ''}${b.trustedHeader ? ` · leída de ${esc(b.trustedHeader)}` : ''}

+ ${b.fails.length ? `

Intentos fallidos recientes: ${b.fails.map(f => `${esc(f.ip)} ×${f.n}`).join(', ')}

` : ''}` + box.querySelectorAll('[data-unban]').forEach(x => x.addEventListener('click', async () => { + try { await api(`/api/bans/${encodeURIComponent(x.dataset.unban)}`, { method: 'DELETE' }); toast('IP desbloqueada'); renderBans() } catch (err) { toast(err.message) } + })) + } + renderBans() + api('/api/prefetch').then(c => { if (!$('#pfn')) return; $('#pfn').value = String(c.n); $('#pfhd').checked = c.hd }).catch(() => {}) + const savePf = () => api('/api/prefetch', { method: 'POST', body: { n: Number($('#pfn').value), hd: $('#pfhd').checked } }).then(() => toast('Descarga anticipada guardada')).catch(err => toast(err.message)) + $('#pfn').addEventListener('change', savePf) + $('#pfhd').addEventListener('change', savePf) + if (OFFLINE_OK) { + const dev = offByManga() + $('#offsum').textContent = dev.length ? `${plural(dev.reduce((n, m) => n + m.chapters.length, 0), 'capítulo')} de ${plural(dev.length, 'manga')} · ${fmtSize(dev.reduce((n, m) => n + m.bytes, 0))}` : 'ninguno' + $('#offclear').hidden = !dev.length + $('#offclear').addEventListener('click', async () => { + if (!confirm('¿Borrar todos los capítulos guardados en este dispositivo?')) return + await caches.delete(OFF_CACHE) + store.set('offlineChapters', {}) + toast('Borrado') + viewSettings() + }) + const inst = $('#install') + inst.hidden = !installPrompt + inst.addEventListener('click', async () => { installPrompt?.prompt(); installPrompt = null; inst.hidden = true }) + } $('#ntest').addEventListener('click', async () => { try { await api('/api/notify/test', { method: 'POST' }); toast('Notificación de prueba enviada') } catch (err) { toast(err.message) } }) diff --git a/app/public/apple-touch-icon.png b/app/public/apple-touch-icon.png new file mode 100644 index 0000000..988fb75 Binary files /dev/null and b/app/public/apple-touch-icon.png differ diff --git a/app/public/icon-192.png b/app/public/icon-192.png new file mode 100644 index 0000000..98b0d02 Binary files /dev/null and b/app/public/icon-192.png differ diff --git a/app/public/icon-512.png b/app/public/icon-512.png new file mode 100644 index 0000000..4212255 Binary files /dev/null and b/app/public/icon-512.png differ diff --git a/app/public/index.html b/app/public/index.html index b66cb71..95d915c 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -6,6 +6,12 @@ Manga Reader + + + + + + @@ -13,7 +19,7 @@ Manga Reader