Consolidate MangaCircus app, scraper, and Compose baseline
This commit is contained in:
1 parent
cfb2d79103
commit
eef4ffa07c
103 files changed
+9316
-172
No files matched your search
+211
-31
@@ -1,5 +1,5 @@
|
||||
'use strict';
|
||||
/* Manga page: info, follow toggle and the chapter list (books are just separators). */
|
||||
/* Manga overview: complete metadata, volume filters and newest-first chapters. */
|
||||
(async function () {
|
||||
const id = new URLSearchParams(location.search).get('m') || '';
|
||||
let me, M;
|
||||
@@ -11,63 +11,225 @@
|
||||
|
||||
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(s => s && s.name);
|
||||
const sources = arr(meta.sources).filter(source => source && (source.name || source.url));
|
||||
const tags = arr(meta.tags).filter(Boolean);
|
||||
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, meta.year, TYPE[M.direction]].filter(Boolean).join(' · ')),
|
||||
h('div', { class: 'chips' }, arr(meta.tags).map(t => h('span', { class: 'chip' }, t))),
|
||||
h('div', { class: 'muted' }, [arr(meta.authors).join(', '), meta.status, releaseDate(), TYPE[M.direction]].filter(Boolean).join(' · ')),
|
||||
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,
|
||||
sources.length ? h('div', { class: 'sources small' }, h('span', { class: 'muted' }, 'Sources: '),
|
||||
sources.map(s => (safeHref(s.url) ? h('a', { href: safeHref(s.url), target: '_blank', rel: 'noopener noreferrer' }, s.name) : h('span', {}, s.name + ' ')))) : 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))));
|
||||
|
||||
const books = {};
|
||||
for (const b of M.books) books[b.key] = b;
|
||||
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' });
|
||||
main.append(list);
|
||||
let lastBook = null;
|
||||
for (const ch of M.chapters) {
|
||||
if (ch.book !== lastBook && M.books.length > 1) {
|
||||
const b = books[ch.book];
|
||||
list.append(h('div', { class: 'book-sep' }, b.label + (b.title ? ' · ' + b.title : '')));
|
||||
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));
|
||||
}
|
||||
lastBook = ch.book;
|
||||
list.append(chapterRow(ch));
|
||||
}
|
||||
|
||||
function chapterRow(ch) {
|
||||
const wrap = h('div', {});
|
||||
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 state = h('button', { class: 'state' + (done ? ' done' : ''), title: done ? 'Mark as unread' : 'Mark as read' },
|
||||
done ? '✓ read' : p ? `page ${p.p + 1} / ${p.n}` : `${ch.versions[0].pages} p.`);
|
||||
done ? '✓ 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; }
|
||||
else {
|
||||
const v = ch.versions[0];
|
||||
await api('api/progress', { m: M.id, c: ch.id, v: v.id, p: v.pages - 1, n: v.pages, done: true });
|
||||
ch.progress = { v: v.id, p: v.pages - 1, n: v.pages, done: true };
|
||||
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 };
|
||||
}
|
||||
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);
|
||||
wrap.textContent = '';
|
||||
wrap.append(h('div', { class: 'ch' + (done ? ' read' : '') },
|
||||
h('a', { class: 'ch-main', href: readUrl(M.id, ch.id) },
|
||||
h('span', { class: 'ch-label' }, ch.label), h('span', { class: 'ch-title' }, ch.title || ''),
|
||||
h('a', { class: 'ch-main' + (timing ? ' has-time' : ''), href: readUrl(M.id, ch.id) },
|
||||
h('span', { class: 'ch-label' }, ch.label),
|
||||
h('span', { class: 'ch-pages' }, `${pages} p.`),
|
||||
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();
|
||||
@@ -75,21 +237,39 @@
|
||||
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((v, i) => {
|
||||
const vote = async value => { ch.versions = (await api('api/vote', { m: M.id, c: ch.id, v: v.id, value: v.my === value ? 0 : value })).versions; drawVersions(); };
|
||||
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(v.sourceUrl) ? h('a', { href: safeHref(v.sourceUrl), target: '_blank', rel: 'noopener noreferrer' }, v.source || v.id) : h('span', {}, v.source || v.id),
|
||||
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' }, `${v.pages} pages · added ${new Date(v.added).toLocaleDateString()}`)),
|
||||
h('div', { class: 'muted small' }, `${version.pages} pages · added ${new Date(version.added).toLocaleDateString()}`)),
|
||||
h('span', { class: 'vote' },
|
||||
h('button', { class: v.my > 0 ? 'on' : '', title: 'Better', onclick: () => vote(1) }, '▲'),
|
||||
h('b', {}, v.score),
|
||||
h('button', { class: v.my < 0 ? 'on' : '', title: 'Worse', onclick: () => vote(-1) }, '▼')),
|
||||
h('a', { class: 'btn small', href: readUrl(M.id, ch.id, v.id) }, 'Read')));
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
})();
|
||||
Reference in new issue
Block a user