'use strict'; /* Manga overview: complete metadata, volume filters and newest-first chapters. */ (async function () { const id = new URLSearchParams(location.search).get('m') || ''; let me, M; try { [me, M] = await Promise.all([api('api/me'), api('api/manga?' + qsa({ m: id }))]); } catch (e) { return showError(e); } topNav(me); document.title = M.title + ' · MangaCircus'; const meta = M.meta || {}, main = $('main'); main.textContent = ''; const TYPE = { rtl: 'Manga · right to left', ltr: 'Comic · left to right', ttb: 'Webtoon · vertical strip' }; const arr = x => (Array.isArray(x) ? x : x ? [x] : []); const text = value => { if (value == null || value === '') return ''; if (Array.isArray(value)) return value.map(text).filter(Boolean).join(', '); if (typeof value === 'object') return JSON.stringify(value); return String(value); }; const releaseDate = () => { const raw = meta.firstReleaseDate || meta.releaseDate || meta.startDate; if (!raw) return text(meta.year); if (typeof raw !== 'object') return text(raw); return [raw.year, raw.month && String(raw.month).padStart(2, '0'), raw.day && String(raw.day).padStart(2, '0')].filter(Boolean).join('-'); }; const follow = h('button', { class: 'btn' }, ''); const drawFollow = () => { follow.textContent = M.following ? '🔔 Notifying you' : 'Notify me about new chapters'; follow.classList.toggle('on', M.following); }; follow.onclick = async () => { M.following = (await api('api/follow', { m: M.id, on: !M.following })).following; drawFollow(); }; drawFollow(); const sources = arr(meta.sources).filter(source => source && (source.name || source.url)); const tags = arr(meta.tags).filter(Boolean); const numberedChapters = M.chapters.map(chapter => /^Ch\. (\d+(?:\.\d+)?)/.exec(chapter.label || '')) .filter(Boolean).map(match => Number(match[1])); const firstNumber = numberedChapters.length ? Math.min(...numberedChapters) : null; const books = {}; for (const book of M.books) books[book.key] = book; const selectedBooks = new Set(); main.append(h('section', { class: 'hero' }, M.cover ? h('img', { src: M.cover, alt: '' }) : null, h('div', { class: 'info' }, h('h1', {}, M.title), arr(meta.altTitles).length ? h('div', { class: 'muted' }, arr(meta.altTitles).join(' · ')) : null, h('div', { class: 'muted' }, [arr(meta.authors).join(', '), meta.status, releaseDate(), TYPE[M.direction]].filter(Boolean).join(' · ')), firstNumber > 1 ? h('div', { class: 'coverage-indicators', role: 'status' }, h('span', { class: 'coverage-badge coverage-partial' }, 'Series coverage partial'), h('span', { class: 'small' }, `This library starts at chapter ${firstNumber}; earlier numbered chapters are absent.`)) : null, h('div', { class: 'chips' }, tags.map(tag => h('a', { class: 'chip chip-link', href: libraryUrlForTags([tag]), title: `Show manga tagged ${tag}` }, tag))), meta.description ? h('p', { class: 'desc' }, meta.description) : null, h('div', { class: 'actions' }, M.continue ? h('a', { class: 'btn primary', href: readUrl(M.id, M.continue.c, M.continue.v) }, M.continue.text) : null, follow)))); let storyRhythm = null; if (M.collections?.length || M.relations?.length) main.append(h('section', { class: 'card' }, h('h2', {}, 'Collections & related manga'), h('div', { class: 'chips' }, (M.collections || []).map(c => h('a', { class: 'btn', href: 'collection.html?' + qsa({ id: c.id }) }, c.title))), (M.relations || []).map(r => h('p', {}, h('a', { href: mangaUrl(r.otherId) }, r.otherTitle), ` · ${r.from === M.id ? 'target is ' : 'this manga is '}${r.type.replaceAll('-', ' ')}`, h('details', {}, h('summary', {}, 'Evidence'), h('p', { class: 'muted' }, r.explanation), r.evidence.map(e => h('a', { class: 'btn small', href: safeHref(e.url), target: '_blank', rel: 'noopener' }, e.provider || 'Source'))))))); if (M.reading && M.reading.activeMs > 0) { const lastChapter = M.chapters.find(chapter => chapter.id === M.reading.lastChapterId); storyRhythm = h('div', { class: 'story-rhythm', role: 'list', 'aria-label': 'Completed chapter reading times' }); const openKey = `reading-story-open.${me.name}`; const story = h('details', { class: 'reading-story card', open: store.get(openKey) !== '0' }, h('summary', { class: 'reading-story-summary' }, h('span', {}, h('span', { 'aria-hidden': 'true' }, '⏱ '), h('b', {}, 'Your Reading Story')), h('span', { class: 'muted small' }, readingDuration(M.reading.activeMs) + ' together')), h('div', { class: 'story-stats' }, storyStat(readingDuration(M.reading.activeMs), 'time together'), storyStat(M.reading.timedChapters, `chapter${M.reading.timedChapters === 1 ? '' : 's'} timed`), storyStat(M.reading.typicalChapterMs ? readingDuration(M.reading.typicalChapterMs) : '—', 'typical chapter'), storyStat(M.reading.rereads, `reread${M.reading.rereads === 1 ? '' : 's'}`)), lastChapter ? h('p', { class: 'story-last muted' }, 'Last timed ', h('a', { href: readUrl(M.id, lastChapter.id) }, `${lastChapter.label}${lastChapter.title ? ' · ' + lastChapter.title : ''}`), ` · ${ago(M.reading.updatedAt)}`) : null, h('div', { class: 'story-rhythm-head' }, h('b', {}, 'Reading rhythm'), h('span', { class: 'muted small' }, 'Completed chapters · active time')), storyRhythm, h('div', { class: 'story-actions' }, h('span', { class: 'muted small' }, 'Only you can see these measurements.'), h('button', { class: 'btn small', onclick: async () => { if (!confirm(`Forget all reading time recorded for ${M.title}? Reading progress is kept.`)) return; await api('api/reading/delete', { m: M.id }); location.reload(); } }, 'Forget this manga’s time'))); story.addEventListener('toggle', () => store.set(openKey, story.open ? '1' : '0')); main.append(story); drawStoryRhythm(); } function storyStat(value, label) { return h('div', { class: 'story-stat' }, h('strong', {}, value), h('span', { class: 'muted small' }, label)); } function drawStoryRhythm() { if (!storyRhythm) return; const chapters = M.chapters.filter(chapter => !selectedBooks.size || selectedBooks.has(chapter.book)); const completed = chapters.map(chapter => chapter.reading?.lastCompleteMs || 0).filter(Boolean); const scale = Math.max(1, ...completed.map(value => Math.log1p(value))); storyRhythm.textContent = ''; for (const chapter of chapters) { const value = chapter.reading?.lastCompleteMs || 0; const label = `${chapter.label}${chapter.title ? ' · ' + chapter.title : ''}`; if (!value) { storyRhythm.append(h('span', { class: 'rhythm-bar empty', role: 'listitem', title: `${label} · Not timed`, 'aria-label': `${label}, not timed` })); continue; } const height = Math.max(12, Math.round(12 + 48 * Math.log1p(value) / scale)); const passes = chapter.reading.completedPasses; const title = `${label} · ${readingDuration(value)}${passes > 1 ? ` · ${passes} timed reads · ${readingDuration(chapter.reading.totalActiveMs)} total` : ''}`; storyRhythm.append(h('a', { class: 'rhythm-bar', role: 'listitem', href: readUrl(M.id, chapter.id), title, 'aria-label': title, style: `height:${height}px`, })); } if (!chapters.length) storyRhythm.append(h('span', { class: 'muted small' }, 'No chapters in the selected volumes.')); } const metadata = h('dl', { class: 'meta-grid' }); function addMeta(label, value) { if (value == null || value === '' || (Array.isArray(value) && !value.length)) return; metadata.append(h('dt', {}, label), h('dd', {}, value instanceof Node ? value : text(value))); } addMeta('Title', meta.title || M.title); addMeta('Alternative titles', arr(meta.altTitles)); addMeta('Authors', arr(meta.authors)); addMeta('Status', meta.status); addMeta('First release', releaseDate()); addMeta('Format', meta['x-anilist'] && meta['x-anilist'].format); addMeta('Reading style', TYPE[M.direction] || M.direction); addMeta('Volumes', M.books.length); addMeta('Chapters', M.chapters.length); addMeta('Library ID', M.id); addMeta('Tags', h('span', { class: 'meta-tags' }, tags.map(tag => h('a', { class: 'chip chip-link', href: libraryUrlForTags([tag]) }, tag)))); if (sources.length) addMeta('Content sources', h('span', { class: 'meta-links' }, sources.map(source => { const label = source.name || source.url; return safeHref(source.url) ? h('a', { href: safeHref(source.url), target: '_blank', rel: 'noopener noreferrer' }, label) : h('span', {}, label); }))); const anilist = meta['x-anilist'] || {}; if (anilist.url || anilist.id) addMeta('AniList', safeHref(anilist.url) ? h('a', { href: safeHref(anilist.url), target: '_blank', rel: 'noopener noreferrer' }, anilist.id ? `Title ${anilist.id}` : 'Open title') : anilist.id); if (anilist.idMal) addMeta('MyAnimeList', h('a', { href: `https://myanimelist.net/manga/${encodeURIComponent(anilist.idMal)}`, target: '_blank', rel: 'noopener noreferrer' }, `Title ${anilist.idMal}`)); if (anilist.coverUrl) addMeta('Metadata cover', safeHref(anilist.coverUrl) ? h('a', { href: safeHref(anilist.coverUrl), target: '_blank', rel: 'noopener noreferrer' }, 'Open source image') : anilist.coverUrl); const cover = meta['x-cover'] || {}; addMeta('Cover provider', [cover.source, cover.label].filter(Boolean).join(' · ')); if (cover.url) addMeta('Cover source', safeHref(cover.url) ? h('a', { href: safeHref(cover.url), target: '_blank', rel: 'noopener noreferrer' }, cover.url) : cover.url); const scraper = meta['x-scraper'] || {}; addMeta('Auto update', typeof scraper.autoUpdate === 'boolean' ? (scraper.autoUpdate ? 'Enabled' : 'Disabled') : ''); addMeta('Scraper strategy', scraper.strategy); addMeta('Source identifiers', scraper.ids); const handled = new Set(['title', 'altTitles', 'authors', 'status', 'year', 'firstReleaseDate', 'releaseDate', 'startDate', 'tags', 'description', 'direction', 'sources', 'x-anilist', 'x-cover', 'x-scraper']); for (const [key, value] of Object.entries(meta)) if (!handled.has(key)) addMeta(key, value); const rawMeta = h('details', { class: 'raw-meta' }, h('summary', {}, 'Raw metadata'), h('pre', {}, JSON.stringify(meta, null, 2))); const metadataOpen = !window.matchMedia('(max-width: 640px)').matches; main.append(h('details', { class: 'metadata card', open: metadataOpen }, h('summary', { class: 'metadata-summary' }, h('h2', {}, 'Metadata')), metadata, rawMeta)); const volumeButtons = new Map(); const volumeBar = h('div', { class: 'volume-filter', 'aria-label': 'Filter chapters by volume' }); const allVolumes = h('button', { class: 'filter-chip', type: 'button' }, 'All volumes'); allVolumes.onclick = () => { selectedBooks.clear(); drawVolumeFilter(); drawChapters(); drawStoryRhythm(); }; volumeBar.append(allVolumes); for (const book of M.books) { const label = book.label + (book.title ? ' · ' + book.title : ''); const button = h('button', { class: 'filter-chip', type: 'button', title: label }, label); button.onclick = () => { selectedBooks.has(book.key) ? selectedBooks.delete(book.key) : selectedBooks.add(book.key); drawVolumeFilter(); drawChapters(); drawStoryRhythm(); }; volumeButtons.set(book.key, button); volumeBar.append(button); } const list = h('section', { class: 'chapters' }); const empty = h('p', { class: 'muted chapter-empty', hidden: true }, 'No chapters match these volumes.'); main.append(h('section', { class: 'chapter-section' }, h('div', { class: 'chapter-heading' }, h('h2', {}, 'Chapters'), h('span', { class: 'muted small' }, 'Newest first')), volumeBar, h('div', { class: 'chapter-list-wrap' }, list), empty)); function drawVolumeFilter() { allVolumes.classList.toggle('on', !selectedBooks.size); allVolumes.setAttribute('aria-pressed', String(!selectedBooks.size)); for (const [key, button] of volumeButtons) { button.classList.toggle('on', selectedBooks.has(key)); button.setAttribute('aria-pressed', String(selectedBooks.has(key))); } } function drawChapters() { list.textContent = ''; const chapters = [...M.chapters].reverse().filter(chapter => !selectedBooks.size || selectedBooks.has(chapter.book)); empty.hidden = !!chapters.length; let lastBook = null; for (const chapter of chapters) { if (chapter.book !== lastBook && M.books.length > 1) { const book = books[chapter.book]; list.append(h('div', { class: 'book-sep' }, book.label + (book.title ? ' · ' + book.title : ''))); } lastBook = chapter.book; list.append(chapterRow(chapter)); } } function chapterRow(ch) { const wrap = h('div', { class: 'chapter-entry' }); const panel = h('div', { class: 'versions', hidden: true }); function draw() { const p = ch.progress, done = p && p.done; const readAt = done ? readDate(ch.readAt) : null; const state = h('button', { class: 'state' + (done ? ' done' : ''), title: (readAt ? readAt.title + ' · ' : '') + (done ? 'Mark as unread' : 'Mark as read') }, done ? (readAt ? '✓ ' + readAt.text : '✓ read') : p ? `page ${p.p + 1} / ${p.n}` : 'unread'); state.onclick = async () => { if (done) { await api('api/unread', { m: M.id, c: ch.id }); ch.progress = null; ch.readAt = null; } else { const version = ch.versions[0]; await api('api/progress', { m: M.id, c: ch.id, v: version.id, p: version.pages - 1, n: version.pages, done: true }); ch.progress = { v: version.id, p: version.pages - 1, n: version.pages, done: true }; ch.readAt = { at: Date.now(), approx: false }; } draw(); }; const verBtn = ch.versions.length > 1 ? h('button', { class: 'btn small', onclick: () => { panel.hidden = !panel.hidden; } }, ch.versions.length + ' versions') : null; const pages = ch.versions[0] ? ch.versions[0].pages : 0; const timing = chapterTiming(ch), released = chapterRelease(ch.dates); wrap.textContent = ''; wrap.append(h('div', { class: 'ch' + (done ? ' read' : '') }, h('a', { class: 'ch-main' + (timing ? ' has-time' : '') + (M.chapterDates?.release ? ' has-date' : ''), href: readUrl(M.id, ch.id) }, h('span', { class: 'ch-label' }, ch.label), h('span', { class: 'ch-pages' }, `${pages} p.`), M.chapterDates?.release ? h('span', { class: 'ch-date' + (released ? ' tier-' + released.tier : ''), title: released ? released.title : 'No date known', 'aria-label': released ? released.title : 'No date known' }, released ? released.text : '–') : null, timing ? h('span', { class: 'ch-time', title: timing.title, 'aria-label': timing.title }, timing.text) : null, h('span', { class: 'ch-title', title: ch.title || '' }, ch.title || 'Untitled'), ch.extra ? h('span', { class: 'tag' }, 'extra') : null), verBtn, state), panel); drawVersions(); } function drawVersions() { panel.textContent = ''; panel.append(h('div', { class: 'muted small', style: 'padding-top:6px' }, 'The best rated version opens by default. Vote to change that for everyone.')); ch.versions.forEach((version, i) => { const vote = async value => { ch.versions = (await api('api/vote', { m: M.id, c: ch.id, v: version.id, value: version.my === value ? 0 : value })).versions; drawVersions(); }; panel.append(h('div', { class: 'ver' }, h('div', { class: 'src' }, safeHref(version.sourceUrl) ? h('a', { href: safeHref(version.sourceUrl), target: '_blank', rel: 'noopener noreferrer' }, version.source || version.id) : h('span', {}, version.source || version.id), i === 0 ? h('span', { class: 'tag', style: 'margin-left:8px' }, 'default') : null, h('div', { class: 'muted small' }, `${version.pages} pages · added ${new Date(version.added).toLocaleDateString()}`)), h('span', { class: 'vote' }, h('button', { class: version.my > 0 ? 'on' : '', title: 'Better', onclick: () => vote(1) }, '▲'), h('b', {}, version.score), h('button', { class: version.my < 0 ? 'on' : '', title: 'Worse', onclick: () => vote(-1) }, '▼')), h('a', { class: 'btn small', href: readUrl(M.id, ch.id, version.id) }, 'Read'))); }); } draw(); return wrap; } // Phones (the chapter-row breakpoint in app.css) get numeric dates to leave room for titles. const compact = matchMedia('(max-width: 640px)').matches; // Read date: the day and the hour (local time) the chapter was first completed. function readDate(value) { const mode = M.chapterDates?.read; if (!value || !value.at || mode === 'off') return null; const when = new Date(value.at), thisYear = when.getFullYear() === new Date().getFullYear(); const day = when.toLocaleDateString(undefined, compact ? { day: '2-digit', month: '2-digit', ...(thisYear ? {} : { year: '2-digit' }) } : { day: 'numeric', month: 'short', ...(thisYear ? {} : { year: 'numeric' }) }); const text = (value.approx ? '≤ ' : '') + day + (mode === 'hour' ? `, ${String(when.getHours()).padStart(2, '0')}h` : ''); const full = when.toLocaleDateString(undefined, { dateStyle: 'long' }) + (mode === 'hour' ? `, ${String(when.getHours()).padStart(2, '0')}:00–${String((when.getHours() + 1) % 24).padStart(2, '0')}:00` : ''); return { text, title: (value.approx ? 'Read by ' : 'Read on ') + full + (value.approx ? ' (earlier reads were not recorded)' : '') }; } // Release date by tier: official > source upload > Manga Circus ecosystem > this library. const TIER = { official: { mark: '' }, source: { mark: '≈' }, ecosystem: { mark: '◎' }, library: { mark: '↓' } }; const dayText = (at, precision, long) => { // Day-precision dates are calendar dates, not instants: show them in UTC so they do not shift. const dayOnly = !String(at).includes('T'); const opts = precision === 'year' ? { year: 'numeric' } : precision === 'month' ? { year: 'numeric', month: 'short' } : long ? { dateStyle: 'long' } : compact ? { day: '2-digit', month: '2-digit', year: '2-digit' } : { day: 'numeric', month: 'short', year: 'numeric' }; return new Date(at).toLocaleDateString(undefined, { ...opts, ...(dayOnly ? { timeZone: 'UTC' } : {}) }); }; function chapterRelease(dates) { if (!dates || !dates.release) return null; const known = dates.known || {}, lines = []; if (known.official) lines.push(`Released ${dayText(known.official.at, known.official.precision, true)}${known.official.name ? ' (' + known.official.name + ')' : ''}`); if (known.source) lines.push(`Uploaded at ${known.source.name || 'the source'} ${dayText(known.source.at, 'day', true)}`); if (known.batch) lines.push(`Bulk upload at ${known.batch.name || 'the source'} ${dayText(known.batch.at, 'day', true)} (not a release date)`); if (known.ecosystem) lines.push(`First in Manga Circus ${dayText(known.ecosystem.at, 'day', true)}${known.ecosystem.name && known.ecosystem.name !== 'this server' ? ' (via ' + known.ecosystem.name + ')' : ''}`); if (known.library) lines.push(`Added to this library ${dayText(known.library.at, 'day', true)}`); const tier = TIER[dates.tier] || TIER.library; return { tier: dates.tier, text: tier.mark + dayText(dates.release.at, dates.release.precision), title: lines.join('\n') }; } function chapterTiming(chapter) { if (!M.reading) return null; const value = chapter.reading; if (!value) return { text: 'Not timed', title: 'No reading time recorded' }; if (value.completedPasses) { const text = readingDuration(value.lastCompleteMs, true) + (value.completedPasses > 1 ? ` ↻${value.completedPasses}` : ''); return { text, title: `Last timed read ${readingDuration(value.lastCompleteMs)}; ${value.completedPasses} timed read${value.completedPasses === 1 ? '' : 's'}; ${readingDuration(value.totalActiveMs)} total`, }; } const active = value.currentPassMs || value.totalActiveMs; return { text: `◷ ${readingDuration(active, true)}`, title: `${readingDuration(active)} active; current pass in progress` }; } drawVolumeFilter(); drawChapters(); })();