La ficha del manga incluye la info de la obra, menú lateral y banners reescalados

- Ficha e Info son una sola página: banner, puntuaciones, sinopsis, obras relacionadas y recomendaciones.
- La info se guarda una vez por manga para todos los usuarios y se actualiza en segundo plano; los mangas
  seguidos la mantienen al día solos. Si una fuente falla, se conserva lo guardado.
- Menú lateral (⋯) con seguir, listas, recortes, otras fuentes, descargas y datos de la obra.
- Banners reescalados con Real-ESRGAN; si la obra no tiene, se usa un recorte del propio usuario.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 21:33:03 -06:00
co-authored by Claude Opus 5.5
parent 41a87bc5ad
commit 53e5a0d40e
7 changed files with 289 additions and 145 deletions
+15 -3
View File
@@ -79,16 +79,28 @@ El primer usuario puede registrarse siempre; después el registro queda cerrado
## Info de la obra y fechas de los capítulos
El botón **Info** de cada manga muestra datos de [AniList](https://anilist.co) y
La ficha de cada manga incluye datos de [AniList](https://anilist.co) y
[MangaUpdates](https://www.mangaupdates.com): autores, revista y editorial, fechas de publicación, tomos,
puntuaciones, rankings, adaptaciones al anime, obras relacionadas, recomendaciones y enlaces oficiales.
puntuaciones, rankings, adaptaciones al anime, obras relacionadas, recomendaciones, enlaces oficiales y la
imagen de fondo de la obra.
La obra se identifica por los enlaces de MangaDex; si el manga viene de LeerCapitulo o M440, por su título
(solo si coincide, para no mostrar datos de otra obra).
Las fechas de la lista de capítulos son las de la **primera traducción conocida** de cada capítulo
(MangaDex en cualquier idioma y MangaUpdates), que en series en curso es muy cercana a la de publicación;
los sitios en español suelen poner la fecha en que ellos lo subieron. Las marcadas con «≈» son estimadas
entre dos capítulos con fecha. Todo se guarda en la base de datos (ficha 7 días, fechas 1 día).
entre dos capítulos con fecha.
Todo se guarda en la base de datos, una vez por manga y para todos los usuarios: lo guardado se muestra sin
consultar nada y, si es antiguo (info 30 días, fechas 1 día), se actualiza en segundo plano. Los mangas que
sigue algún usuario se completan y se mantienen al día solos, aunque nadie abra su ficha.
La imagen de fondo de la ficha se reescala con Real-ESRGAN (×4 si mide menos de 1000 px de ancho, ×2 si no). Si la obra
no tiene, se usa uno de tus recortes de ese manga; sin recortes, la ficha va sin imagen de fondo.
En la ficha solo quedan a la vista «Continuar/Empezar a leer» y «Capítulos». El botón **⋯** abre el menú lateral con
seguir, listas, recortes, buscar en otras fuentes, dejar de leer, la copia local, el reescalado HD, «En este dispositivo»
y los datos de la obra (autores, editorial, publicación…).
## Sin internet en el servidor
+11 -1
View File
@@ -98,6 +98,16 @@ export function setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID,
}
const count = (userId, mangaId) => db.prepare('SELECT COUNT(*) n FROM clips WHERE user_id = ? AND manga_id = ?').get(userId, mangaId).n
// Banner de la ficha cuando la obra no tiene: un recorte del propio usuario, el más apaisado (es el que mejor
// llena la franja) y, si los hay, de entre los ya reescalados.
function bannerFor(userId, mangaId) {
const c = db.prepare(`SELECT * FROM clips WHERE user_id = ? AND manga_id = ?
ORDER BY status = 'done' DESC, CAST(width AS REAL) / height DESC, id DESC LIMIT 1`).get(userId, mangaId)
if (!c) return null
const v = view(c)
return v.hd || v.img
}
async function readImage(req) {
let size = 0
const chunks = []
@@ -178,5 +188,5 @@ export function setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID,
const queueInfo = userId => db.prepare(`SELECT SUM(status = 'queued') queued, SUM(status = 'running') running, SUM(status = 'error') errors
FROM clips WHERE user_id = ?`).get(userId)
return { route, count, pump, queueInfo }
return { route, count, bannerFor, pump, queueInfo }
}
+14 -8
View File
@@ -1,6 +1,7 @@
// Portadas en HD: cada portada que se sirve (de la fuente o la cover.jpg de una descarga) se encola para
// reescalarla con Real-ESRGAN (×4 si es pequeña, ×2 si no). Mientras tanto se sirve la original con
// caché corta; en cuanto existe la versión HD se sirve esa. Caché en <DATA_DIR>/covers/<hash>.webp.
// Los banners de la ficha pasan por lo mismo, pero se reescalan siempre: se ven a todo el ancho de la pantalla.
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
@@ -11,6 +12,7 @@ const MAX_QUEUE = 300
const X4_MAX_SIDE = 400
const MAX_SIDE = 1600 // más grande ya no hace falta reescalarla
const WEBP_QUALITY = 88
const BANNER_X4_MAX_WIDTH = 1000
// Ancho/alto de un JPEG, PNG o WebP sin decodificarlo (null si no se reconoce).
export function imageSize(b) {
@@ -53,23 +55,26 @@ export function setupCovers({ DATA_DIR, upscale }) {
return true
}
// Cabecera de caché para la original mientras no hay HD (corta, para que el navegador pida luego la HD).
const pendingCache = () => (upscale.available() ? 'private, max-age=300' : 'private, max-age=604800, immutable')
// Un banner no se guarda: en la siguiente visita ya se ve el reescalado.
const pendingCache = (banner = false) => (!upscale.available() ? 'private, max-age=604800, immutable' : banner ? 'no-store' : 'private, max-age=300')
function enqueue(key, buf) {
if (!upscale.available() || pending.has(key) || failed.has(key) || queue.length >= MAX_QUEUE || fs.existsSync(fileOf(key))) return
// ¿Falta generar esta imagen? (para no descargarla si no hace falta)
const wants = key => upscale.available() && !pending.has(key) && !failed.has(key) && queue.length < MAX_QUEUE && !fs.existsSync(fileOf(key))
function enqueue(key, buf, banner = false) {
if (!wants(key)) return
pending.add(key)
queue.push({ key, buf })
queue.push({ key, buf, banner })
pump()
}
async function processOne({ key, buf }) {
async function processOne({ key, buf, banner }) {
const size = imageSize(buf)
if (!size) throw new Error('formato no reconocido')
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'cover-'))
try {
const out = path.join(work, 'out.png')
const tmp = `${fileOf(key)}.tmp`
if (Math.max(size.w, size.h) > MAX_SIDE) {
if (!banner && Math.max(size.w, size.h) > MAX_SIDE) {
// Ya es grande: solo se recomprime para no volver a intentarlo.
const inp = path.join(work, `in.${size.type}`)
fs.writeFileSync(inp, buf)
@@ -78,7 +83,8 @@ export function setupCovers({ DATA_DIR, upscale }) {
} else {
const inp = path.join(work, `in.${size.type}`)
fs.writeFileSync(inp, buf)
const scale = Math.max(size.w, size.h) <= X4_MAX_SIDE && fs.existsSync(path.join(MODELS, `${MODEL}-x4.bin`)) ? 4 : 2
const small = banner ? size.w < BANNER_X4_MAX_WIDTH : Math.max(size.w, size.h) <= X4_MAX_SIDE
const scale = small && fs.existsSync(path.join(MODELS, `${MODEL}-x4.bin`)) ? 4 : 2
await run(BIN, ['-i', inp, '-o', out, '-m', MODELS, '-n', MODEL, '-s', String(scale), '-f', 'png'])
await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), '-m', '4', '-mt', out, '-o', tmp])
}
@@ -105,5 +111,5 @@ export function setupCovers({ DATA_DIR, upscale }) {
}
}
return { sendCached, enqueue, pendingCache, pendingCount: () => pending.size }
return { sendCached, wants, enqueue, pendingCache, pendingCount: () => pending.size }
}
+74 -34
View File
@@ -9,8 +9,12 @@
// misma para cientos). Se toma la primera fecha conocida de cada número de capítulo en cualquier
// idioma (MangaDex) o grupo (MangaUpdates): es la fecha de la primera traducción, muy cercana a la de
// publicación en series en curso.
// Todo se guarda en la base de datos (si una fuente falla se usa lo último que se tenía).
// Todo se guarda en la base de datos, una vez por manga y para todos los usuarios: lo guardado se sirve sin
// consultar nada y, si es antiguo, se actualiza en segundo plano (si una fuente falla, se queda lo que había).
// Los mangas seguidos por alguien se mantienen siempre al día, aunque nadie abra su ficha.
const DAY = 24 * 3600e3
const INFO_TTL = v => (v?.found ? 30 * DAY : 3 * DAY)
const SYNC_EVERY = 3600e3
const ANILIST = 'https://graphql.anilist.co'
const MU_API = 'https://api.mangaupdates.com/v1'
const MD_RATINGS = ['safe', 'suggestive', 'erotica', 'pornographic']
@@ -34,29 +38,30 @@ const numKey = n => { const f = parseFloat(n); return Number.isFinite(f) ? Strin
const toText = (html, decode) => decode(String(html || '').replace(/\r/g, '').replace(/<br\s*\/?>\s*/gi, '\n').replace(/<\/p>/gi, '\n\n').replace(/<[^>]+>/g, ''))
.split('\n').map(l => l.trim()).join('\n').replace(/\n{3,}/g, '\n\n').trim()
export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA }) {
export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA, prepareBanner }) {
db.exec(`CREATE TABLE IF NOT EXISTS meta_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, at INTEGER NOT NULL)`)
// Guardado en la base de datos con caducidad; ttl puede depender del valor (p. ej. "no encontrado" dura menos).
// Si caducó se devuelve lo guardado y se actualiza en segundo plano; con wait se espera a la actualización.
const inflight = new Map()
function stored(key, ttl, fn) {
function stored(key, ttl, fn, wait = false) {
const row = db.prepare('SELECT value, at FROM meta_cache WHERE key = ?').get(key)
const old = row ? JSON.parse(row.value) : undefined
const life = v => (typeof ttl === 'function' ? ttl(v) : ttl)
if (row && Date.now() - row.at < life(old)) return Promise.resolve(old)
if (inflight.has(key)) return inflight.get(key)
const p = (async () => {
if (!inflight.has(key)) {
inflight.set(key, (async () => {
try {
const v = await fn()
const v = await fn(old)
db.prepare('INSERT INTO meta_cache (key, value, at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, at = excluded.at').run(key, JSON.stringify(v), Date.now())
return v
} catch (e) {
if (old !== undefined) return old
throw e
}
})().finally(() => inflight.delete(key))
inflight.set(key, p)
return p
})().finally(() => inflight.delete(key)))
}
return old !== undefined && !wait ? Promise.resolve(old) : inflight.get(key)
}
// Peticiones a AniList/MangaUpdates, de una en una con una pausa (los dos limitan la frecuencia).
@@ -87,7 +92,7 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
}
const mdTitles = m => [...Object.values(m.attributes.title || {}), ...(m.attributes.altTitles || []).flatMap(t => Object.values(t))]
async function links(mangaId) {
async function links(mangaId, wait = false) {
return stored(`links:${mangaId}`, v => (v?.md || v?.al || v?.mu ? 30 * DAY : 3 * DAY), async () => {
if (sourceOf(mangaId) === 'md') {
const d = await md(`/manga/${mangaId}`, {}, 30 * 60e3)
@@ -96,18 +101,22 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
const { manga } = await fetchMangaFull(mangaId, 'es')
const title = manga.title
// MangaDex: el que tenga ese título (o alternativo); entre varios, el que más enlaces tenga.
const r = await md('/manga', { title, limit: 10, 'contentRating[]': MD_RATINGS }, 6 * 3600e3).catch(() => null)
// Una fuente caída no significa "no encontrado": no se guarda y se reintenta.
let failed = false
const soft = p => p.catch(() => { failed = true; return null })
const r = await soft(md('/manga', { title, limit: 10, 'contentRating[]': MD_RATINGS }, 6 * 3600e3))
const hits = (r?.data || []).filter(m => mdTitles(m).some(t => similar(t, title)))
hits.sort((a, b) => Object.keys(b.attributes.links || {}).length - Object.keys(a.attributes.links || {}).length)
if (hits[0]) return { ...mdLinks(hits[0]), via: 'MangaDex', title: mdTitles(hits[0])[0] }
const a = await anilist(`query($s:String){Page(perPage:6){media(search:$s,type:MANGA){id idMal title{romaji english native} synonyms}}}`, { s: title }).catch(() => null)
const a = await soft(anilist(`query($s:String){Page(perPage:6){media(search:$s,type:MANGA){id idMal title{romaji english native} synonyms}}}`, { s: title }))
const am = a?.Page?.media?.find(m => [m.title.romaji, m.title.english, m.title.native, ...(m.synonyms || [])].some(t => similar(t, title)))
if (am) return { md: null, al: am.id, mu: null, mal: am.idMal || null, via: 'AniList', title: am.title.romaji }
const s = await mu('/series/search', { search: title, perpage: 8 }).catch(() => null)
const s = await soft(mu('/series/search', { search: title, perpage: 8 }))
const sm = s?.results?.find(x => similar(decode(x.record.title), title) || similar(decode(x.hit_title), title))
if (sm) return { md: null, al: null, mu: sm.record.series_id, mal: null, via: 'MangaUpdates', title: decode(sm.record.title) }
if (failed) throw new HttpError(502, 'No se pudo identificar la obra')
return {}
})
}, wait)
}
// ---------- Ficha ----------
@@ -124,15 +133,18 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
const date = d => (d?.year ? [d.year, d.month, d.day].filter(Boolean).map((x, i) => (i ? String(x).padStart(2, '0') : x)).join('-') : null)
const cover = u => (u ? imgUrl(u) : null)
async function info(mangaId) {
const l = await links(mangaId)
return stored(`info:${mangaId}`, 7 * DAY, async () => {
async function info(mangaId, wait = false) {
const l = await links(mangaId, wait)
return stored(`info:${mangaId}`, INFO_TTL, async old => {
let failed = false
const [al, ms] = await Promise.all([
l.al ? anilist(`query($id:Int){Media(id:$id,type:MANGA){${AL_FIELDS}}}`, { id: l.al })
: l.mal ? anilist(`query($id:Int){Media(idMal:$id,type:MANGA){${AL_FIELDS}}}`, { id: l.mal }) : null,
l.mu ? mu(`/series/${l.mu}`) : null,
].map(p => Promise.resolve(p).catch(e => { console.warn('Info:', e.message); return null })))
].map(p => Promise.resolve(p).catch(e => { console.warn('Info:', e.message); failed = true; return null })))
const a = al?.Media
// Con una fuente caída no se pisa lo que había ni se da la obra por no encontrada.
if (failed && (old?.found || (!a && !ms))) throw new HttpError(502, 'No se pudo consultar la información de la obra')
if (!a && !ms) return { found: false }
const staff = (a?.staff?.edges || []).filter(e => /^(Story|Art|Original)/i.test(e.role)).map(e => ({ role: e.role, name: e.node.name.full, native: e.node.name.native }))
const muAuthors = (ms?.authors || []).map(x => ({ role: x.type === 'Artist' ? 'Art' : 'Story', name: decode(x.name) }))
@@ -181,14 +193,24 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
].filter(x => x && /^https?:\/\//.test(x.url)).slice(0, 14),
matched: { via: l.via || null, title: l.title || null },
}
})
}, wait)
}
// La ficha guardada, sin esperar a ninguna fuente (null si aún no hay). Si caducó, se actualiza por detrás.
function cachedInfo(mangaId) {
const row = db.prepare('SELECT value FROM meta_cache WHERE key = ?').get(`info:${mangaId}`)
if (!row) return null
info(mangaId).catch(() => {})
return JSON.parse(row.value)
}
// ---------- Fechas de los capítulos ----------
async function dates(mangaId) {
const l = await links(mangaId)
async function dates(mangaId, wait = false) {
const l = await links(mangaId, wait)
if (!l.md && !l.mu) return { dates: {}, via: [] }
return stored(`dates:${mangaId}`, DAY, async () => {
return stored(`dates:${mangaId}`, DAY, async old => {
let failed = false
const soft = p => p.catch(() => { failed = true; return null })
const out = {}
const via = []
const add = (n, d) => {
@@ -200,10 +222,10 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
if (l.md) {
let n = 0
for (let offset = 0; offset < 10000; offset += 500) {
const d = await md(`/manga/${l.md}/feed`, {
const d = await soft(md(`/manga/${l.md}/feed`, {
limit: 500, offset, 'order[chapter]': 'asc', 'contentRating[]': MD_RATINGS,
includeExternalUrl: 1, includeEmptyPages: 1, includeFuturePublishAt: 0, includeUnavailable: 1,
}, 3600e3).catch(() => null)
}, 3600e3))
for (const c of d?.data || []) { add(c.attributes.chapter, c.attributes.publishAt || c.attributes.createdAt); n++ }
if (!d || offset + 500 >= d.total) break
}
@@ -212,7 +234,7 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
if (l.mu) {
let n = 0
for (let page = 1; page <= 40; page++) {
const r = await mu('/releases/search', { search: String(l.mu), search_type: 'series', perpage: 100, page, orderby: 'date', asc: 'asc' }).catch(() => null)
const r = await soft(mu('/releases/search', { search: String(l.mu), search_type: 'series', perpage: 100, page, orderby: 'date', asc: 'asc' }))
for (const x of r?.results || []) {
const ch = String(x.record.chapter || '')
const d = x.record.release_date
@@ -229,23 +251,41 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
}
if (n) via.push('MangaUpdates')
}
if (failed && (old?.via?.length || !Object.keys(out).length)) throw new HttpError(502, 'No se pudieron consultar las fechas')
return { dates: out, via }
})
}, wait)
}
// ---------- Mangas seguidos: siempre con su información ----------
// Cada hora se completa lo que falte o haya caducado, de uno en uno (también lo seguido al empezar a leer),
// y se reescalan los banners guardados que aún no lo estén.
let syncing = false
async function syncFollowed() {
if (syncing || !net.online()) return
syncing = true
try {
for (const { manga_id: id } of db.prepare('SELECT DISTINCT manga_id FROM follows WHERE active = 1').all()) {
if (!net.online()) break
await info(id, true).catch(e => console.warn('Info de seguidos', id, e.message))
await dates(id, true).catch(e => console.warn('Fechas de seguidos', id, e.message))
}
for (const { value } of db.prepare("SELECT value FROM meta_cache WHERE key LIKE 'info:%'").all()) {
const { banner } = JSON.parse(value)
if (banner) await prepareBanner(banner).catch(e => console.warn('Banner', e.message))
}
} finally { syncing = false }
}
setTimeout(syncFollowed, 60e3).unref()
setInterval(syncFollowed, SYNC_EVERY).unref()
net.whenOnline(syncFollowed)
async function route(p, method, res) {
const m = /^\/api\/meta\/([^/]+)(\/dates)?$/.exec(p)
if (!m || method !== 'GET') return false
const id = decodeURIComponent(m[1])
if (!net.online()) throw new HttpError(503, 'El servidor no tiene conexión a internet: la información extra no está disponible.')
if (m[2]) json(res, 200, await dates(id))
else {
// La ficha de la fuente (título, sinopsis en español, portada) va aparte de lo externo.
const [src, ext] = await Promise.all([fetchMangaFull(id, 'es').then(r => r.manga, () => null), info(id)])
json(res, 200, { ...ext, source: src && { title: src.title, description: src.description, cover: src.cover, authors: src.authors, source: src.source } })
}
json(res, 200, await (m[2] ? dates(id) : info(id)))
return true
}
return { route, links, info, dates }
return { route, links, info, cachedInfo, dates }
}
+112 -67
View File
@@ -44,7 +44,6 @@ const ICONS = {
phone: '<rect x="6" y="2" width="12" height="20" rx="2.5"/><path d="M11 18h2"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
pencil: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13.5 6.5 4 4"/>',
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v6"/><path d="M12 7.5h.01"/>',
sparkles: '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>',
}
const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`
@@ -368,7 +367,7 @@ async function router() {
try {
if (parts[0] === 'login') return (location.hash = '#/')
if (parts[0] === 'manga') return await viewManga(parts[1])
if (parts[0] === 'info') return await viewInfo(parts[1])
if (parts[0] === 'info') return (location.hash = `#/manga/${parts[1]}`) // la ficha ya incluye la info
if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1)
if (parts[0] === 'history') return (location.hash = '#/library') // antiguo nombre
if (parts[0] === 'library') return await viewLibrary(params)
@@ -588,7 +587,7 @@ async function viewHome(params) {
}
// ---------- Ficha del manga ----------
// ---------- Info: datos de la obra de AniList y MangaUpdates ----------
// ---------- Datos de la obra de AniList y MangaUpdates (parte de la ficha) ----------
const AL_FORMAT = { MANGA: 'Manga', NOVEL: 'Novela ligera', ONE_SHOT: 'Oneshot', Manga: 'Manga', Manhwa: 'Manhwa', Manhua: 'Manhua' }
const AL_STATUS = { FINISHED: 'Terminado', RELEASING: 'En publicación', NOT_YET_RELEASED: 'Sin estrenar', CANCELLED: 'Cancelado', HIATUS: 'En pausa' }
const AL_COUNTRY = { JP: 'Japón', KR: 'Corea del Sur', CN: 'China', TW: 'Taiwán' }
@@ -597,74 +596,55 @@ const AL_REL = { SEQUEL: 'Secuela', PREQUEL: 'Precuela', SIDE_STORY: 'Historia p
const AL_ANIME = { TV: 'Serie TV', TV_SHORT: 'Serie corta', MOVIE: 'Película', OVA: 'OVA', ONA: 'ONA', SPECIAL: 'Especial', MUSIC: 'Vídeo musical' }
const fmtFuzzy = d => (!d ? '' : d.length === 4 ? d : d.length === 7 ? new Date(`${d}-15T12:00:00`).toLocaleDateString('es', { month: 'long', year: 'numeric' }) : fmtDate(d))
async function viewInfo(id) {
if (cleanup) { cleanup(); cleanup = null }
app.innerHTML = '<div class="loading">Buscando información de la obra…</div>'
const d = await api(`/api/meta/${id}`)
const src = d.source || {}
const back = `<a class="btn small" href="#/manga/${id}">${icon('back')} Volver</a>`
if (!d.found) {
app.innerHTML = `<div class="info-page">${back}<h1>${esc(src.title || '')}</h1>
<p class="muted">No se encontró esta obra en AniList ni en MangaUpdates. Puede tener otro título allí: prueba a buscarla en
<a class="tag" href="https://anilist.co/search/manga?search=${encodeURIComponent(src.title || '')}" target="_blank" rel="noopener">AniList</a>.</p></div>`
return
}
// Lo que la ficha pinta a partir de la información guardada de la obra (d.found).
function infoParts(d, manga) {
const row = (k, v) => (v ? `<dt>${k}</dt><dd>${v}</dd>` : '')
const list = a => a.map(esc).join(', ')
const t = d.titles
const people = d.staff.reduce((m, p) => m.set(p.name, [...(m.get(p.name) || []), AL_ROLE(p.role)]), new Map())
const dates = d.start ? `${fmtFuzzy(d.start)}${d.end ? ` – ${fmtFuzzy(d.end)}` : d.status === 'RELEASING' ? ' – actualidad' : ''}` : ''
const size = [d.volumes ? `${d.volumes} tomos` : '', d.chapters ? `${d.chapters} capítulos` : d.latestChapter ? `${d.latestChapter} capítulos (hasta ahora)` : ''].filter(Boolean).join(' · ')
const ranks = d.rankings.map(r => `#${r.rank} ${r.type === 'RATED' ? 'mejor valorado' : 'más popular'}${r.allTime ? ' de siempre' : ` de ${r.year}`}`)
const search = title => `#/?q=${encodeURIComponent(title)}`
const cardOf = r => `<a class="card" href="${search(r.title)}" title="Buscar «${esc(r.title)}»">
const cardOf = r => `<a class="card" href="#/?q=${encodeURIComponent(r.title)}" title="Buscar «${esc(r.title)}»">
<img class="cover" loading="lazy" src="${esc(r.cover || '/icon.svg')}" alt="" data-fallback>
<div class="t">${esc(r.title)}</div><div class="sub">${esc([r.relation ? AL_REL[r.relation] || r.relation : '', r.year || '', r.score ? `${r.score}%` : ''].filter(Boolean).join(' · '))}</div></a>`
const srcDesc = (src.description || '').trim()
app.innerHTML = `
<div class="info-page">
${d.banner ? `<div class="banner" style="background-image:url('${esc(d.banner)}')"></div>` : ''}
<div class="row info-top">${back}</div>
<div class="detail">
<img class="cover" src="${esc(d.cover || src.cover || '/icon.svg')}" alt="" data-fallback>
<div class="detail-body">
<h1>${esc(t.english || t.romaji || src.title)}</h1>
<div class="muted">${esc([t.romaji !== (t.english || t.romaji) ? t.romaji : '', t.native].filter(Boolean).join(' · '))}</div>
<div class="scores">
const seen = new Set([manga.title.toLowerCase()])
const altTitles = [t.english, t.romaji, t.native].filter(x => x && !seen.has(x.toLowerCase()) && seen.add(x.toLowerCase()))
const srcDesc = (manga.description || '').trim()
return {
banner: d.banner,
sub: altTitles.join(' · '),
scores: `<div class="scores">
${d.score != null ? `<div class="score"><b>${d.score}%</b><span>AniList</span></div>` : ''}
${d.muScore != null ? `<div class="score"><b>${Number(d.muScore).toFixed(1)}</b><span>MangaUpdates${d.muVotes ? ` · ${fmtNum(d.muVotes)} votos` : ''}</span></div>` : ''}
${d.popularity ? `<div class="score"><b>${fmtNum(d.popularity)}</b><span>lectores en AniList</span></div>` : ''}
</div>
${ranks.length ? `<div class="tags">${ranks.map(r => `<span class="tag hot">${esc(r)}</span>`).join('')}</div>` : ''}
<dl class="kv">
${[...people].map(([name, roles]) => row(esc(roles.join(' y ')), esc(name))).join('')}
${ranks.length ? `<div class="tags">${ranks.map(r => `<span class="tag hot">${esc(r)}</span>`).join('')}</div>` : ''}`,
kv: `${people.size ? [...people].map(([name, roles]) => row(esc(roles.join(' y ')), esc(name))).join('') : row('Autor', esc(manga.authors.join(', ')))}
${row('Revista', d.magazines.map(m => esc(m.name) + (m.publisher ? ` <span class="muted">(${esc(m.publisher)})</span>` : '')).join(', '))}
${row('Editorial', d.publishers.filter(p => p.type === 'Original').map(p => esc(p.name)).join(', '))}
${row('En inglés', d.publishers.filter(p => p.type === 'English').map(p => esc(p.name)).join(', '))}
${row('Tipo', esc([AL_FORMAT[d.format] || d.format, AL_COUNTRY[d.country]].filter(Boolean).join(' · ')))}
${row('Publicación', esc(dates))}
${row('Estado', esc([AL_STATUS[d.status], d.statusDetail].filter(Boolean).join(' · ')))}
${row('Publicación', esc(dates || manga.year || ''))}
${row('Estado', esc([AL_STATUS[d.status], d.statusDetail].filter(Boolean).join(' · ') || manga.status || ''))}
${row('Extensión', esc(size))}
${row('Anime', [...d.adaptations.map(a => esc(`${AL_ANIME[a.format] || a.format || 'Anime'}${a.year ? ` (${a.year})` : ''}`)), d.anime ? `<span class="muted">adapta: ${esc(d.anime.start)}${d.anime.end ? ` → ${esc(d.anime.end)}` : ''}</span>` : ''].filter(Boolean).join('<br>'))}
</dl>
<div class="tags">${d.genres.map(g => `<span class="tag">${esc(g)}</span>`).join('')}${d.tags.map(g => `<span class="tag soft">${esc(g)}</span>`).join('')}</div>
</div>
</div>
${srcDesc ? `<h2>Sinopsis</h2><p class="desc long">${esc(srcDesc)}</p>` : ''}
${d.description ? (srcDesc ? `<details class="more"><summary>Sinopsis de AniList (en inglés)</summary><p class="desc long">${esc(d.description)}</p></details>` : `<h2>Sinopsis <span class="muted">(en inglés)</span></h2><p class="desc long">${esc(d.description)}</p>`) : ''}
${t.other.length ? `<h2>Otros títulos</h2><p class="muted">${list(t.other)}</p>` : ''}
${d.related.length ? `<h2>Obras relacionadas</h2><div class="grid">${d.related.map(cardOf).join('')}</div>` : ''}
${d.recommendations.length ? `<h2>Si te gusta, prueba</h2><div class="grid">${d.recommendations.map(cardOf).join('')}</div>` : ''}
${row('Anime', [...d.adaptations.map(a => esc(`${AL_ANIME[a.format] || a.format || 'Anime'}${a.year ? ` (${a.year})` : ''}`)), d.anime ? `<span class="muted">adapta: ${esc(d.anime.start)}${d.anime.end ? ` → ${esc(d.anime.end)}` : ''}</span>` : ''].filter(Boolean).join('<br>'))}`,
genres: d.genres.map(g => `<span class="tag">${esc(g)}</span>`).join(''),
softTags: d.tags.map(g => `<span class="tag soft">${esc(g)}</span>`).join(''),
synopsis: `${srcDesc ? `<h2>Sinopsis</h2><p class="desc long">${esc(srcDesc)}</p>` : ''}
${d.description ? (srcDesc ? `<details class="more"><summary>Sinopsis de AniList (en inglés)</summary><p class="desc long">${esc(d.description)}</p></details>` : `<h2>Sinopsis <span class="muted">(en inglés)</span></h2><p class="desc long">${esc(d.description)}</p>`) : ''}`,
extra: `${t.other.length ? `<h2>Otros títulos</h2><p class="muted">${t.other.map(esc).join(', ')}</p>` : ''}
${d.related.length ? `<h2>Obras relacionadas</h2><div class="hrow">${d.related.map(cardOf).join('')}</div>` : ''}
${d.recommendations.length ? `<h2>Si te gusta, prueba</h2><div class="hrow">${d.recommendations.map(cardOf).join('')}</div>` : ''}
${d.links.length ? `<h2>Enlaces</h2><div class="tags">${d.links.map(l => `<a class="tag" href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.site)}</a>`).join('')}</div>` : ''}
<p class="muted note">Datos de AniList y MangaUpdates${d.matched?.title ? ` (identificado por título como «${esc(d.matched.title)}»)` : ''}. Las fechas de los capítulos son las de la primera traducción conocida.</p>
</div>`
<p class="muted note">Datos de AniList y MangaUpdates${d.matched?.title ? ` (identificado por título como «${esc(d.matched.title)}»)` : ''}. Las fechas de los capítulos son las de la primera traducción conocida.</p>`,
}
}
async function viewManga(id, langOverride) {
if (cleanup) { cleanup(); cleanup = null }
app.innerHTML = '<div class="loading">Cargando…</div>'
const lang = langOverride || store.get(`lang:${id}`, me.lang)
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, clips: clipCount = 0, lists: inLists = [], 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 = [], info: savedInfo = null, clipBanner = null } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
const dlSet = new Set(downloaded)
const newSet = new Set(newKeys.map(String))
let isFollowing = following
@@ -683,30 +663,43 @@ async function viewManga(id, langOverride) {
if (first && !mainBtn) mainBtn = `<a class="btn primary" href="${readUrl(id, first.id)}">${icon('book')} Empezar a leer</a>`
const menuItems = [
manga.source === 'lc' ? '' : `<a href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
manga.source === 'mi' ? '' : `<a href="${altSearchMi}">${icon('search')} Buscar en M440</a>`,
progress || reads.length ? `<button id="drop" class="danger">${icon('stopRead')} Dejar de leer…</button>` : '',
manga.source === 'lc' ? '' : `<a class="btn" href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
manga.source === 'mi' ? '' : `<a class="btn" href="${altSearchMi}">${icon('search')} Buscar en M440</a>`,
progress || reads.length ? `<button id="drop" class="btn danger">${icon('stopRead')} Dejar de leer…</button>` : '',
].join('')
const srcTags = manga.tags.map(t => (t.id ? `<a class="tag" href="${tagHref(manga.source, t)}">${esc(t.name)}</a>` : `<span class="tag">${esc(t.name ?? t)}</span>`)).join('')
const srcSub = `${esc(manga.authors.join(', '))}${manga.year ? ` · ${manga.year}` : ''}${manga.status ? ` · ${esc(manga.status)}` : ''}`
app.innerHTML = `
<div class="banner" id="mbanner" hidden></div>
<div class="detail">
<img class="cover" src="${esc(manga.cover || '/icon.svg')}" alt="">
<img class="cover" id="mcover" src="${esc(manga.cover || '/icon.svg')}" alt="">
<div class="detail-body">
<h1>${esc(manga.title)}</h1>
<div class="muted">${esc(manga.authors.join(', '))}${manga.year ? ` · ${manga.year}` : ''}${manga.status ? ` · ${esc(manga.status)}` : ''}</div>
<div class="tags">${manga.tags.map(t => (t.id ? `<a class="tag" href="${tagHref(manga.source, t)}">${esc(t.name)}</a>` : `<span class="tag">${esc(t.name ?? t)}</span>`)).join('')}</div>
<p class="desc">${esc(manga.description)}</p>
<div class="muted" id="msub">${srcSub}</div>
<div id="mscores"></div>
<div class="actions">${mainBtn}
<button class="btn" id="follow"></button>
${offline ? '' : `<a class="btn" href="#/info/${id}" title="Autor, revista, puntuación, adaptaciones…">${icon('info')} Info</a>`}
${offline ? '' : `<details class="menu" id="lmenu"><summary class="btn" id="lbtn" title="Añadir a tus listas">${icon('bookmark')} Listas</summary><div class="menu-list" id="lmlist"><div class="muted note">Cargando…</div></div></details>`}
${menuItems ? `<details class="menu"><summary class="btn" title="Más opciones" aria-label="Más opciones">${icon('dots')}</summary><div class="menu-list">${menuItems}</div></details>` : ''}
${chapters.length ? `<button class="btn" id="tochap" title="Ir a la lista de capítulos">${icon('queue')} Capítulos</button>` : ''}
<button class="btn" id="libbtn" title="Más opciones" aria-label="Más opciones"></button>
</div>
<div class="facts muted"><span>Fuente: ${esc(SOURCES[manga.source])}</span>${download ? `<span>Carpeta: mangas/${esc(download.folder)}</span>` : ''}${clipCount ? `<a href="#/clips?manga=${encodeURIComponent(id)}">${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}</a>` : ''}</div>
${offline ? '<p class="error">Sin conexión con la fuente: mostrando solo los capítulos descargados.</p>' : ''}
<div class="info-block" id="msyn"></div>
<div class="tags" id="mtags"></div>
</div>
</div>
<div class="info-block" id="mextra"></div>
<div class="drawer-back" id="libback" hidden></div>
<aside class="drawer" id="libdrawer" aria-label="Más opciones" inert>
<div class="drawer-head"><b>Opciones</b><button class="btn small" id="libclose" title="Cerrar" aria-label="Cerrar">${icon('x')}</button></div>
<div class="drawer-opts">
<button class="btn" id="follow"></button>
${offline ? '' : `<details class="menu" id="lmenu"><summary class="btn" id="lbtn" title="Añadir a tus listas">${icon('bookmark')} Listas</summary><div class="menu-list" id="lmlist"><div class="muted note">Cargando…</div></div></details>`}
${clipCount ? `<a class="btn" href="#/clips?manga=${encodeURIComponent(id)}">${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}</a>` : ''}
${menuItems}
</div>
<div class="libpanel"><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></div><div class="psec" id="off" hidden></div></div>
</div>
</div>
<div class="libpanel" id="mdata"><div class="psec"><div class="psec-title">${icon('book')} Datos de la obra</div><dl class="kv" id="mkv"></dl></div></div>
</aside>
<div class="list-head">
<h2>Capítulos <span class="muted">(${chapters.length})</span>${newSet.size ? ` <span class="badge">${newSet.size} ${newSet.size === 1 ? 'nuevo' : 'nuevos'}</span>` : ''}</h2>
<div class="row">
@@ -719,6 +712,54 @@ async function viewManga(id, langOverride) {
${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `<a class="tag" href="#" data-lang="${esc(l)}">${esc(LANGS[l] || l)}</a>`).join(' ')}` : 'No hay capítulos en ningún idioma.'}</p>`}
<div id="selbar" hidden></div>`
// --- Información de la obra: la guardada llega con la ficha; si aún no hay, se pide y se pinta al llegar.
const synBox = $('#msyn')
const paintInfo = d => {
if (!synBox.isConnected) return // ya se cambió de página
const parts = d?.found ? infoParts(d, manga) : null
const banner = $('#mbanner')
// El de AniList (&b=1: reescalado) o, si no tiene, uno de tus recortes; sin ninguno, nada.
const bannerUrl = parts?.banner ? `${parts.banner}&b=1` : clipBanner
banner.hidden = !bannerUrl
banner.style.backgroundImage = bannerUrl ? `url("${bannerUrl}")` : ''
if (!manga.cover && d?.cover) $('#mcover').src = d.cover
$('#msub').innerHTML = parts ? esc(parts.sub) : srcSub
$('#mscores').innerHTML = parts?.scores || ''
const folder = download ? `<dt>Carpeta</dt><dd>mangas/${esc(download.folder)}</dd>` : ''
$('#mkv').innerHTML = (parts?.kv || '') + folder
$('#mdata').hidden = !parts && !folder
$('#mtags').innerHTML = (srcTags || parts?.genres || '') + (parts?.softTags || '')
const srcDesc = (manga.description || '').trim()
synBox.innerHTML = parts?.synopsis || (srcDesc ? `<h2>Sinopsis</h2><p class="desc long">${esc(srcDesc)}</p>` : '')
$('#mextra').innerHTML = parts?.extra || (d ? `<p class="muted note">No se encontró esta obra en AniList ni en MangaUpdates. Puede tener otro título allí: prueba a buscarla en
<a class="tag" href="https://anilist.co/search/manga?search=${encodeURIComponent(manga.title)}" target="_blank" rel="noopener">AniList</a>.</p>` : '')
}
paintInfo(savedInfo)
if (!savedInfo) {
$('#mextra').innerHTML = '<p class="muted note">Buscando información de la obra…</p>'
api(`/api/meta/${id}`).then(paintInfo, () => { if (synBox.isConnected) $('#mextra').innerHTML = '' })
}
// --- Menú lateral (botón ⋯): seguir, listas, recortes, otras fuentes, copia local, reescalado HD, este dispositivo y datos de la obra.
const openLib = open => {
$('#libdrawer').classList.toggle('open', open)
$('#libdrawer').inert = !open
$('#libback').hidden = !open
}
// El ⋯ avisa con un punto si hay algo en curso (descarga, reescalado o guardado en el dispositivo).
const libBusy = {}
const markLib = (kind, busy) => {
libBusy[kind] = busy
$('#libbtn').innerHTML = `${icon('dots')}${Object.values(libBusy).some(Boolean) ? ' <span class="libbusy" title="En curso"></span>' : ''}`
}
const toChapters = () => $('.list-head')?.scrollIntoView({ behavior: 'smooth' })
$('#libbtn').addEventListener('click', () => openLib(true))
$('#libclose').addEventListener('click', () => openLib(false))
$('#libback').addEventListener('click', () => openLib(false))
$('#tochap')?.addEventListener('click', toChapters)
const onLibKey = e => { if (e.key === 'Escape') openLib(false) }
document.addEventListener('keydown', onLibKey)
const secHead = (ic, title, status) => `<div class="psec-head"><div class="psec-title">${icon(ic)} ${title}</div>${status ? `<div class="psec-status muted">${status}</div>` : ''}</div>`
// --- Selector de capítulos compartido por "Copia local" (kind 'dl') y "Reescalado HD" (kind 'hd'):
@@ -823,6 +864,7 @@ async function viewManga(id, langOverride) {
if (prevKind && prevKind !== next?.kind) renderSelForm(prevKind) // cerrar el otro formulario
if (next) renderSelForm(next.kind)
if (wasPick !== (next?.mode === 'pick') || (wasPick && prevKind !== next?.kind)) renderList()
if (next?.mode === 'pick' && !wasPick) { openLib(false); toChapters() } // se marca en la lista: hay que verla
$('#dlopen')?.classList.toggle('on', sel?.kind === 'dl')
$('#hdopen')?.classList.toggle('on', sel?.kind === 'hd')
$('#offopen')?.classList.toggle('on', sel?.kind === 'off')
@@ -867,6 +909,7 @@ async function viewManga(id, langOverride) {
const box = $('#dl')
if (!box) return
const active = d && DL_ACTIVE.includes(d.status)
markLib('dl', !!active)
const some = d?.scope === 'some'
const partial = d && d.done < d.total
const bar = d && (active || partial) ? `<div class="bar dlbar"><i style="width:${downloadPct(d).toFixed(1)}%"></i></div>` : ''
@@ -941,6 +984,7 @@ async function viewManga(id, langOverride) {
hdInfo = u
const show = !!(u?.available && u.downloaded && !offline)
box.hidden = !show
markLib('hd', show && (!!u.current || Object.values(u.jobs).some(s => s !== 'error')))
if (show) {
const queued = Object.values(u.jobs).filter(s => s !== 'error').length
const active = !!u.current || queued > 0
@@ -991,6 +1035,7 @@ async function viewManga(id, langOverride) {
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
markLib('off', !!(run || queued))
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))
@@ -1088,10 +1133,10 @@ async function viewManga(id, langOverride) {
const back = prevHash && !prevHash.startsWith('#/read/') && !prevHash.startsWith(`#/manga/${id}`) && prevHash !== '#/login'
location.hash = back ? prevHash : '#/'
})
// Cerrar el menú ⋯ al pulsar fuera.
// Cerrar el desplegable de listas al pulsar fuera.
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); offState.listeners.delete(onOff) }
cleanup = () => { clearTimeout(dlTimer); clearTimeout(hdTimer); document.removeEventListener('click', closeMenu); document.removeEventListener('keydown', onLibKey); 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)
@@ -1179,7 +1224,7 @@ async function viewManga(id, langOverride) {
updateSummary()
})
renderList()
if (!offline && serverOnline) api(`/api/meta/${id}/dates`).then(r => { extDates = r.dates || {}; if (Object.keys(extDates).length) { buildDates(); renderList() } }, () => {})
api(`/api/meta/${id}/dates`).then(r => { extDates = r.dates || {}; if (Object.keys(extDates).length) { buildDates(); renderList() } }, () => {})
renderDl(download)
renderHd(upscale)
renderOff()
@@ -2341,7 +2386,7 @@ async function viewLibrary(params) {
sub: `${plural(m.chapters.length, 'capítulo')} · ${fmtSize(m.bytes)}<br>${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.')
}), '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({
@@ -2475,7 +2520,7 @@ async function viewQueues() {
${rest ? `<div>${cur ? `${rest} más` : plural(rest, 'capítulo')} en cola</div>` : ''}`,
actions: `<button class="btn small" data-devcancel="${esc(g.mangaId)}">${icon('stop')} Cancelar</button>`,
})
}).join('')}</div>` : '<p class="muted">No se está guardando nada. Se añade desde la ficha («En este dispositivo») o desde el lector.</p>'}`
}).join('')}</div>` : '<p class="muted">No se está guardando nada. Se añade desde la ficha («⋯ → En este dispositivo») o desde el lector.</p>'}`
}
const html = q.offline ? `<h1>Colas</h1><p class="error">${esc(q.offline)}: no se pueden ver las colas del servidor.</p>
<section class="qsec" id="devq">${devHtml()}</section>` : `<h1>Colas</h1>
@@ -2696,7 +2741,7 @@ function viewSettings() {
<label class="row muted" style="font-size:.88rem"><input type="checkbox" id="pfhd"> Reescalarlos también a HD</label>
</div></section>
<section><b>${icon('phone')} App y lectura sin conexión</b>
${OFFLINE_OK ? `<p class="muted" style="margin:0;font-size:.88rem">Capítulos guardados en este dispositivo: <b id="offsum">…</b>. Se guardan desde la ficha de cada manga («En este dispositivo») o desde el lector.</p>
${OFFLINE_OK ? `<p class="muted" style="margin:0;font-size:.88rem">Capítulos guardados en este dispositivo: <b id="offsum">…</b>. Se guardan desde la ficha de cada manga («⋯ → En este dispositivo») o desde el lector.</p>
<div class="row" style="flex-wrap:wrap;gap:8px"><a class="btn small" href="#/library?tab=device">${icon('library')} Ver lo guardado</a>
<button class="btn small danger" id="offclear">${icon('trash')} Borrar todo del dispositivo</button>
<button class="btn small" id="install" hidden>${icon('download')} Instalar como app</button></div>`
+28 -8
View File
@@ -380,13 +380,33 @@ body.reading #netbanner { display: none; }
/* Info de la obra */
.chapters .meta .cdate { white-space: nowrap; }
.info-page { position: relative; }
.info-page .banner { height: 180px; margin: -16px -16px 16px; background: center / cover no-repeat; mask-image: linear-gradient(#000 40%, transparent); -webkit-mask-image: linear-gradient(#000 40%, transparent); opacity: .7; }
.info-page .info-top { margin-bottom: 14px; }
.info-page .banner + .info-top { position: absolute; top: 12px; left: 0; }
.info-page h2 { margin: 26px 0 10px; font-size: 1.1rem; }
.info-page .desc.long { max-height: none; }
.info-page .more summary { cursor: pointer; color: var(--muted); margin-top: 14px; }
.banner { height: 180px; margin: -16px -16px 16px; background: center / cover no-repeat; mask-image: linear-gradient(#000 40%, transparent); -webkit-mask-image: linear-gradient(#000 40%, transparent); opacity: .7; }
.banner[hidden] { display: none; }
.info-block h2 { margin: 26px 0 10px; font-size: 1.1rem; }
.detail-body .info-block h2:first-child { margin-top: 18px; }
.detail-body .info-block + .tags { margin-top: 14px; }
.drawer .kv { margin: 0; }
.info-block .desc.long { max-height: none; }
.info-block .more summary { cursor: pointer; color: var(--muted); margin-top: 14px; }
.info-block > .note { margin-top: 16px; }
.kv:empty, #mscores:empty { display: none; }
.hrow { display: grid; grid-auto-flow: column; grid-auto-columns: 130px; gap: 12px; justify-content: start; overflow-x: auto; padding-bottom: 8px; overscroll-behavior-x: contain; }
.list-head { scroll-margin-top: 64px; }
/* Menú lateral de la ficha (botón ⋯) */
.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 35; width: min(440px, 100%); padding: 14px 14px max(14px, env(safe-area-inset-bottom)); display: grid; align-content: start; gap: 12px;
overflow-y: auto; overscroll-behavior: contain; background: var(--bg); border-left: 1px solid var(--surface-2); box-shadow: -10px 0 28px rgba(0,0,0,.45);
transform: translateX(100%); visibility: hidden; transition: transform .22s ease, visibility .22s; }
.drawer.open { transform: none; visibility: visible; }
.drawer-head { display: flex; justify-content: space-between; align-items: center; font-size: 1.05rem; padding-top: env(safe-area-inset-top); }
.drawer-opts { display: grid; gap: 8px; }
.drawer-opts .btn { width: 100%; justify-content: flex-start; box-sizing: border-box; }
.drawer-opts .menu > summary { padding-left: 16px; padding-right: 16px; }
.drawer-opts .menu-list { position: static; margin-top: 6px; box-shadow: none; min-width: 0; }
.drawer .libpanel { margin-top: 0; max-width: none; min-width: 0; }
.drawer-back { position: fixed; inset: 0; z-index: 34; background: rgba(0,0,0,.5); touch-action: none; }
.drawer-back[hidden] { display: none; }
.libbusy { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: libpulse 1.2s ease-in-out infinite; }
@keyframes libpulse { 50% { opacity: .25; } }
.scores { display: flex; flex-wrap: wrap; gap: 10px; margin: 12px 0 6px; }
.score { background: var(--surface); border-radius: 10px; padding: 8px 12px; display: grid; }
.score b { font-size: 1.25rem; }
@@ -396,5 +416,5 @@ body.reading #netbanner { display: none; }
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.tag.soft { background: transparent; border: 1px solid var(--surface-2); }
.tag.hot { color: var(--accent); }
@media (max-width: 640px) { .kv { grid-template-columns: 1fr; gap: 0; } .kv dd { margin-bottom: 8px; } .info-page .banner { height: 120px; } }
@media (max-width: 640px) { .kv { grid-template-columns: 1fr; gap: 0; } .kv dd { margin-bottom: 8px; } .banner { height: 120px; } }
.chapters .meta .cdate.approx { opacity: .7; }
+20 -9
View File
@@ -460,14 +460,16 @@ const sourceOf = id => (id.startsWith('lc~') ? 'lc' : id.startsWith('mi~') ? 'mi
const spanishOnly = id => sourceOf(id) !== 'md'
// Proxy de imágenes: MangaDex bloquea el hotlinking de portadas y así el navegador no depende de CDNs ajenos.
// isCover: portada a servir en HD; coverish: portada sin HD (caché corta por si luego se sigue el manga).
async function proxyImage(req, res, u, s, isCover = false, coverish = false) {
// isCover: portada a servir en HD; coverish: portada sin HD (caché corta por si luego se sigue el manga);
// banner: imagen de fondo de la ficha, siempre en HD.
async function proxyImage(req, res, u, s, isCover = false, coverish = false, banner = false) {
let url
try { url = new URL(u) } catch { throw new HttpError(400, 'URL inválida') }
const expected = sign(u)
if (typeof s !== 'string' || s.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))) throw new HttpError(403, 'Firma inválida')
if (!/^https?:$/.test(url.protocol) || /^(localhost|\d+\.\d+\.\d+\.\d+|\[.*\])$/.test(url.hostname)) throw new HttpError(403, 'Host no permitido')
if (isCover && covers.sendCached(res, u)) return
const hdKey = banner ? bannerKey(u) : isCover ? u : null
if (hdKey && covers.sendCached(res, hdKey)) return
// Los CDN de las fuentes fallan a ratos (520, conexión cortada, respuestas que no llegan): hasta 3
// intentos con tiempo límite, para no dejar al navegador esperando para siempre.
net.guard() // sin internet: fallar ya (la portada local entra como respaldo), sin reintentos
@@ -477,11 +479,11 @@ async function proxyImage(req, res, u, s, isCover = false, coverish = false) {
return r
}).catch(e => { throw e instanceof HttpError ? e : new HttpError(502, `Imagen: ${e.message}`) })
if (!r.ok || !r.body) throw new HttpError(502, `Imagen ${r.status}`)
if (isCover) {
if (hdKey) {
const buf = Buffer.from(await r.arrayBuffer())
res.writeHead(200, { 'Content-Type': r.headers.get('content-type') || 'image/jpeg', 'Cache-Control': covers.pendingCache() })
res.writeHead(200, { 'Content-Type': r.headers.get('content-type') || 'image/jpeg', 'Cache-Control': covers.pendingCache(banner) })
res.end(buf)
return covers.enqueue(u, buf)
return covers.enqueue(hdKey, buf, banner)
}
res.writeHead(200, {
'Content-Type': r.headers.get('content-type') || 'image/jpeg',
@@ -491,6 +493,15 @@ async function proxyImage(req, res, u, s, isCover = false, coverish = false) {
res.end()
}
const bannerKey = u => `banner:${u}`
// Reescala un banner sin esperar a que alguien abra la ficha (recibe la URL del proxy, como se guarda).
async function prepareBanner(proxyUrl) {
const u = rawFromProxy(proxyUrl)
if (!u || !covers.wants(bannerKey(u))) return
const r = await fetchImage(new URL(u), AbortSignal.timeout(60e3))
if (r.ok) covers.enqueue(bannerKey(u), Buffer.from(await r.arrayBuffer()), true)
}
function fetchImage(url, signal) {
net.guard()
const host = new URL(url).hostname
@@ -890,7 +901,7 @@ async function route(req, res) {
if (p === '/api/img' && method === 'GET') {
const mid = MANGA_ID.test(q.get('m') || '') ? q.get('m') : null
try {
return await proxyImage(req, res, q.get('u') || '', q.get('s'), !!mid && tracked(user.id, mid), q.has('m'))
return await proxyImage(req, res, q.get('u') || '', q.get('s'), !!mid && tracked(user.id, mid), q.has('m'), q.has('b'))
} catch (e) {
// Portada sin conexión con la fuente: la cover.jpg de la copia local, si la hay.
if (mid && !res.headersSent && e.status !== 403) {
@@ -940,7 +951,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 !== 'md' || offline ? [] : await getChapterLangs(m[1]).catch(() => [])
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), ...features.mangaExtras(user.id, manga) })
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), info: meta.cachedInfo(m[1]), clipBanner: clips.bannerFor(user.id, m[1]), ...features.mangaExtras(user.id, manga) })
}
if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') {
const hdAvailable = !!upscale.hdFileOf(m[1])
@@ -1075,7 +1086,7 @@ const covers = setupCovers({ DATA_DIR, upscale })
const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, fetchMangaFull, enqueueDownload, linkDownload, upscale, stats: features.stats, searchManga, mdCategories, net })
// Al volver internet: reanudar las descargas en cola.
net.whenOnline(() => pumpDownloads())
const meta = setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA })
const meta = setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA, prepareBanner })
const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale })
// Migración: lo descargado antes de que hubiera dueños pasa a quien lo lee, lo sigue o lo tiene en una