Optional Senpai integration: "Who's here" side sheet and /api/senpai proxy
- server.js: GET /api/senpai/v1/{locate,xray,characters/<ref>[/timeline],portraits/<ref>} forwarded to
SENPAI_URL (plain http/https, no dependencies) with SENPAI_TOKEN, X-Senpai-User and the user's horizon
(furthest chapter with progress, its version and furthest page); off when SENPAI_URL is unset
- progress records "top", the furthest page read in a version, so paging back does not lower the horizon
- /api/me reports senpai: true|false
- reader: Who's here button (key x) with This page / This chapter / So far, character card, timeline with
jump links, figure outlines on the page; options "only up to this page" and "show unconfirmed"
- read.html?...&p=<index> opens a page; readUrl takes p
This commit is contained in:
1 parent
cfb2d79103
commit
f3304732fb
6 files changed
+257
-10
No files matched your search
@@ -37,6 +37,8 @@ docker compose up -d --build
|
||||
| `SIGNUP` | `closed` | `open` lets anyone create an account. Otherwise the admin adds users under Settings |
|
||||
| `SCAN_INTERVAL` | `10` | Minutes between automatic library rescans (`0` = only at start and on "Rescan") |
|
||||
| `COOKIE_SECURE` | – | `1` marks the login cookie `Secure` (use it when you serve over HTTPS) |
|
||||
| `SENPAI_URL` | – | Optional: base URL of a [Senpai](#whos-here-senpai-optional) story companion, e.g. `http://senpai-api:8780`. Unset = feature off |
|
||||
| `SENPAI_TOKEN` | – | Service token for `SENPAI_URL` |
|
||||
|
||||
Serve it behind a reverse proxy with HTTPS if it is reachable from the internet. All pages use relative URLs, so a sub-path like `/manga/` works.
|
||||
|
||||
@@ -95,6 +97,17 @@ library/
|
||||
- **Progress** is saved per user and chapter version: the last page, whether the chapter is done, and book-mode shifts. The manga page offers *Continue*. Tap a chapter's status to mark it read/unread.
|
||||
- **Notifications:** press *Notify me* on a manga. When a rescan finds new chapter numbers, they show up under the bell. Optionally, the server also POSTs a short text to a webhook URL of your choice (e.g. an ntfy.sh or Gotify topic, set in Settings).
|
||||
|
||||
## Who's here (Senpai, optional)
|
||||
|
||||
With `SENPAI_URL` set, the reader's top bar gets a **Who's here** button (key `x`). It opens a side sheet with
|
||||
*This page* / *This chapter* / *So far*: the characters on the visible page(s) (hover or tap outlines them on the
|
||||
page), the cast of the chapter and of the series with their prominence, and a character card with the names known so
|
||||
far, first appearance and a timeline whose links jump to the page. Everything is limited to what you have read: the
|
||||
server forwards `GET /api/senpai/v1/…` to Senpai with your reading horizon in this manga (the furthest page of the
|
||||
furthest chapter you have opened; paging back to re-read does not lower it). Options in the sheet: *Only up to this
|
||||
page* (for re-reading) and *Show unconfirmed* (machine findings). Without `SENPAI_URL` nothing changes.
|
||||
Running both together: Senpai's `docker-compose.circus.yml` and `docs/runbook.md`.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
|
||||
@@ -104,7 +104,8 @@ This keeps ids human-readable and derivable without a database. The price: renam
|
||||
"p": 4, "n": 14, // page index in that version (all pages), page count
|
||||
"t": 1790796800000, // last activity
|
||||
"done": false, // sticky once true (reset via "mark unread")
|
||||
"shift": [] // book mode: run starts shown alone
|
||||
"shift": [], // book mode: run starts shown alone
|
||||
"top": 4 // furthest page read in this version (Senpai horizon)
|
||||
} } } },
|
||||
"follows": { "admin": { "Moon Garden": true } },
|
||||
"notes": { "admin": [ { "id": "a1b2c3d4e5f6", "t": 1790797300000, "m": "Moon Garden", "c": "2/6",
|
||||
@@ -154,6 +155,7 @@ All requests and responses are JSON. POSTs require `Content-Type: application/js
|
||||
| `POST /api/admin/rescan` `{full}` | admin | Scan now, and wait for the result |
|
||||
| `POST /api/admin/user` `{name, password, admin}` | admin | Create a user or reset a password |
|
||||
| `POST /api/admin/user/delete` `{name}` | admin | Delete a user and their data |
|
||||
| `GET /api/senpai/v1/{locate,xray,characters/<ref>[/timeline],portraits/<ref>}?m=…` | user | Optional proxy to Senpai (`SENPAI_URL`): adds `Authorization: Bearer SENPAI_TOKEN`, `X-Senpai-User` and `X-Senpai-Horizon` (furthest chapter with progress in this manga, its version and `max(p, top)`, percent-encoded). 404 when `SENPAI_URL` is unset; other paths are not forwarded |
|
||||
|
||||
### 6.3 Authentication and security
|
||||
|
||||
@@ -239,7 +241,7 @@ mode = adapt && intended ∉ compatible ? compatible[0] : intended
|
||||
| Horizontal swipe ≥ 50 px within 800 ms (paged, not zoomed in) | Previous/next |
|
||||
| Ctrl + wheel, touchpad pinch, Safari gesture events, two-finger pinch | Page zoom 20–600 %, snapping to 100 % within ±3 %, anchored at the pointer or finger midpoint. Browser zoom is suppressed only over the pages |
|
||||
| Mouse drag (moved ≥ 6 px) | Pan; the following click is swallowed. Hand cursor when the content is larger than the screen |
|
||||
| Keys | Arrows follow the direction; Space/PageDown/PageUp (paged); `+` `-` `0` zoom; `m`/Esc bars; `f` fullscreen |
|
||||
| Keys | Arrows follow the direction; Space/PageDown/PageUp (paged); `+` `-` `0` zoom; `m`/Esc bars; `f` fullscreen; `x` Who's here (Esc closes it) |
|
||||
|
||||
**Bars:**
|
||||
- Shown on open.
|
||||
@@ -272,7 +274,8 @@ docker compose up -d --build
|
||||
- **Volumes:**
|
||||
- `/library`: read-only is enough. Files must be readable by uid 1000.
|
||||
- `/data`: must be writable.
|
||||
- **Configuration:** only environment variables (`PORT`, `LIBRARY`, `DATA`, `SIGNUP`, `SCAN_INTERVAL`, `COOKIE_SECURE`).
|
||||
- **Configuration:** only environment variables (`PORT`, `LIBRARY`, `DATA`, `SIGNUP`, `SCAN_INTERVAL`, `COOKIE_SECURE`, optional `SENPAI_URL` + `SENPAI_TOKEN`).
|
||||
- **With Senpai:** the Senpai repository's `docker-compose.circus.yml` builds this branch next to Senpai and sets `SENPAI_URL=http://senpai-api:8780`; Senpai is reached only through the proxy, never by the browser.
|
||||
- **Without Docker:** `node server.js` with Node ≥ 18.
|
||||
- **Behind a reverse proxy:** terminate TLS, set `COOKIE_SECURE=1`. A sub-path works.
|
||||
- **Upgrades:** replace the code and restart. There are no migrations: unknown fields in `db.json` are kept, and missing settings fall back to defaults.
|
||||
|
||||
@@ -173,3 +173,32 @@ body.nobars #bot { transform: translateY(100%); opacity: 0; pointer-events: none
|
||||
}
|
||||
#toast.show { opacity: 1; }
|
||||
@media (max-width: 560px) { .seg button { padding: 7px 10px; } #bot .btn { padding: 6px 10px; } .wide-only { display: none; } }
|
||||
|
||||
/* Who's here (Senpai): a side sheet in the .panel style */
|
||||
.panel.sheet {
|
||||
position: fixed; top: 0; right: 0; bottom: 0; width: min(380px, 100vw); max-height: none; border-radius: 0; border-width: 0 0 0 1px;
|
||||
padding: max(6px, env(safe-area-inset-top)) 0 0; display: flex; flex-direction: column; z-index: 25;
|
||||
}
|
||||
.sheet-head { display: flex; align-items: flex-start; gap: 8px; padding: 6px 12px; }
|
||||
.sheet .seg { display: flex; margin: 2px 12px 8px; }
|
||||
.sheet .seg button { flex: 1; padding: 6px 4px; }
|
||||
.sheet-body { flex: 1; min-height: 0; overflow: auto; padding: 0 8px 10px; }
|
||||
.sheet-foot { border-top: 1px solid var(--line); padding: 8px 12px max(8px, env(safe-area-inset-bottom)); }
|
||||
.sheet-foot .check { margin: 4px 0; }
|
||||
.who { display: flex; align-items: center; gap: 10px; width: 100%; padding: 6px; border: 0; border-radius: 8px; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
||||
.who:hover { background: var(--card2); }
|
||||
.who .pic { width: 44px; height: 44px; flex: none; border-radius: 50%; object-fit: cover; object-position: top; background: var(--card2); }
|
||||
.pic.none, .big.none { display: flex; align-items: center; justify-content: center; color: var(--muted); font-weight: 700; }
|
||||
.who .nm { font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
|
||||
.who .nm.unnamed { color: var(--muted); font-style: italic; font-weight: 400; }
|
||||
.sp-chip { flex: none; font-size: 11px; border: 1px solid var(--line); border-radius: 6px; padding: 0 5px; color: var(--muted); }
|
||||
.sp-more { margin: 6px; }
|
||||
.tier { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); padding: 12px 6px 4px; }
|
||||
.sp-card { display: flex; gap: 12px; align-items: flex-start; margin: 10px 4px; }
|
||||
.sp-card h2 { margin: 0 0 6px; font-size: 18px; overflow-wrap: anywhere; }
|
||||
.sp-card .big { width: 96px; height: 120px; flex: none; border-radius: 10px; object-fit: cover; object-position: top; background: var(--card2); }
|
||||
.sp-names { margin: 0; padding-left: 22px; }
|
||||
.sp-time a { display: block; padding: 6px; border-radius: 8px; text-decoration: none; }
|
||||
.sp-time a:hover { background: var(--card2); }
|
||||
.sp-box { position: absolute; border: 3px solid var(--accent); border-radius: 6px; box-shadow: 0 0 0 2px rgba(0, 0, 0, .5); pointer-events: none; }
|
||||
#who.on { border-color: var(--accent); color: #ffb3bd; }
|
||||
+2
-1
@@ -21,6 +21,7 @@ function h(tag, props, ...kids) {
|
||||
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>',
|
||||
people: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.5"/><path d="M2.5 20a6.5 6.5 0 0 1 13 0"/><circle cx="17" cy="9" r="2.5"/><path d="M16 14.2a5 5 0 0 1 6 4.8"/></svg>',
|
||||
};
|
||||
|
||||
async function api(path, body) {
|
||||
@@ -50,7 +51,7 @@ function ago(t) {
|
||||
}
|
||||
|
||||
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 readUrl = (m, c, v, p) => 'read.html?' + qsa({ m, c, v, p });
|
||||
const mangaUrl = m => 'manga.html?' + qsa({ m });
|
||||
const safeHref = u => (/^https?:\/\//i.test(u || '') ? u : null);
|
||||
|
||||
|
||||
+156
-2
@@ -51,6 +51,11 @@
|
||||
const k = list.findIndex(x => x.i >= p);
|
||||
cur = k < 0 ? list.length - 1 : k;
|
||||
}
|
||||
// read.html?...&p=<page index> opens that page (jump links from Who's here).
|
||||
if (P.get('p') !== null && /^\d+$/.test(P.get('p'))) {
|
||||
const k = list.findIndex(x => x.i >= +P.get('p'));
|
||||
if (k >= 0) cur = 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.
|
||||
@@ -341,8 +346,10 @@
|
||||
else if (k === '+' || k === '=') setZoom(zoom() * 1.15);
|
||||
else if (k === '-') setZoom(zoom() / 1.15);
|
||||
else if (k === '0') setZoom(1);
|
||||
else if (k === 'Escape' && senpai && senpai.close()) return;
|
||||
else if (k === 'm' || k === 'Escape') toggleBars();
|
||||
else if (k === 'f') toggleFullscreen();
|
||||
else if (k === 'x' && senpai) senpai.toggle();
|
||||
});
|
||||
|
||||
let scrollRaf = 0;
|
||||
@@ -478,7 +485,7 @@
|
||||
$('#zoom').textContent = Math.round(zoom() * 100) + '%';
|
||||
}
|
||||
|
||||
let saveTimer = 0;
|
||||
let saveTimer = 0, senpai = null;
|
||||
function moved() {
|
||||
if (mode === 'flow') {
|
||||
const b = boxes[boxes.length - 1];
|
||||
@@ -487,11 +494,12 @@
|
||||
updateUI();
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(saveProgress, 700);
|
||||
if (senpai) senpai.moved();
|
||||
}
|
||||
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(() => {});
|
||||
return fetch('api/progress', { method: 'POST', keepalive: !!keepalive, headers: { 'Content-Type': 'application/json' }, body }).catch(() => {});
|
||||
}
|
||||
addEventListener('pagehide', () => saveProgress(true));
|
||||
|
||||
@@ -532,6 +540,152 @@
|
||||
// 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 }); });
|
||||
|
||||
// ---------- Who's here (Senpai, optional: only when the server has SENPAI_URL) ----------
|
||||
// A side sheet in the .panel style with three tabs and a character card. Everything comes from
|
||||
// api/senpai/v1/..., which the server answers only up to this user's reading horizon.
|
||||
function whosHere() {
|
||||
const sheet = h('aside', { class: 'panel sheet', hidden: true, 'aria-label': "Who's here" });
|
||||
const body = h('div', { class: 'sheet-body' }), sub = h('div', { class: 'muted small' });
|
||||
const tabs = [['page', 'This page'], ['chapter', 'This chapter'], ['so_far', 'So far']];
|
||||
const tabBtns = tabs.map(([id, label]) => h('button', { 'data-tab': id, onclick: () => { tab = id; store.set('senpai.tab', id); card = null; unmark(); render(); } }, label));
|
||||
const option = (key, label, on) => h('label', { class: 'check small' },
|
||||
h('input', { type: 'checkbox', checked: store.get(key) === on, onchange: e => { store.set(key, e.target.checked ? on : ''); card = null; refresh(); } }), h('span', {}, label));
|
||||
sheet.append(
|
||||
h('div', { class: 'sheet-head' }, h('div', { class: 'grow' }, h('b', {}, "Who's here"), sub),
|
||||
h('button', { class: 'btn icon', title: 'Close (Esc)', 'aria-label': 'Close', onclick: () => close() }, '×')),
|
||||
h('div', { class: 'seg' }, tabBtns), body,
|
||||
h('div', { class: 'sheet-foot' }, option('senpai.asof', 'Only up to this page (for re-reading)', 'page'), option('senpai.proposed', 'Show unconfirmed (machine) findings', '1')));
|
||||
document.body.append(sheet);
|
||||
let tab = ['page', 'chapter', 'so_far'].includes(store.get('senpai.tab')) ? store.get('senpai.tab') : 'page';
|
||||
let data = null, card = null, loadToken = 0, movedTimer = 0, marks = [];
|
||||
const opts = extra => qsa({ m: D.manga.id, asof: store.get('senpai.asof') === 'page' ? 'page' : null, proposed: store.get('senpai.proposed') === '1' ? '1' : null, ...extra });
|
||||
const pagesShown = () => (mode === 'flow' || atEnd || !shown.length ? [list[cur]] : shown.map(k => list[k])).filter(Boolean).map(p => p.i);
|
||||
const sp = path => 'api/senpai/' + path;
|
||||
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
||||
|
||||
function unmark() { for (const m of marks) m.remove(); marks = []; }
|
||||
// Outline a character's figures on the visible page(s); boxes are in page pixels.
|
||||
function mark(item) {
|
||||
unmark();
|
||||
if (!item.boxes || !item.page_size || !item.page_size[0]) return;
|
||||
const [w, hh] = item.page_size;
|
||||
for (const b of item.boxes) {
|
||||
for (const el of els) {
|
||||
if (el._p.i !== b.page || el._p.r) continue;
|
||||
const [x1, y1, x2, y2] = b.box;
|
||||
const box = h('div', { class: 'sp-box' });
|
||||
box.style.cssText = `left:${(x1 / w) * 100}%;top:${(y1 / hh) * 100}%;width:${((x2 - x1) / w) * 100}%;height:${((y2 - y1) / hh) * 100}%`;
|
||||
el.append(box);
|
||||
marks.push(box);
|
||||
}
|
||||
}
|
||||
}
|
||||
function pic(item, cls) {
|
||||
const none = () => h('div', { class: (cls || 'pic') + ' none' }, item.name ? item.name.slice(0, 1).toUpperCase() : '?');
|
||||
return item.portrait_url ? h('img', { class: cls || 'pic', src: sp(item.portrait_url), alt: '', loading: 'lazy', onerror: e => e.target.replaceWith(none()) }) : none();
|
||||
}
|
||||
function who(item, detail) {
|
||||
return h('button', { class: 'who', onclick: () => { mark(item); openCard(item); }, onmouseenter: () => mark(item), onmouseleave: () => { if (!card) unmark(); } },
|
||||
pic(item),
|
||||
h('div', { class: 'grow' }, h('div', { class: item.name ? 'nm' : 'nm unnamed' }, item.name || item.descriptor || 'unknown'), h('div', { class: 'muted small' }, detail)),
|
||||
item.prominence ? h('span', { class: 'sp-chip' }, item.prominence) : null,
|
||||
item.confirmed === false ? h('span', { class: 'sp-chip' }, 'unconfirmed') : null);
|
||||
}
|
||||
// Jump links stay inside the reader: same chapter and version -> go to the page, otherwise open it.
|
||||
function jumpLink(j, label, text) {
|
||||
const same = j.c === D.chapter.id && j.v === D.version;
|
||||
return h('a', { href: readUrl(j.m, j.c, j.v, j.p), onclick: e => {
|
||||
if (!same) { saveProgress(true); return; }
|
||||
e.preventDefault();
|
||||
const k = list.findIndex(x => x.i >= j.p);
|
||||
if (k >= 0) goTo(k);
|
||||
} }, h('div', { class: 'muted small' }, label), text ? h('div', {}, text) : null);
|
||||
}
|
||||
|
||||
function render() {
|
||||
for (const b of tabBtns) b.classList.toggle('on', b.dataset.tab === tab && !card);
|
||||
body.textContent = '';
|
||||
if (card) return renderCard();
|
||||
if (!data) return body.append(h('p', { class: 'muted' }, 'Loading…'));
|
||||
if (data.error) return body.append(h('p', { class: 'error' }, data.error));
|
||||
sub.textContent = data.horizon && data.horizon.label ? 'What you know up to ' + data.horizon.label : 'Nothing read yet';
|
||||
if (tab === 'page') {
|
||||
const named = data.page.filter(x => x.kind === 'character'), figures = data.page.filter(x => x.kind !== 'character');
|
||||
if (data.beyond_horizon) body.append(h('p', { class: 'muted' }, "You haven't read this far yet."));
|
||||
else if (!data.page.length) body.append(h('p', { class: 'muted' }, data.coverage.page_has_evidence ? 'Nobody on this page.' : 'Senpai has no notes for this page yet.'));
|
||||
for (const x of named) body.append(who(x, x.voice_only ? 'heard, not seen' : x.lines ? plural(x.lines, 'line') : 'seen'));
|
||||
if (figures.length) {
|
||||
const more = h('div', { hidden: true }, figures.map(x => who(x, x.lines ? plural(x.lines, 'line') : 'seen')));
|
||||
body.append(h('button', { class: 'btn small sp-more', onclick: () => { more.hidden = !more.hidden; } }, `${plural(figures.length, 'unknown figure')} on this page`), more);
|
||||
}
|
||||
if (data.crowd_on_page) body.append(h('p', { class: 'muted small' }, 'and a crowd'));
|
||||
} else if (tab === 'chapter') {
|
||||
if (!data.chapter.length) body.append(h('p', { class: 'muted' }, 'Senpai has no notes for this chapter up to here.'));
|
||||
for (const x of data.chapter) body.append(who(x, `${plural(x.pages, 'page')} · ${plural(x.lines, 'line')} in this chapter`));
|
||||
if (data.chapter_unnamed_figures) body.append(h('p', { class: 'muted small' }, `and ${plural(data.chapter_unnamed_figures, 'unnamed figure')}`));
|
||||
} else {
|
||||
let any = false;
|
||||
for (const [key, label] of [['lead', 'Lead'], ['frequent', 'Frequent'], ['occasional', 'Occasional'], ['minor', 'Minor']]) {
|
||||
if (!data.so_far[key].length) continue;
|
||||
any = true;
|
||||
body.append(h('div', { class: 'tier' }, label));
|
||||
for (const x of data.so_far[key]) body.append(who(x, `${plural(x.pages, 'page')} · ${plural(x.lines, 'line')} so far`));
|
||||
}
|
||||
if (!any) body.append(h('p', { class: 'muted' }, 'Senpai has no notes up to here.'));
|
||||
body.append(h('p', { class: 'muted small' }, `Based on ${plural(data.coverage.pages_so_far, 'page')} with notes, up to where you are.`));
|
||||
}
|
||||
}
|
||||
async function openCard(item) {
|
||||
const mine = (card = { item, info: null, time: null, error: null });
|
||||
render();
|
||||
try {
|
||||
const [info, time] = await Promise.all([api(sp(`v1/characters/${item.ref}?` + opts())), api(sp(`v1/characters/${item.ref}/timeline?` + opts()))]);
|
||||
Object.assign(mine, { info, time });
|
||||
} catch (e) { mine.error = e.message; }
|
||||
if (card === mine) render();
|
||||
}
|
||||
function renderCard() {
|
||||
const { item, info, time, error } = card, c = info || item;
|
||||
body.append(h('button', { class: 'btn small', onclick: () => { card = null; unmark(); render(); } }, '‹ Back'),
|
||||
h('div', { class: 'sp-card' }, pic(c, 'big'), h('div', { class: 'grow' }, h('h2', {}, c.name || c.descriptor || 'unknown'),
|
||||
c.prominence ? h('div', {}, h('span', { class: 'sp-chip' }, c.prominence)) : null,
|
||||
info ? h('div', { class: 'muted small' }, `${plural(info.counts.pages, 'page')} · ${plural(info.counts.lines, 'line')} so far`) : null)));
|
||||
if (error) return body.append(h('p', { class: 'error' }, error));
|
||||
if (!info) return body.append(h('p', { class: 'muted' }, 'Loading…'));
|
||||
if (info.names_known.length > 1) {
|
||||
body.append(h('div', { class: 'tier' }, 'Names so far'), h('ul', { class: 'sp-names' }, info.names_known.map(n => h('li', {}, n.name, h('span', { class: 'muted small' }, ' · since ' + n.since)))));
|
||||
}
|
||||
if (info.first_seen) body.append(h('div', { class: 'tier' }, 'First seen'), h('div', { class: 'sp-time' }, jumpLink(info.first_seen.jump, info.first_seen.label)));
|
||||
if (time && time.items.length) body.append(h('div', { class: 'tier' }, 'Timeline'), h('div', { class: 'sp-time' }, time.items.map(t => jumpLink(t.jump, t.label, t.text))));
|
||||
body.append(h('p', { class: 'muted small' }, 'Only what you have read, up to ' + info.horizon.label + '.'));
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const token = ++loadToken;
|
||||
await saveProgress(); // the server's horizon must include the page on screen
|
||||
let x;
|
||||
try { x = await api(sp('v1/xray?' + opts({ c: D.chapter.id, v: D.version, p: pagesShown().join(',') }))); }
|
||||
catch (e) { x = { error: e.message }; }
|
||||
if (token === loadToken) { data = x; render(); }
|
||||
}
|
||||
const btn = h('button', { id: 'who', class: 'btn', title: "Who's here (x)", 'aria-label': "Who's here", onclick: () => toggle() },
|
||||
icon(ICONS.people), h('span', { class: 'wide-only' }, ' Who’s here'));
|
||||
function show() { sheet.hidden = false; btn.classList.add('on'); card = null; data = null; render(); refresh(); }
|
||||
function close() { if (sheet.hidden) return false; sheet.hidden = true; btn.classList.remove('on'); unmark(); stage.focus({ preventScroll: true }); return true; }
|
||||
function toggle() { if (sheet.hidden) show(); else close(); }
|
||||
$('#top').insertBefore(btn, $('#fs'));
|
||||
return {
|
||||
toggle, close,
|
||||
moved() {
|
||||
if (sheet.hidden) return;
|
||||
clearTimeout(movedTimer);
|
||||
unmark();
|
||||
movedTimer = setTimeout(() => { if (!card) refresh(); }, 500);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (me.senpai) senpai = whosHere();
|
||||
|
||||
setMode(mode, false);
|
||||
stage.focus({ preventScroll: true });
|
||||
setBars(true);
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
* SIGNUP=closed|open (the very first account can always be created and becomes admin)
|
||||
* SCAN_INTERVAL=10 minutes between automatic library rescans, 0 = off
|
||||
* COOKIE_SECURE=1 mark the session cookie Secure (when served over HTTPS)
|
||||
* SENPAI_URL=http://senpai-api:8780 optional story companion ("Who's here"); off when unset
|
||||
* SENPAI_TOKEN=... service token for SENPAI_URL
|
||||
*/
|
||||
const http = require('http'), fs = require('fs'), path = require('path'), crypto = require('crypto');
|
||||
const http = require('http'), https = require('https'), fs = require('fs'), path = require('path'), crypto = require('crypto');
|
||||
const { scanLibrary } = require('./library');
|
||||
|
||||
const PORT = +process.env.PORT || 8080;
|
||||
@@ -17,6 +19,8 @@ const DATA = path.resolve(process.env.DATA || 'data');
|
||||
const OPEN_SIGNUP = process.env.SIGNUP === 'open';
|
||||
const SCAN_MINUTES = +(process.env.SCAN_INTERVAL || 10);
|
||||
const SECURE_COOKIE = process.env.COOKIE_SECURE === '1';
|
||||
const SENPAI = process.env.SENPAI_URL ? process.env.SENPAI_URL.replace(/\/?$/, '/') : '';
|
||||
const SENPAI_TOKEN = process.env.SENPAI_TOKEN || '';
|
||||
const PUB = path.join(__dirname, 'public');
|
||||
const IMG = /\.(jpe?g|png|webp|gif|avif|bmp)$/i;
|
||||
const MIME = {
|
||||
@@ -182,7 +186,7 @@ const routes = {
|
||||
res.setHeader('Set-Cookie', 'sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
|
||||
return { ok: true };
|
||||
},
|
||||
'GET /api/me': ({ u }) => ({ name: u.name, admin: !!u.admin, settings: settingsOf(u), unread: unreadOf(u) }),
|
||||
'GET /api/me': ({ u }) => ({ name: u.name, admin: !!u.admin, settings: settingsOf(u), unread: unreadOf(u), senpai: !!SENPAI }),
|
||||
|
||||
'POST /api/settings': ({ u, b }) => {
|
||||
if (typeof b.webhook === 'string' && b.webhook.trim() && !/^https?:\/\/\S+$/i.test(b.webhook.trim())) bad(400, 'The webhook must be an http(s) URL.');
|
||||
@@ -256,9 +260,12 @@ const routes = {
|
||||
'POST /api/progress': ({ u, b }) => {
|
||||
const m = mangaOf(b.m), ch = chapterOf(m, b.c);
|
||||
const all = (db.progress[u.name] = db.progress[u.name] || {}), mp = (all[m.id] = all[m.id] || {}), old = mp[ch.id] || {};
|
||||
const v = String(b.v || ''), p = Math.max(0, b.p | 0);
|
||||
mp[ch.id] = {
|
||||
v: String(b.v || ''), p: Math.max(0, b.p | 0), n: Math.max(0, b.n | 0), t: Date.now(), done: !!(old.done || b.done),
|
||||
v, p, n: Math.max(0, b.n | 0), t: Date.now(), done: !!(old.done || b.done),
|
||||
shift: Array.isArray(b.shift) ? b.shift.filter(Number.isInteger).slice(0, 1000) : old.shift || [],
|
||||
// furthest page read in this version (paging back to re-read does not lower the reading horizon)
|
||||
top: Math.max(p, old.v === v ? old.top | 0 || old.p | 0 : 0),
|
||||
};
|
||||
save();
|
||||
return { ok: true };
|
||||
@@ -318,6 +325,42 @@ const routes = {
|
||||
},
|
||||
};
|
||||
|
||||
// ---------- Senpai proxy (optional) ----------
|
||||
// GET /api/senpai/v1/... is forwarded to SENPAI_URL with the service token, the user name and the user's reading
|
||||
// horizon in this manga: the furthest chapter (in reading order) with saved progress, its version and page.
|
||||
// Senpai never sees Circus sessions, and the browser can't raise its own horizon.
|
||||
const SENPAI_PATHS = /^v1\/(locate|xray|characters\/[A-Za-z0-9-]{1,80}(\/timeline)?|portraits\/[A-Za-z0-9-]{1,80})$/;
|
||||
function horizonOf(u, m) {
|
||||
const prog = progressOf(u, m);
|
||||
let last = null;
|
||||
for (const ch of m.chapters) if (prog[ch.id]) last = { ch, p: prog[ch.id] };
|
||||
if (!last) return '';
|
||||
const enc = encodeURIComponent;
|
||||
const page = Math.max(last.p.p | 0, last.p.top | 0);
|
||||
return `m=${enc(m.id)};c=${enc(last.ch.id)};v=${enc(last.p.v || '')};p=${page};done=${last.p.done ? 1 : 0}`;
|
||||
}
|
||||
function senpaiProxy(req, res, u, rest, q) {
|
||||
if (!SENPAI) bad(404, 'Senpai is not enabled');
|
||||
if (req.method !== 'GET') bad(405, 'Method not allowed');
|
||||
if (!SENPAI_PATHS.test(rest)) bad(404, 'Unknown endpoint');
|
||||
const m = mangaOf(q.get('m'));
|
||||
const target = new URL(rest + '?' + q.toString(), SENPAI);
|
||||
const headers = { Authorization: 'Bearer ' + SENPAI_TOKEN, 'X-Senpai-User': u.name, 'X-Senpai-Horizon': horizonOf(u, m), Accept: req.headers.accept || '*/*' };
|
||||
const up = (target.protocol === 'https:' ? https : http).request(target, { method: 'GET', headers, timeout: 15000 }, r => {
|
||||
const out = { 'Content-Type': r.headers['content-type'] || 'application/octet-stream', 'Cache-Control': r.headers['cache-control'] || 'no-store' };
|
||||
if (r.headers['content-length']) out['Content-Length'] = r.headers['content-length'];
|
||||
res.writeHead(r.statusCode, out);
|
||||
r.pipe(res);
|
||||
});
|
||||
up.on('timeout', () => up.destroy(new Error('timeout')));
|
||||
up.on('error', e => {
|
||||
console.warn(`[senpai] ${e.message}`);
|
||||
if (!res.headersSent) sendJson(res, 502, { error: 'Senpai is not reachable' });
|
||||
else res.destroy();
|
||||
});
|
||||
up.end();
|
||||
}
|
||||
|
||||
// ---------- HTTP ----------
|
||||
function sendJson(res, code, obj) {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||
@@ -355,6 +398,10 @@ http.createServer(async (req, res) => {
|
||||
try {
|
||||
const { pathname, searchParams } = new URL(req.url, 'http://localhost');
|
||||
const u = userOf(req);
|
||||
if (pathname.startsWith('/api/senpai/')) {
|
||||
if (!u) bad(401, 'Please log in');
|
||||
return senpaiProxy(req, res, u, pathname.slice('/api/senpai/'.length), searchParams);
|
||||
}
|
||||
if (pathname.startsWith('/api/')) {
|
||||
const route = routes[req.method + ' ' + pathname];
|
||||
if (!route) bad(404, 'Unknown endpoint');
|
||||
@@ -371,7 +418,7 @@ http.createServer(async (req, res) => {
|
||||
if (!(e instanceof HttpError)) console.error(e);
|
||||
if (!res.headersSent) sendJson(res, e.code || 500, { error: e instanceof HttpError ? e.message : 'Internal error' });
|
||||
}
|
||||
}).listen(PORT, () => console.log(`MangaCircus on http://localhost:${PORT} (library: ${LIB}, data: ${DATA})`));
|
||||
}).listen(PORT, () => console.log(`MangaCircus on http://localhost:${PORT} (library: ${LIB}, data: ${DATA})${SENPAI ? ' senpai: ' + SENPAI : ''}`));
|
||||
|
||||
rescan();
|
||||
if (SCAN_MINUTES > 0) setInterval(() => rescan(), SCAN_MINUTES * 60000).unref();
|
||||
Reference in new issue
Block a user