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

+105
View File
@@ -0,0 +1,105 @@
'use strict';
/* Settings: reading preferences, notifications, password and (for admins) library + users. */
(async function () {
let me;
try { me = await api('api/me'); } catch (e) { return showError(e); }
topNav(me);
const S = me.settings, main = $('main');
main.textContent = '';
const saved = h('span', { class: 'saved', hidden: true }, 'Saved');
let savedTimer = 0;
async function save(patch) {
Object.assign(S, (await api('api/settings', patch)).settings);
saved.hidden = false;
clearTimeout(savedTimer);
savedTimer = setTimeout(() => { saved.hidden = true; }, 1500);
}
const check = (key, text, hint) => h('label', { class: 'check' },
h('input', { type: 'checkbox', checked: !!S[key], onchange: e => save({ [key]: e.target.checked }) }),
h('span', {}, text, hint ? h('div', { class: 'muted small' }, hint) : null));
const startMode = h('select', { onchange: e => save({ startMode: e.target.value }) },
h('option', { value: 'last' }, 'The mode I used last'),
h('option', { value: 'flow' }, 'Always Flow (pages below each other)'),
h('option', { value: 'page' }, 'Always Page (one page at a time)'),
h('option', { value: 'book' }, 'Always Book (two pages side by side)'));
startMode.value = S.startMode;
const LABEL = { flow: 'Flow', page: 'Page', book: 'Book', strip: 'Webtoon flow' };
const zooms = () => Object.keys(LABEL).map(k => `${LABEL[k]} ${Math.round((S.zoom[k] || 1) * 100)}%`).join(' · ');
const zoomText = h('span', { class: 'muted' }, zooms());
main.append(h('section', { class: 'card' },
h('h2', {}, 'Reading ', saved),
h('label', { class: 'field' }, h('span', {}, 'Open chapters in'), startMode,
h('div', { class: 'muted small' }, `Last used: ${LABEL[S.lastMode]}. Only clicking a mode in the reader changes it.`)),
check('adapt', 'Use the fitting mode when the content is not made for my choice',
'Example: webtoons are one long strip, so they always open in Flow. This never changes the mode you picked.'),
check('skipExtras', 'Skip extra pages and extra chapters', 'Pages or chapters marked as extra (omake, notes, specials) are left out while reading.'),
check('skipCovers', 'Skip chapter cover pages'),
h('div', { class: 'field' }, h('span', {}, 'Page zoom'), zoomText, ' ',
h('button', { class: 'btn small', onclick: async () => { await save({ zoom: {} }); zoomText.textContent = zooms(); } }, 'Reset to fit'),
h('div', { class: 'muted small' }, '100% = the page fits the screen. Change it in the reader with Ctrl + mouse wheel, a pinch, or the + / - keys.'))));
const hook = h('input', { type: 'url', placeholder: 'https://ntfy.sh/my-manga-topic', value: S.webhook || '', style: 'width:100%' });
const hookMsg = h('div', { class: 'small' });
main.append(h('section', { class: 'card' },
h('h2', {}, 'Notifications'),
h('p', { class: 'muted' }, 'Press "Notify me" on a manga page. New chapters then show up under the bell at the top.'),
h('label', { class: 'field' }, h('span', {}, 'Also send them to a webhook (optional)'), hook,
h('div', { class: 'muted small' }, 'The server POSTs a short text message to this URL, e.g. an ntfy.sh or Gotify topic.')),
h('div', { class: 'row' }, h('button', { class: 'btn', onclick: async () => {
try { await save({ webhook: hook.value }); hookMsg.textContent = ''; } catch (e) { hookMsg.textContent = e.message; hookMsg.className = 'small error'; }
} }, 'Save webhook'), hookMsg)));
const oldPw = h('input', { type: 'password', autocomplete: 'current-password', placeholder: 'Current password' });
const newPw = h('input', { type: 'password', autocomplete: 'new-password', placeholder: 'New password' });
const pwMsg = h('span', { class: 'small' });
main.append(h('section', { class: 'card' },
h('h2', {}, 'Account · ' + me.name),
h('div', { class: 'row' }, oldPw, newPw, h('button', { class: 'btn', onclick: async () => {
try { await api('api/password', { old: oldPw.value, new: newPw.value }); pwMsg.textContent = 'Password changed'; pwMsg.className = 'saved'; oldPw.value = newPw.value = ''; }
catch (e) { pwMsg.textContent = e.message; pwMsg.className = 'small error'; }
} }, 'Change password'), pwMsg)));
if (me.admin) adminCard();
async function adminCard() {
const card = h('section', { class: 'card' });
main.append(card);
const draw = data => {
const s = data.scan;
const scanText = s.running ? 'Scanning…' : s.error ? 'Last scan failed: ' + s.error
: s.at ? `${s.mangas} manga, ${s.chapters} chapters, ${s.pages} pages · scanned ${ago(s.at)} in ${s.ms} ms` : 'Not scanned yet';
const rescan = full => async e => {
e.target.disabled = true; e.target.textContent = 'Scanning…';
try { await api('api/admin/rescan', { full }); } finally { load(); }
};
const name = h('input', { placeholder: 'user name', autocomplete: 'off' });
const pw = h('input', { type: 'password', placeholder: 'password', autocomplete: 'new-password' });
const isAdmin = h('input', { type: 'checkbox' });
const msg = h('span', { class: 'small' });
card.textContent = '';
card.append(h('h2', {}, 'Admin'),
h('div', { class: 'field' }, h('span', {}, 'Library'), h('div', { class: 'muted small' }, s.library), h('div', {}, scanText),
h('div', { class: 'row', style: 'margin-top:8px' },
h('button', { class: 'btn', onclick: rescan(false) }, 'Rescan'),
h('button', { class: 'btn', onclick: rescan(true), title: 'Also re-read all image sizes' }, 'Full rescan'))),
h('div', { class: 'field' }, h('span', {}, 'Users'),
h('div', { class: 'users' }, data.users.map(u => h('div', {},
h('span', {}, u.name, u.admin ? h('span', { class: 'tag', style: 'margin-left:8px' }, 'admin') : null),
u.name === me.name ? h('span', { class: 'muted small' }, 'you') : h('button', { class: 'btn small', onclick: async () => {
if (!confirm(`Delete ${u.name} and their reading progress?`)) return;
await api('api/admin/user/delete', { name: u.name }); load();
} }, 'Delete'))))),
h('div', { class: 'field' }, h('span', {}, 'Add a user or reset a password'),
h('div', { class: 'row' }, name, pw, h('label', { class: 'row' }, isAdmin, 'admin'),
h('button', { class: 'btn', onclick: async () => {
try { await api('api/admin/user', { name: name.value, password: pw.value, admin: isAdmin.checked }); load(); }
catch (e) { msg.textContent = e.message; msg.className = 'small error'; }
} }, 'Save user'), msg)));
};
async function load() { draw(await api('api/admin')); }
load();
}
})();