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,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();
|
||||
})();
|
||||
Reference in new issue
Block a user