- 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
96 lines
4.9 KiB
JavaScript
96 lines
4.9 KiB
JavaScript
'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>',
|
|
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) {
|
|
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('mc.' + k); } catch (e) { return null; } },
|
|
set(k, v) { try { localStorage.setItem('mc.' + 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, p) => 'read.html?' + qsa({ m, c, v, p });
|
|
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: './' }, 'MangaCircus'),
|
|
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'));
|
|
}
|