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
106 lines
6.6 KiB
JavaScript
106 lines
6.6 KiB
JavaScript
'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();
|
|
}
|
|
})();
|