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

+175
View File
@@ -0,0 +1,175 @@
:root {
--bg: #121214; --fg: #e9e9ec; --muted: #9b9ba4; --card: #1d1d22; --card2: #26262c; --line: #2d2d34; --accent: #e0566a;
color-scheme: dark;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
a { color: inherit; }
h1 { font-size: 26px; line-height: 1.2; margin: 0 0 6px; }
[hidden] { display: none !important; }
.muted { color: var(--muted); }
.small { font-size: 13px; }
.error { color: #ff8a8a; }
.grow { flex: 1; min-width: 0; }
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 36px; padding: 6px 12px;
border-radius: 8px; border: 1px solid var(--line); background: var(--card); color: var(--fg); font: inherit;
text-decoration: none; cursor: pointer; white-space: nowrap; -webkit-tap-highlight-color: transparent;
}
.btn:hover { background: var(--card2); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn.on { border-color: var(--accent); color: #ffb3bd; }
.btn.small { min-height: 28px; padding: 2px 9px; font-size: 13px; }
.btn.icon { padding: 6px 9px; }
.btn[disabled], .btn.disabled { opacity: .4; pointer-events: none; }
input, select, textarea { font: inherit; color: var(--fg); background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; min-height: 36px; max-width: 100%; }
label { display: block; }
/* navigation */
.nav {
position: sticky; top: 0; z-index: 5; display: flex; align-items: center; gap: 8px;
padding: 8px 16px; padding-top: max(8px, env(safe-area-inset-top)); background: rgba(18, 18, 20, .94); border-bottom: 1px solid var(--line);
}
.brand { font-weight: 800; text-decoration: none; font-size: 17px; letter-spacing: .2px; }
.bell-wrap { position: relative; }
.bell .count { background: var(--accent); color: #fff; border-radius: 9px; font-size: 12px; font-weight: 700; padding: 0 6px; }
.panel {
position: absolute; right: 0; top: 44px; width: min(360px, 92vw); max-height: 70vh; overflow: auto; z-index: 20;
background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 8px; box-shadow: 0 10px 30px rgba(0, 0, 0, .5);
}
.panel p { margin: 8px; }
.panel-head { display: flex; justify-content: space-between; align-items: center; padding: 4px 6px 8px; }
.note { display: block; padding: 8px; border-radius: 8px; text-decoration: none; }
.note:hover { background: var(--card2); }
.note.unread { border-left: 3px solid var(--accent); }
main { max-width: 1200px; margin: 0 auto; padding: 16px; }
/* library */
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; }
.toolbar input { flex: 1; min-width: 0; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 22px 16px; }
.tile { position: relative; display: block; text-decoration: none; }
.tile .cv { aspect-ratio: 2 / 3; border-radius: 8px; overflow: hidden; background: var(--card); box-shadow: 0 3px 14px rgba(0, 0, 0, .45); }
.tile img { display: block; width: 100%; height: 100%; object-fit: cover; transition: transform .2s; }
.tile:hover img { transform: scale(1.03); }
.tile .t { margin-top: 7px; font-weight: 650; line-height: 1.25; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.tile .s { color: var(--muted); font-size: 13px; }
.prog { height: 3px; background: var(--line); border-radius: 2px; margin-top: 5px; overflow: hidden; }
.prog i { display: block; height: 100%; background: var(--accent); }
.badge { position: absolute; top: 7px; right: 7px; background: var(--accent); color: #fff; font-size: 12px; font-weight: 700; padding: 2px 8px; border-radius: 10px; }
.empty { text-align: center; padding: 60px 10px; }
@media (max-width: 560px) {
main { padding: 12px; }
.grid { grid-template-columns: repeat(2, 1fr); gap: 16px 12px; }
.nav { padding-left: 12px; padding-right: 12px; }
}
/* manga page */
.hero { display: flex; gap: 22px; align-items: flex-start; margin-bottom: 22px; }
.hero > img { width: 220px; flex: none; border-radius: 10px; box-shadow: 0 4px 18px rgba(0, 0, 0, .5); background: var(--card); }
.hero .info { min-width: 0; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; }
.chip { background: var(--card2); border-radius: 12px; padding: 1px 9px; font-size: 13px; }
.desc { white-space: pre-line; margin: 10px 0; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.sources a { margin-right: 10px; }
@media (max-width: 640px) {
.hero { flex-direction: column; align-items: center; text-align: center; }
.hero > img { width: 180px; }
.chips, .actions { justify-content: center; }
}
.chapters { border-top: 1px solid var(--line); }
.book-sep { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; padding: 16px 4px 6px; }
.ch { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--line); }
.ch-main { flex: 1; min-width: 0; display: flex; gap: 10px; align-items: baseline; padding: 12px 4px; text-decoration: none; }
.ch-main:hover .ch-title { text-decoration: underline; }
.ch-label { font-weight: 650; flex: none; }
.ch-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ch.read .ch-main { color: var(--muted); }
.tag { font-size: 11px; border: 1px solid var(--line); border-radius: 6px; padding: 0 5px; color: var(--muted); flex: none; }
.state { color: var(--muted); font-size: 13px; background: none; border: 0; cursor: pointer; padding: 6px; font: inherit; font-size: 13px; }
.state.done { color: #7fd3a0; }
.versions { background: var(--card); border-radius: 8px; margin: 4px 0 10px; padding: 4px 10px; }
.ver { display: flex; align-items: center; gap: 10px; padding: 7px 0; flex-wrap: wrap; }
.ver + .ver { border-top: 1px solid var(--line); }
.ver .src { flex: 1; min-width: 140px; }
.vote { display: inline-flex; align-items: center; gap: 4px; }
.vote button { background: var(--card2); border: 1px solid var(--line); color: var(--fg); border-radius: 6px; width: 30px; height: 28px; cursor: pointer; }
.vote button.on { background: var(--accent); border-color: var(--accent); color: #fff; }
.vote b { min-width: 22px; text-align: center; }
/* settings & login */
.card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 16px; margin-bottom: 16px; max-width: 720px; }
.card h2 { margin: 0 0 12px; font-size: 18px; }
.field { margin-bottom: 14px; }
.field > span { display: block; font-weight: 600; margin-bottom: 4px; }
.check { display: flex; gap: 10px; align-items: flex-start; margin-bottom: 12px; }
.check input { min-height: 0; margin-top: 4px; }
.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.users div { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-bottom: 1px solid var(--line); }
.login { max-width: 360px; margin: 12vh auto 0; }
.login input { width: 100%; }
.saved { color: #7fd3a0; font-size: 13px; }
/* ---------------- reader ---------------- */
body.reader { position: fixed; top: 0; left: 0; right: 0; bottom: 0; overflow: hidden; background: #0b0b0c; overscroll-behavior: none; }
#stage {
position: absolute; top: 0; left: 0; right: 0; bottom: 0; overflow: auto; outline: none;
touch-action: pan-x pan-y; /* pinch is handled by the reader so it zooms the pages, not the controls */
-webkit-user-select: none; user-select: none; -webkit-tap-highlight-color: transparent;
overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: #3a3a40 transparent;
}
#stage.pan { cursor: grab; }
#stage.drag { cursor: grabbing; }
#canvas { position: relative; }
.pg { position: absolute; overflow: hidden; background: #19191c; }
.pg img { display: block; width: 100%; height: 100%; -webkit-user-drag: none; }
.pg.rot img { position: absolute; left: 50%; top: 50%; max-width: none; }
/* still loading: a spinner that only appears after 150 ms, so fast loads never flash it */
.pg.loading::after {
content: ''; position: absolute; left: 50%; top: 50%; width: 38px; height: 38px; margin: -19px 0 0 -19px;
border: 3px solid rgba(255, 255, 255, .15); border-top-color: var(--accent); border-radius: 50%;
opacity: 0; animation: mp-spin .8s linear infinite, mp-show 0s .15s forwards;
}
.pg.failed::after {
content: 'This page could not be loaded'; position: absolute; top: 0; left: 0; right: 0; bottom: 0;
display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 14px; text-align: center;
}
.pg.failed img { visibility: hidden; }
@keyframes mp-spin { to { transform: rotate(360deg); } }
@keyframes mp-show { to { opacity: 1; } }
.loading { color: var(--muted); padding: 40px; text-align: center; }
.end { position: absolute; left: 0; right: 0; display: flex; align-items: center; justify-content: center; }
#canvas.page .end, #canvas.book .end { top: 0; bottom: 0; }
.endbox { display: flex; flex-direction: column; gap: 12px; align-items: center; text-align: center; padding: 20px; }
.bar {
position: fixed; left: 0; right: 0; z-index: 10; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px;
padding: 6px max(8px, env(safe-area-inset-right)) 6px max(8px, env(safe-area-inset-left));
background: rgba(14, 14, 16, .9); color: var(--fg); transition: transform .2s, opacity .2s;
}
#top { top: 0; padding-top: max(6px, env(safe-area-inset-top)); }
#bot { bottom: 0; padding-bottom: max(6px, env(safe-area-inset-bottom)); }
body.nobars #top { transform: translateY(-100%); opacity: 0; pointer-events: none; }
body.nobars #bot { transform: translateY(100%); opacity: 0; pointer-events: none; }
#title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
#chsel { flex: 0 1 auto; min-width: 0; max-width: min(440px, 60vw); }
#bot .row { width: 100%; flex-wrap: nowrap; }
#slider { flex: 1; min-width: 0; accent-color: var(--accent); padding: 0; border: 0; background: none; }
#pageno { min-width: 64px; text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); font-size: 13px; }
#tools { width: 100%; display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 6px 8px; }
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.seg button { background: var(--card); color: var(--fg); border: 0; padding: 7px 12px; font: inherit; cursor: pointer; }
.seg button + button { border-left: 1px solid var(--line); }
.seg button.on { background: var(--accent); color: #fff; }
.seg button.meh:not(.on) { color: var(--muted); }
#ver { max-width: 170px; }
#toast {
position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 30; pointer-events: none;
background: rgba(0, 0, 0, .75); color: #fff; padding: 9px 16px; border-radius: 10px; opacity: 0; transition: opacity .25s; text-align: center;
}
#toast.show { opacity: 1; }
@media (max-width: 560px) { .seg button { padding: 7px 10px; } #bot .btn { padding: 6px 10px; } .wide-only { display: none; } }
+94
View File
@@ -0,0 +1,94 @@
'use strict';
/* Shared helpers for all pages. Plain scripts, no build step. */
function $(sel, root) { return (root || document).querySelector(sel); }
// h('a', { href, class, onclick }, 'text', childNode, [more]) -> element. Strings always become text, never HTML.
function h(tag, props, ...kids) {
const el = document.createElement(tag);
for (const k in props || {}) {
const v = props[k];
if (v == null || v === false) continue;
if (k === 'class') el.className = v;
else if (k.slice(0, 2) === 'on') el.addEventListener(k.slice(2), v);
else el.setAttribute(k, v === true ? '' : v);
}
for (const c of kids.flat(9)) if (c != null && c !== false) el.append(c instanceof Node ? c : String(c));
return el;
}
// Only for the constant SVG icons below.
function icon(svg) { const t = document.createElement('template'); t.innerHTML = svg.trim(); return t.content.firstChild; }
const ICONS = {
bell: '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>',
};
async function api(path, body) {
const opt = body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
const r = await fetch(path, opt);
if (r.status === 401 && !/login\.html$/.test(location.pathname)) {
location.href = 'login.html?next=' + encodeURIComponent(location.pathname + location.search);
throw new Error('Please log in');
}
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || r.statusText);
return data;
}
const store = {
get(k) { try { return localStorage.getItem('mp.' + k); } catch (e) { return null; } },
set(k, v) { try { localStorage.setItem('mp.' + k, v); } catch (e) { /* private mode */ } },
};
function ago(t) {
const s = (Date.now() - t) / 1000;
if (s < 90) return 'just now';
if (s < 5400) return Math.round(s / 60) + ' min ago';
if (s < 129600) return Math.round(s / 3600) + ' h ago';
if (s < 3e6) return Math.round(s / 86400) + ' days ago';
return new Date(t).toLocaleDateString();
}
const qsa = obj => { const p = new URLSearchParams(); for (const k in obj) if (obj[k] != null && obj[k] !== '') p.set(k, obj[k]); return p.toString(); };
const readUrl = (m, c, v) => 'read.html?' + qsa({ m, c, v });
const mangaUrl = m => 'manga.html?' + qsa({ m });
const safeHref = u => (/^https?:\/\//i.test(u || '') ? u : null);
// Top navigation with the notification bell.
function topNav(me) {
const count = h('span', { class: 'count', hidden: !me.unread }, me.unread);
const panel = h('div', { class: 'panel', hidden: true });
const setCount = n => { count.textContent = n; count.hidden = !n; };
async function open() {
panel.hidden = !panel.hidden;
if (panel.hidden) return;
panel.textContent = 'Loading…';
const { items } = await api('api/notifications');
panel.textContent = '';
if (!items.length) {
panel.append(h('p', { class: 'muted' }, 'Nothing yet. Press "Notify me" on a manga to hear about its new chapters.'));
return;
}
panel.append(h('div', { class: 'panel-head' }, h('b', {}, 'New chapters'),
h('button', { class: 'btn small', onclick: async () => { setCount((await api('api/notifications/read', { all: true })).unread); panel.querySelectorAll('.unread').forEach(e => e.classList.remove('unread')); } }, 'Mark all read')));
for (const n of items) {
panel.append(h('a', { class: 'note' + (n.read ? '' : ' unread'), href: readUrl(n.m, n.c) }, h('div', {}, n.text), h('div', { class: 'muted small' }, ago(n.t))));
}
}
const bell = h('button', { class: 'btn icon bell', title: 'Notifications', 'aria-label': 'Notifications', onclick: e => { e.stopPropagation(); open(); } }, icon(ICONS.bell), count);
panel.addEventListener('click', e => e.stopPropagation());
document.addEventListener('click', () => { panel.hidden = true; });
const nav = h('header', { class: 'nav' },
h('a', { class: 'brand', href: './' }, 'MangaParty'),
h('div', { class: 'grow' }),
h('div', { class: 'bell-wrap' }, bell, panel),
h('a', { class: 'btn', href: 'settings.html' }, 'Settings'),
h('button', { class: 'btn', onclick: async () => { await api('api/logout', {}); location.href = 'login.html'; } }, 'Log out'));
document.body.prepend(nav);
}
function showError(e) {
const main = $('main') || document.body;
main.textContent = '';
main.append(h('p', { class: 'error' }, e.message || String(e)), h('a', { class: 'btn', href: './' }, 'Back to the library'));
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#e0566a"/><path d="M12 16c7-2 14-1 20 3v31c-6-4-13-5-20-3z" fill="#fff"/><path d="M52 16c-7-2-14-1-20 3v31c6-4 13-5 20-3z" fill="#ffd9de"/></svg>

After

Width:  |  Height:  |  Size: 249 B

+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>MangaParty</title>
<link rel="stylesheet" href="app.css">
<link rel="icon" href="icon.svg">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#121214">
<script src="common.js" defer></script>
<script src="library.js" defer></script>
</head>
<body>
<main><p class="loading">Loading…</p></main>
</body>
</html>
+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();
})();
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Sign in · MangaParty</title>
<link rel="stylesheet" href="app.css">
<link rel="icon" href="icon.svg">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#121214">
<script src="common.js" defer></script>
<script src="login.js" defer></script>
</head>
<body>
<main class="login">
<h1>MangaParty</h1>
<form id="form" class="card">
<h2 id="heading">Sign in</h2>
<label class="field"><span>User name</span><input id="name" autocomplete="username" autocapitalize="none" required></label>
<label class="field"><span>Password</span><input id="password" type="password" autocomplete="current-password" required></label>
<p id="msg" class="error" hidden></p>
<div class="row"><button class="btn primary" id="submit">Sign in</button><a href="#" id="toggle" hidden>Create an account</a></div>
</form>
</main>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
'use strict';
(async function () {
const info = await api('api/auth').catch(() => ({}));
let signup = !!info.firstRun;
const draw = () => {
$('#heading').textContent = info.firstRun ? 'Create the admin account' : signup ? 'Create an account' : 'Sign in';
$('#submit').textContent = signup ? 'Create account' : 'Sign in';
$('#password').autocomplete = signup ? 'new-password' : 'current-password';
$('#toggle').hidden = info.firstRun || !info.signup;
$('#toggle').textContent = signup ? 'I have an account' : 'Create an account';
};
$('#toggle').onclick = e => { e.preventDefault(); signup = !signup; draw(); };
$('#form').onsubmit = async e => {
e.preventDefault();
try {
await api(signup ? 'api/signup' : 'api/login', { name: $('#name').value, password: $('#password').value });
const next = new URLSearchParams(location.search).get('next') || '';
location.href = /^\/(?!\/)/.test(next) ? next : './';
} catch (err) {
$('#msg').textContent = err.message;
$('#msg').hidden = false;
}
};
draw();
$('#name').focus();
})();
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>MangaParty</title>
<link rel="stylesheet" href="app.css">
<link rel="icon" href="icon.svg">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#121214">
<script src="common.js" defer></script>
<script src="manga.js" defer></script>
</head>
<body>
<main><p class="loading">Loading…</p></main>
</body>
</html>
+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;
}
})();
+10
View File
@@ -0,0 +1,10 @@
{
"name": "MangaParty",
"short_name": "MangaParty",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#121214",
"theme_color": "#121214",
"icons": [{ "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }]
}
+47
View File
@@ -0,0 +1,47 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>MangaParty</title>
<link rel="stylesheet" href="app.css">
<link rel="icon" href="icon.svg">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#0b0b0c">
<script src="common.js" defer></script>
<script src="reader.js" defer></script>
</head>
<body class="reader">
<div id="stage" tabindex="-1"><div id="canvas"><p class="loading">Loading…</p></div></div>
<div id="top" class="bar">
<a id="back" class="btn icon" href="./" title="Back to the manga" aria-label="Back">‹</a>
<div id="title" class="grow"></div>
<select id="chsel" title="Jump to a chapter" aria-label="Chapter"></select>
<button id="fs" class="btn icon" title="Fullscreen" aria-label="Fullscreen"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg></button>
</div>
<div id="bot" class="bar">
<div class="row">
<input id="slider" type="range" min="1" max="1" value="1" aria-label="Page">
<span id="pageno"></span>
</div>
<div id="tools">
<a id="prevch" class="btn" title="Previous chapter">‹<span class="wide-only"> Prev</span></a>
<div id="modes" class="seg" role="group" aria-label="View mode">
<button data-mode="flow" title="Continuous flow: all pages below each other">Flow</button>
<button data-mode="page" title="One page at a time">Page</button>
<button data-mode="book" title="Two pages side by side, like an open book">Book</button>
</div>
<button id="shift" class="btn" title="Page pairs look wrong? Shift them by one page">⇄<span class="wide-only"> Shift</span></button>
<button id="zoom" class="btn" title="Page zoom (Ctrl + scroll or pinch). Click to fit the screen again.">100%</button>
<select id="ver" title="Version of this chapter" aria-label="Version"></select>
<a id="nextch" class="btn" title="Next chapter"><span class="wide-only">Next </span>›</a>
</div>
</div>
<div id="toast"></div>
</body>
</html>
+538
View File
@@ -0,0 +1,538 @@
'use strict';
/*
* Chapter reader.
* - Modes: flow (all pages below each other, same width), page (one page), book (two facing pages; double pages alone).
* - Page zoom ("zoom" below): 1 = the page (in flow: a normal single page) exactly fits the screen.
* It is remembered per mode; flow for webtoons has its own value ("strip", 1 = strip as wide as the screen / its own width).
* - Tap zones: left/right third turns pages following the reading direction (top/bottom third in flow), the middle toggles the bars.
* - All pages are laid out with absolute positions computed from the image sizes the server knows, so zooming and
* restoring positions never depend on images having loaded.
*/
(async function () {
const P = new URLSearchParams(location.search);
const stage = $('#stage'), canvas = $('#canvas'), slider = $('#slider'), ver = $('#ver');
let me, D;
try { [me, D] = await Promise.all([api('api/me'), api('api/chapter?' + qsa({ m: P.get('m'), c: P.get('c'), v: P.get('v') }))]); }
catch (e) { canvas.textContent = ''; canvas.append(h('p', { class: 'loading' }, e.message + ' · ', h('a', { href: './' }, 'Library'))); return; }
const S = me.settings, rtl = D.direction === 'rtl', ttb = D.direction === 'ttb';
const GAP = ttb ? 0 : 8, END_H = 280;
document.title = `${D.chapter.label} · ${D.manga.title}`;
// ---------- pages ----------
const all = D.pages.map((p, i) => {
const t = p.t || [], r = p.r || 0, unknown = !p.w || !p.h;
const w = unknown ? (t.includes('double') ? 2000 : 1000) : p.w, hh = unknown ? 1414 : p.h;
const dw = r % 180 ? hh : w, dh = r % 180 ? w : hh; // size as displayed (after rotation)
return { i, u: p.u, r, w: dw, a: dw / dh, unknown, cover: t.includes('cover'), extra: t.includes('extra'), wide: t.includes('double') || dw > dh };
});
let list = all.filter(p => !(S.skipExtras && p.extra) && !(S.skipCovers && p.cover));
if (!list.length) list = all;
const median = xs => (xs.length ? xs.sort((x, y) => x - y)[xs.length >> 1] : 0);
const refA = median(list.filter(p => !p.wide).map(p => p.a)) || 0.7; // aspect ratio of a normal single page
const refW = median(list.map(p => p.w)) || 800; // natural width of a webtoon strip
const solo = p => p.wide || p.cover; // shown alone in book mode
// ---------- state ----------
const compatible = ttb ? ['flow'] : ['flow', 'page', 'book'];
const intended = S.startMode === 'last' ? S.lastMode : S.startMode;
let mode = S.adapt && !compatible.includes(intended) ? compatible[0] : intended;
if (!['flow', 'page', 'book'].includes(mode)) mode = compatible[0];
let cur = 0, atEnd = false, done = !!(D.progress && D.progress.done), bars = true, barsAt = 0;
let spreads = [], spreadIdx = 0, shown = [], els = [], boxes = [], endEl = null, lastW = 0, lastH = 0;
const shift = new Set(D.progress && D.progress.v === D.version ? D.progress.shift : []);
const zkey = () => (mode === 'flow' && ttb ? 'strip' : mode);
const zoom = () => S.zoom[zkey()] || 1;
const pr = D.progress;
if (pr && !(pr.done && pr.p >= pr.n - 1)) {
const p = pr.v === D.version || !pr.n ? pr.p : Math.round((pr.p / pr.n) * all.length);
const k = list.findIndex(x => x.i >= p);
cur = k < 0 ? list.length - 1 : k;
}
// Book mode: pages pair up inside each run of single pages. Runs restart after every double page / cover,
// so one missing pair never shifts the rest of the chapter. "Shift" makes a run start with a lone page.
function computeSpreads() {
const out = [];
for (let i = 0; i < list.length;) {
if (solo(list[i])) { out.push([i++]); continue; }
let j = i, k = i;
while (j < list.length && !solo(list[j])) j++;
if (shift.has(list[i].i)) out.push([k++]);
for (; k < j; k += 2) out.push(k + 1 < j ? [k, k + 1] : [k]);
i = j;
}
return out;
}
const runStart = k => { while (k > 0 && !solo(list[k - 1])) k--; return k; };
// ---------- rendering ----------
const io = window.IntersectionObserver
? new IntersectionObserver(entries => {
for (const e of entries) if (e.isIntersecting) { setSrc(e.target, e.target._p.u); io.unobserve(e.target); }
}, { root: stage, rootMargin: '150% 50%' })
: null;
// Page/book mode: preloaded images are downloaded *and decoded* ahead of time and then shown as the very same
// elements, so a page turn swaps pictures within one frame instead of flashing an empty box.
const pool = new Map();
function pooled(url, priority) {
let img = pool.get(url);
if (img) pool.delete(url);
else {
img = new Image();
img.decoding = 'async';
img.fetchPriority = priority;
img.src = url;
if (img.decode) img.decode().catch(() => {});
}
pool.set(url, img); // most recently used last
while (pool.size > 12) pool.delete(pool.keys().next().value);
return img;
}
function preloadAround() {
const ahead = mode === 'page' ? [cur + 1, cur + 2, cur + 3, cur - 1]
: [].concat(spreads[spreadIdx + 1] || [], spreads[spreadIdx + 2] || [], spreads[spreadIdx - 1] || []);
for (const k of ahead) if (list[k]) pooled(list[k].u, 'low');
}
// A spinner shows (after a short delay, see CSS) while a page is still loading, a message if it failed.
function watch(el) {
const img = el.firstChild, p = el._p;
const state = () => {
el.classList.toggle('loading', !!img.getAttribute('src') && !img.complete);
el.classList.toggle('failed', img.complete && !!img.getAttribute('src') && !img.naturalWidth);
};
img.onload = () => {
state();
if (p.unknown && img.naturalWidth) {
const w = p.r % 180 ? img.naturalHeight : img.naturalWidth, hh = p.r % 180 ? img.naturalWidth : img.naturalHeight;
Object.assign(p, { unknown: false, w, a: w / hh, wide: p.wide || w > hh });
relayout();
}
};
img.onerror = state;
state();
}
function setSrc(el, url) { el.firstChild.src = url; watch(el); }
function pageEl(p, lazy) {
const img = lazy ? h('img', { decoding: 'async' }) : pooled(p.u, 'high');
img.alt = '';
img.draggable = false;
img.style.cssText = p.r ? `transform: translate(-50%, -50%) rotate(${p.r}deg)` : '';
if (!lazy) img.decoding = 'sync'; // paint together with the page turn (it is decoded already when preloaded)
const el = h('div', { class: 'pg' + (p.r ? ' rot' : '') }, img);
el._p = p;
if (!lazy) watch(el);
else if (io) io.observe(el);
else setSrc(el, p.u);
return el;
}
function endCard() {
const n = D.next;
return h('div', { class: 'end' }, h('div', { class: 'endbox' },
h('div', { class: 'muted' }, 'End of ' + D.chapter.label + (D.chapter.title ? ' · ' + D.chapter.title : '')),
n ? h('a', { class: 'btn primary', href: readUrl(D.manga.id, n.id), onclick: () => saveProgress(true) }, `Next: ${n.label}${n.title ? ' · ' + n.title : ''} ›`)
: h('div', {}, 'You are up to date.'),
h('a', { class: 'btn', href: mangaUrl(D.manga.id) }, 'Back to ' + D.manga.title)));
}
function build() {
if (io) io.disconnect();
canvas.textContent = '';
canvas.className = mode + (ttb ? ' ttb' : '');
els = []; shown = []; endEl = null;
if (mode === 'flow') {
els = list.map(p => canvas.appendChild(pageEl(p, true)));
endEl = canvas.appendChild(endCard());
} else if (atEnd) {
endEl = canvas.appendChild(endCard());
} else {
shown = mode === 'page' ? [cur] : spreads[spreadIdx];
els = shown.map(k => canvas.appendChild(pageEl(list[k])));
// The pages on screen get the bandwidth first, then the next ones are fetched.
const token = ++buildToken, waiting = els.map(el => el.firstChild).filter(img => !img.complete);
Promise.all(waiting.map(img => new Promise(done => {
img.addEventListener('load', done, { once: true });
img.addEventListener('error', done, { once: true });
}))).then(() => { if (token === buildToken) preloadAround(); });
}
layout();
}
let buildToken = 0;
// Scrollbars appear/disappear with the content size and change the viewport, so repeat until it is stable.
function layout() {
for (let pass = 0; pass < 3; pass++) {
const vw = stage.clientWidth, vh = stage.clientHeight;
layoutFor(vw, vh);
if (stage.clientWidth === vw && stage.clientHeight === vh) break;
}
}
function layoutFor(vw, vh) {
const z = zoom();
let cw = vw, ch = vh;
boxes = [];
if (mode === 'flow') {
// Every page gets the width of a normal single page; wide pages keep that height but never get wider than the screen.
const W = (ttb ? Math.min(vw, refW) : Math.min(vw, vh * refA)) * z;
const ws = list.map(p => (p.wide && !ttb ? Math.min((W * p.a) / refA, Math.max(vw, W)) : W));
cw = Math.max(vw, ...ws);
let y = 0;
ws.forEach((w, k) => { boxes.push({ x: (cw - w) / 2, y, w, h: w / list[k].a }); y += w / list[k].a + GAP; });
endEl.style.top = y + 'px';
endEl.style.height = END_H + 'px';
ch = y + END_H;
} else if (shown.length) {
const sum = shown.reduce((s, k) => s + list[k].a, 0), H = Math.min(vh, vw / sum) * z, W = H * sum;
cw = Math.max(vw, W); ch = Math.max(vh, H);
const y = (ch - H) / 2, order = shown.map((k, j) => j);
if (rtl) order.reverse(); // right-to-left: the first page of a pair sits on the right
let x = (cw - W) / 2;
for (const j of order) { boxes[j] = { x, y, w: H * list[shown[j]].a, h: H }; x += boxes[j].w; }
}
canvas.style.width = cw + 'px';
canvas.style.height = ch + 'px';
els.forEach((el, j) => place(el, boxes[j]));
stage.classList.toggle('pan', cw > vw + 1 || (mode === 'flow' ? z > 1.01 : ch > vh + 1));
lastW = vw; lastH = vh;
}
function place(el, b) {
const s = el.style, x = Math.round(b.x), y = Math.round(b.y), w = Math.round(b.x + b.w) - x, hh = Math.round(b.y + b.h) - y;
s.left = x + 'px'; s.top = y + 'px'; s.width = w + 'px'; s.height = hh + 'px';
if (el._p.r) {
const im = el.firstChild.style, turned = el._p.r % 180;
im.width = (turned ? hh : w) + 'px';
im.height = (turned ? w : hh) + 'px';
}
}
function flowIndexAt(y) {
let lo = 0, hi = boxes.length - 1;
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (boxes[mid].y <= y) lo = mid; else hi = mid - 1; }
return lo;
}
function union() {
const x = Math.min(...boxes.map(b => b.x));
return { x, y: boxes[0].y, w: Math.max(...boxes.map(b => b.x + b.w)) - x, h: boxes[0].h };
}
// Remember which point of which page is under (cx, cy) and put it back there after a re-layout.
function anchor(cx, cy) {
if (!boxes.length) return null;
const X = stage.scrollLeft + cx, Y = stage.scrollTop + cy, k = mode === 'flow' ? flowIndexAt(Y) : -1, b = k < 0 ? union() : boxes[k];
return { k, fx: (X - b.x) / b.w, fy: (Y - b.y) / b.h };
}
function restore(a, cx, cy) {
if (!a || !boxes.length) return;
const b = a.k < 0 ? union() : boxes[a.k];
stage.scrollLeft = b.x + a.fx * b.w - cx;
stage.scrollTop = b.y + a.fy * b.h - cy;
barsAt = stage.scrollTop;
}
function relayout() { const a = anchor(lastW / 2, lastH / 2); layout(); restore(a, lastW / 2, lastH / 2); }
// ---------- zoom ----------
let zoomTimer = 0;
function setZoom(z, cx = stage.clientWidth / 2, cy = stage.clientHeight / 2) {
z = Math.min(6, Math.max(0.2, z));
if (Math.abs(z - 1) < 0.03) z = 1; // easy to get back to "fit"
if (Math.abs(z - zoom()) < 0.001) return;
const a = anchor(cx, cy);
S.zoom[zkey()] = z;
layout();
restore(a, cx, cy);
toast(Math.round(z * 100) + '%');
updateUI();
clearTimeout(zoomTimer);
zoomTimer = setTimeout(() => api('api/settings', { zoom: S.zoom }).catch(() => {}), 800);
}
// Ctrl + wheel (and touchpad pinch, which browsers report the same way) zooms the pages, not the page controls.
stage.addEventListener('wheel', e => {
if (!e.ctrlKey) return;
e.preventDefault();
setZoom(zoom() * Math.exp(-e.deltaY * (e.deltaMode === 1 ? 0.05 : 0.0025)), e.clientX, e.clientY);
}, { passive: false });
// Touch: two fingers zoom, a quick horizontal swipe turns the page (page/book mode, when not zoomed in).
let pinch = null, swipe = null, pinchRaf = 0, pinchNext = null;
const pinchApply = () => { pinchRaf = 0; if (pinchNext) setZoom(...pinchNext); pinchNext = null; };
const dist = t => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
stage.addEventListener('touchstart', e => {
const t = e.touches;
if (t.length === 2) { pinch = { d: dist(t), z: zoom() }; swipe = null; }
else if (t.length === 1) swipe = { x: t[0].clientX, y: t[0].clientY, t: Date.now() };
}, { passive: true });
stage.addEventListener('touchmove', e => {
if (!pinch || e.touches.length !== 2) return;
e.preventDefault();
const t = e.touches;
pinchNext = [pinch.z * dist(t) / pinch.d, (t[0].clientX + t[1].clientX) / 2, (t[0].clientY + t[1].clientY) / 2];
pinchRaf = pinchRaf || requestAnimationFrame(pinchApply);
}, { passive: false });
stage.addEventListener('touchend', e => {
if (e.touches.length < 2) { if (pinchNext) pinchApply(); pinch = null; }
const s = swipe;
swipe = null;
if (!s || mode === 'flow' || e.touches.length) return;
const t = e.changedTouches[0], dx = t.clientX - s.x, dy = t.clientY - s.y;
if (Math.abs(dx) < 50 || Math.abs(dx) < 2 * Math.abs(dy) || Date.now() - s.t > 800) return;
if (stage.scrollWidth > stage.clientWidth + 1) return; // zoomed in: the finger pans instead
((dx > 0) === rtl ? next : prev)();
});
// Safari reports pinches as gesture events; without this it would zoom the whole page.
let gestureZoom = 1;
stage.addEventListener('gesturestart', e => { e.preventDefault(); gestureZoom = zoom(); });
stage.addEventListener('gesturechange', e => { e.preventDefault(); if (!pinch) setZoom(gestureZoom * e.scale, e.clientX, e.clientY); });
// Mouse: drag to move a zoomed page around; a click without dragging uses the tap zones.
let drag = null, dragged = false;
stage.addEventListener('pointerdown', e => {
if (e.pointerType === 'mouse' && e.button === 0) drag = { x: e.clientX, y: e.clientY, l: stage.scrollLeft, t: stage.scrollTop, moved: false };
});
addEventListener('pointermove', e => {
if (!drag) return;
const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
if (!drag.moved && Math.abs(dx) + Math.abs(dy) < 6) return;
drag.moved = true;
stage.classList.add('drag');
stage.scrollLeft = drag.l - dx;
stage.scrollTop = drag.t - dy;
});
addEventListener('pointerup', () => {
if (drag && drag.moved) { dragged = true; setTimeout(() => { dragged = false; }, 0); }
drag = null;
stage.classList.remove('drag');
});
// Outside the pages: toggle the controls. On a page: the outer 35 % of the visible page area turn pages
// (top/bottom in flow), the middle toggles the controls (needed when a zoomed page fills the whole screen).
stage.addEventListener('click', e => {
if (dragged || e.target.closest('a, button, select, input')) return;
const onPage = e.target.closest('.pg'), vw = stage.clientWidth;
if (!onPage && !(atEnd && mode !== 'flow')) return toggleBars();
if (mode === 'flow') {
const fy = e.clientY / stage.clientHeight;
return fy < 0.35 ? prev() : fy > 0.65 ? next() : toggleBars();
}
let left = 0, right = vw;
if (!atEnd) {
const rects = els.map(el => el.getBoundingClientRect());
left = Math.max(0, Math.min(...rects.map(r => r.left)));
right = Math.min(vw, Math.max(...rects.map(r => r.right)));
}
const fx = (e.clientX - left) / (right - left);
if (fx < 0.35) (rtl ? next : prev)();
else if (fx > 0.65) (rtl ? prev : next)();
else toggleBars();
});
addEventListener('keydown', e => {
if ((e.target.closest && e.target.closest('input, select, textarea')) || e.ctrlKey || e.metaKey || e.altKey) return;
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowRight') { e.preventDefault(); (k === (rtl ? 'ArrowLeft' : 'ArrowRight') ? next : prev)(); }
else if ((k === ' ' || k === 'PageDown') && mode !== 'flow') { e.preventDefault(); (e.shiftKey ? prev : next)(); }
else if (k === 'PageUp' && mode !== 'flow') { e.preventDefault(); prev(); }
else if (k === '+' || k === '=') setZoom(zoom() * 1.15);
else if (k === '-') setZoom(zoom() / 1.15);
else if (k === '0') setZoom(1);
else if (k === 'm' || k === 'Escape') toggleBars();
else if (k === 'f') toggleFullscreen();
});
let scrollRaf = 0;
stage.addEventListener('scroll', () => {
if (mode !== 'flow' || scrollRaf) return;
scrollRaf = requestAnimationFrame(() => {
scrollRaf = 0;
cur = flowIndexAt(stage.scrollTop + stage.clientHeight * 0.3);
// Scrolling (up or down) only ever hides the bars; they come back with a tap in the middle.
if (bars && Math.abs(stage.scrollTop - barsAt) > 100) autoHide();
moved();
});
});
let resizeRaf = 0;
addEventListener('resize', () => { resizeRaf = resizeRaf || requestAnimationFrame(() => { resizeRaf = 0; relayout(); }); });
// ---------- navigation ----------
function next() {
if (mode === 'flow') return flowStep(1);
if (atEnd) return goChapter(D.next);
if (mode === 'page' ? cur >= list.length - 1 : spreadIdx >= spreads.length - 1) atEnd = true;
else if (mode === 'page') cur++;
else cur = spreads[++spreadIdx][0];
turned();
}
function prev() {
if (mode === 'flow') return flowStep(-1);
if (atEnd) atEnd = false;
else if (mode === 'page' ? cur === 0 : spreadIdx === 0) return toast('First page');
else if (mode === 'page') cur--;
else cur = spreads[--spreadIdx][0];
turned();
}
function turned() { build(); toStart(); autoHide(); moved(); }
function toStart() { stage.scrollTop = 0; stage.scrollLeft = rtl ? stage.scrollWidth : 0; barsAt = 0; }
function goChapter(c) {
if (!c) return toast('You are up to date');
saveProgress(true);
location.href = readUrl(D.manga.id, c.id);
}
// Flow: jump to the top of the next page, but never further than ~one screen.
function flowStep(dir) {
const st = stage.scrollTop, vh = stage.clientHeight;
if (dir > 0 && st + vh >= stage.scrollHeight - 2) return goChapter(D.next);
let t;
if (ttb) t = st + dir * vh * 0.85; // webtoon slices are arbitrary cuts: just scroll
else if (dir > 0) { const b = boxes.find(b => b.y > st + 2); t = b ? Math.min(b.y, st + vh * 0.9) : st + vh * 0.9; }
else { let b = null; for (const x of boxes) { if (x.y < st - 2) b = x; else break; } t = b ? Math.max(b.y, st - vh * 0.9) : 0; }
stage.scrollTo({ top: t, behavior: 'smooth' });
}
function goTo(k) {
atEnd = false;
if (mode === 'flow') { stage.scrollTop = boxes[k].y; barsAt = stage.scrollTop; cur = k; return moved(); }
if (mode === 'book') { spreadIdx = spreads.findIndex(s => s.includes(k)); cur = spreads[spreadIdx][0]; }
else cur = k;
build(); toStart(); moved();
}
function setMode(m, byUser) {
mode = m; atEnd = false;
if (mode === 'book') {
spreads = computeSpreads();
spreadIdx = Math.max(0, spreads.findIndex(s => s.includes(cur)));
cur = spreads[spreadIdx][0];
}
build();
if (mode === 'flow') {
stage.scrollTop = boxes[cur].y;
stage.scrollLeft = (stage.scrollWidth - stage.clientWidth) / 2;
barsAt = stage.scrollTop;
} else toStart();
// Only a click changes the remembered mode, never an automatic switch for incompatible content.
if (byUser) { S.lastMode = m; api('api/settings', { lastMode: m }).catch(() => {}); }
moved();
}
function toggleShift() {
const k = shown[0];
if (k === undefined || solo(list[k])) return toast('This page is shown alone anyway');
const s = list[runStart(k)].i;
if (shift.has(s)) shift.delete(s);
else shift.add(s);
spreads = computeSpreads();
spreadIdx = spreads.findIndex(sp => sp.includes(k));
cur = spreads[spreadIdx][0];
build(); toStart(); moved();
toast(shift.has(s) ? 'Pairs shifted by one page' : 'Pairs back to normal');
}
// ---------- bars, progress ----------
function setBars(on) {
// Stop a running smooth scroll first, otherwise it would hide the bars again right away.
if (on) stage.scrollTo({ left: stage.scrollLeft, top: stage.scrollTop });
bars = on;
document.body.classList.toggle('nobars', !on);
barsAt = stage.scrollTop;
}
function toggleBars() { setBars(!bars); }
// Reading hides the bars. The first time (per device and per kind of mode) say how to get them back.
function autoHide() {
if (!bars) return;
setBars(false);
const key = mode === 'flow' ? 'hint.flow' : 'hint.paged';
if (store.get(key)) return;
store.set(key, '1');
toast(mode === 'flow' ? 'Controls hidden · tap the middle of the screen or beside the pages to show them'
: 'Controls hidden · tap the middle of the page or beside it to show them', 3500);
}
let toastTimer = 0;
function toast(msg, ms = 900) {
const t = $('#toast');
t.textContent = msg;
t.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), ms);
}
const modeBtns = [...document.querySelectorAll('#modes button')];
function updateUI() {
const n = list.length;
$('#pageno').textContent = atEnd ? 'end' : (shown.length === 2 ? `${cur + 1}-${cur + 2}` : cur + 1) + ' / ' + n;
slider.max = n;
slider.value = cur + 1;
for (const b of modeBtns) {
b.classList.toggle('on', b.dataset.mode === mode);
b.classList.toggle('meh', !compatible.includes(b.dataset.mode));
}
const sh = $('#shift');
sh.hidden = mode !== 'book';
sh.classList.toggle('on', shown.length > 0 && !solo(list[shown[0]]) && shift.has(list[runStart(shown[0])].i));
$('#zoom').textContent = Math.round(zoom() * 100) + '%';
}
let saveTimer = 0;
function moved() {
if (mode === 'flow') {
const b = boxes[boxes.length - 1];
if (b && stage.scrollTop + stage.clientHeight >= b.y + b.h - 4) { done = true; cur = list.length - 1; }
} else if (atEnd || (mode === 'page' ? cur === list.length - 1 : spreadIdx === spreads.length - 1)) done = true;
updateUI();
clearTimeout(saveTimer);
saveTimer = setTimeout(saveProgress, 700);
}
function saveProgress(keepalive) {
clearTimeout(saveTimer);
const body = JSON.stringify({ m: D.manga.id, c: D.chapter.id, v: D.version, p: list[cur].i, n: all.length, done, shift: [...shift] });
fetch('api/progress', { method: 'POST', keepalive: !!keepalive, headers: { 'Content-Type': 'application/json' }, body }).catch(() => {});
}
addEventListener('pagehide', () => saveProgress(true));
// ---------- controls ----------
$('#title').textContent = D.manga.title;
// Chapter picker: every chapter of the manga, grouped by book when there is more than one.
const chsel = $('#chsel'), groups = {};
for (const b of D.books) {
groups[b.key] = D.books.length > 1 ? chsel.appendChild(h('optgroup', { label: b.label + (b.title ? ' · ' + b.title : '') })) : chsel;
}
for (const c of D.chapters) {
groups[c.book].append(h('option', { value: c.id }, `${c.label}${c.title ? ' · ' + c.title : ''}${c.extra ? ' (extra)' : ''}${c.done ? ' ✓' : ''}`));
}
chsel.value = D.chapter.id;
chsel.onchange = () => { saveProgress(true); location.href = readUrl(D.manga.id, chsel.value); };
$('#back').href = mangaUrl(D.manga.id);
for (const [id, c] of [['#prevch', D.prev], ['#nextch', D.next]]) {
const a = $(id);
if (c) { a.href = readUrl(D.manga.id, c.id); a.title = c.label + (c.title ? ' · ' + c.title : ''); a.onclick = () => saveProgress(true); }
else a.classList.add('disabled');
}
for (const v of D.versions) ver.append(h('option', { value: v.id }, (v.source || v.id) + (v.score ? ` (${v.score > 0 ? '+' : ''}${v.score})` : '')));
ver.value = D.version;
ver.hidden = D.versions.length < 2;
ver.onchange = () => { saveProgress(true); location.href = readUrl(D.manga.id, D.chapter.id, ver.value); };
for (const b of modeBtns) b.onclick = () => { if (b.dataset.mode !== mode) setMode(b.dataset.mode, true); };
$('#shift').onclick = toggleShift;
$('#zoom').onclick = () => setZoom(1);
slider.dir = rtl ? 'rtl' : 'ltr';
slider.addEventListener('input', () => goTo(+slider.value - 1));
function toggleFullscreen() {
if (!document.fullscreenEnabled) return;
if (document.fullscreenElement) document.exitFullscreen();
else document.documentElement.requestFullscreen().catch(() => {});
}
$('#fs').hidden = !document.fullscreenEnabled;
$('#fs').onclick = toggleFullscreen;
// Keep keyboard focus on the pages so Space/arrows never "press" the last clicked button again.
for (const bar of [$('#top'), $('#bot')]) bar.addEventListener('click', e => { if (e.target.closest('button')) stage.focus({ preventScroll: true }); });
setMode(mode, false);
stage.focus({ preventScroll: true });
setBars(true);
})();
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Settings · MangaParty</title>
<link rel="stylesheet" href="app.css">
<link rel="icon" href="icon.svg">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#121214">
<script src="common.js" defer></script>
<script src="settings.js" defer></script>
</head>
<body>
<main><p class="loading">Loading…</p></main>
</body>
</html>
+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();
}
})();