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
+175
@@ -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; } }
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
})();
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
Reference in new issue
Block a user