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:
Maashkinoozhe committed 2026-10-17 16:41:46 +02:00
1 parent cfb2d79103
commit f3304732fb
6 files changed
+257 -10

No files matched your search

+156 -2
View File
@@ -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);