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:
+124
-1
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user