Vista Info de la obra (AniList y MangaUpdates) y fechas de los capítulos

- Info: autores, revista y editorial, fechas, tomos, puntuaciones, rankings, anime, obras relacionadas,
  recomendaciones y enlaces. La obra se identifica por los enlaces de MangaDex o por título.
- Fechas de los capítulos: primera traducción conocida (MangaDex en cualquier idioma y MangaUpdates),
  ordenadas y con los huecos estimados (≈); se ignoran las fechas de subidas masivas de las fuentes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 13:32:23 -06:00
co-authored by Claude Opus 5.5
parent 29af893917
commit e66b5fea95
5 changed files with 412 additions and 1 deletions
+124 -1
View File
@@ -44,6 +44,7 @@ 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>`
@@ -66,6 +67,11 @@ const FILTERS = {
const DL_ACTIVE = ['queued', 'downloading']
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
const fmtDur = sec => sec < 90 ? '≈ 1 min' : sec < 3600 ? `≈ ${Math.round(sec / 60)} min` : `≈ ${Math.floor(sec / 3600)} h ${Math.round((sec % 3600) / 60)} min`
const numKey = n => { const f = parseFloat(n); return Number.isFinite(f) ? String(f) : null }
const fmtDate = d => {
const t = new Date(`${String(d).slice(0, 10)}T12:00:00`)
return Number.isNaN(t.getTime()) ? '' : t.toLocaleDateString('es', { day: 'numeric', month: 'short', year: 'numeric' })
}
const fmtSize = b => (b >= 1e9 ? `${(b / 1e9).toFixed(1)} GB` : b >= 1e6 ? `${Math.round(b / 1e6)} MB` : `${Math.round((b || 0) / 1e3)} KB`)
// Progreso global contando la fracción del capítulo en curso.
function downloadPct(d) {
@@ -359,6 +365,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] === '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)
@@ -578,6 +585,78 @@ async function viewHome(params) {
}
// ---------- Ficha del manga ----------
// ---------- Info: datos de la obra de AniList y MangaUpdates ----------
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' }
const AL_ROLE = r => r.replace(/\bchs?\b/g, 'caps.').replace(/^Story & Art/i, 'Historia y dibujo').replace(/^Original Creator/i, 'Creador original').replace(/^Original Story/i, 'Historia original').replace(/^Story/i, 'Historia').replace(/^Art/i, 'Dibujo')
const AL_REL = { SEQUEL: 'Secuela', PREQUEL: 'Precuela', SIDE_STORY: 'Historia paralela', SPIN_OFF: 'Spin-off', ALTERNATIVE: 'Versión alternativa', PARENT: 'Obra principal', ADAPTATION: 'Adaptación', SOURCE: 'Original', CHARACTER: 'Personajes', SUMMARY: 'Resumen', COMPILATION: 'Recopilación', CONTAINS: 'Contiene', OTHER: 'Relacionado' }
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
}
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)}»">
<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">
${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('')}
${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('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>` : ''}
${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>`
}
async function viewManga(id, langOverride) {
if (cleanup) { cleanup(); cleanup = null }
app.innerHTML = '<div class="loading">Cargando…</div>'
@@ -616,6 +695,7 @@ async function viewManga(id, langOverride) {
<p class="desc">${esc(manga.description)}</p>
<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>` : ''}
</div>
@@ -1017,7 +1097,49 @@ async function viewManga(id, langOverride) {
+ (hdSet.has(c.id) ? '<span class="hd" title="Reescalado a HD">HD</span> '
: hdJobs[c.id] ? `<span class="hd pending" title="${hdJobs[c.id] === 'error' ? 'Falló el reescalado' : 'En cola para HD'}">HD${hdJobs[c.id] === 'error' ? '!' : '…'}</span> ` : '')
+ (OFFLINE_OK && offHas(c.id) ? `<span class="dev" title="Guardado en este dispositivo">${icon('phone')}</span> ` : '')
+ (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group))
+ [r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : manga.source === 'md' ? esc(c.group) : '', chapDate(c)].filter(Boolean).join(' · ')
// Fecha de cada capítulo: la primera publicación conocida (MangaDex/MangaUpdates, se pide aparte) o, si
// no, la de la fuente, salvo que sea la de una subida masiva (la misma fecha en muchos capítulos).
let extDates = {}
const srcDatesOk = (() => {
const ds = chapters.map(c => c.publishAt?.slice(0, 10)).filter(Boolean)
const n = {}
for (const d of ds) n[d] = (n[d] || 0) + 1
return ds.length < 8 || Math.max(...Object.values(n)) / ds.length <= 0.25
})()
// Fechas por capítulo: { d, approx } por id. Un capítulo no puede ser posterior al siguiente (las
// traducciones a veces llegan desordenadas) y los huecos entre dos fechas conocidas se estiman.
let dateMap = new Map()
function buildDates() {
const DAY = 864e5
const known = chapters.map(c => {
const d = extDates[numKey(c.num)] || (srcDatesOk ? c.publishAt?.slice(0, 10) : null)
const t = d ? Date.parse(`${d}T12:00:00Z`) : NaN
return { c, n: parseFloat(c.num), t: Number.isNaN(t) ? null : t }
}).filter(x => Number.isFinite(x.n)).sort((a, b) => a.n - b.n)
let min = Infinity
for (let i = known.length - 1; i >= 0; i--) if (known[i].t != null) { min = Math.min(min, known[i].t); known[i].t = min }
dateMap = new Map()
let prev = null
known.forEach((x, i) => {
if (x.t == null) return
dateMap.set(x.c.id, { t: x.t, approx: false })
if (prev && i - prev.i > 1 && i - prev.i <= 150) {
for (let k = prev.i + 1; k < i; k++) {
const f = (known[k].n - prev.n) / (x.n - prev.n)
dateMap.set(known[k].c.id, { t: Math.round((prev.t + f * (x.t - prev.t)) / DAY) * DAY, approx: true })
}
}
prev = { i, n: x.n, t: x.t }
})
}
buildDates()
const chapDate = c => {
const e = dateMap.get(c.id)
if (!e) return ''
const d = new Date(e.t).toISOString().slice(0, 10)
return `<span class="cdate${e.approx ? ' approx' : ''}" title="${e.approx ? 'Fecha estimada (entre capítulos con fecha conocida)' : 'Primera publicación conocida'}">${e.approx ? '≈ ' : ''}${fmtDate(d)}</span>`
}
function renderList() {
if (!ul) return
const list = desc ? [...chapters].reverse() : chapters
@@ -1053,6 +1175,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() } }, () => {})
renderDl(download)
renderHd(upscale)
renderOff()