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

+45
View File
@@ -0,0 +1,45 @@
'use strict';
/* Library overview: a grid of covers. */
(async function () {
let me, data;
try { [me, data] = await Promise.all([api('api/me'), api('api/library')]); } catch (e) { return showError(e); }
topNav(me);
const main = $('main');
main.textContent = '';
if (!data.mangas.length) {
main.append(h('div', { class: 'empty' },
h('h1', {}, data.scanning ? 'Scanning the library…' : 'The library is empty'),
h('p', { class: 'muted' }, data.scanning ? 'This page refreshes by itself.' : 'Put manga folders into the library folder (see README.md for the format)' + (me.admin ? ', then rescan it in the settings.' : '.'))));
if (data.scanning) setTimeout(() => location.reload(), 2000);
return;
}
const filter = h('input', { type: 'search', placeholder: 'Filter…', 'aria-label': 'Filter', value: store.get('filter') || '' });
const sort = h('select', { 'aria-label': 'Sort' },
h('option', { value: 'title' }, 'A–Z'), h('option', { value: 'updated' }, 'Recently updated'), h('option', { value: 'read' }, 'Recently read'));
sort.value = store.get('sort') || 'title';
const grid = h('div', { class: 'grid' });
main.append(h('div', { class: 'toolbar' }, filter, sort), grid);
function render() {
store.set('filter', filter.value); store.set('sort', sort.value);
const q = filter.value.trim().toLowerCase();
const list = data.mangas.filter(m => !q || m.title.toLowerCase().includes(q));
if (sort.value === 'updated') list.sort((a, b) => b.updated - a.updated);
if (sort.value === 'read') list.sort((a, b) => b.lastRead - a.lastRead);
grid.textContent = '';
for (const m of list) {
grid.append(h('a', { class: 'tile', href: mangaUrl(m.id) },
h('div', { class: 'cv' }, m.cover ? h('img', { loading: 'lazy', src: m.cover, alt: '' }) : null),
m.unread ? h('span', { class: 'badge' }, m.unread + ' new') : null,
h('div', { class: 't' }, m.title),
h('div', { class: 's' }, m.read ? `${m.read} / ${m.chapters} read` : `${m.chapters} chapter${m.chapters === 1 ? '' : 's'}`),
m.read ? h('div', { class: 'prog' }, h('i', { style: `width:${Math.round((100 * m.read) / m.chapters)}%` })) : null));
}
if (!list.length) grid.append(h('p', { class: 'muted' }, 'Nothing matches.'));
}
filter.addEventListener('input', render);
sort.addEventListener('change', render);
render();
})();