MangaParty: self-hosted manga/comic/webtoon reader
Zero-dependency Node server + plain HTML/JS frontend. - Library is a folder tree (Manga/Book/Chapter/pages), indexed in place; page roles (cover, double, extra, rotation) are file name tags - Accounts with per-user progress, reading settings, follows and new-chapter notifications (in-app + optional webhook) - Reader with Flow, Page and Book modes, pointer-anchored page zoom, tap zones, auto-hiding controls, chapter picker, page preloading - Multiple versions per chapter with community votes - Docker image and compose file - Demo library generator (tools/make-dummy.js) - Docs: architecture, library format, import, scraper guide
This commit is contained in:
commit
8dd776b7f4
29 files changed
+3499
No files matched your search
@@ -0,0 +1,95 @@
|
||||
'use strict';
|
||||
/* Manga page: info, follow toggle and the chapter list (books are just separators). */
|
||||
(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 + ' · MangaParty';
|
||||
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 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);
|
||||
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))),
|
||||
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;
|
||||
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 : '')));
|
||||
}
|
||||
lastBook = ch.book;
|
||||
list.append(chapterRow(ch));
|
||||
}
|
||||
|
||||
function chapterRow(ch) {
|
||||
const wrap = h('div', {});
|
||||
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.`);
|
||||
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 };
|
||||
}
|
||||
draw();
|
||||
};
|
||||
const verBtn = ch.versions.length > 1
|
||||
? h('button', { class: 'btn small', onclick: () => { panel.hidden = !panel.hidden; } }, ch.versions.length + ' versions')
|
||||
: null;
|
||||
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 || ''),
|
||||
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((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(); };
|
||||
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),
|
||||
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('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')));
|
||||
});
|
||||
}
|
||||
draw();
|
||||
return wrap;
|
||||
}
|
||||
})();
|
||||
Reference in new issue
Block a user