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:
Maashkinoozhe committed 2026-10-01 09:35:05 +02:00
commit 8dd776b7f4
29 files changed
+3499

No files matched your search

+95
View File
@@ -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;
}
})();