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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user