A reader tab left open on one device (or restored by the browser) used to save its old chapter as the newest position as soon as it was shown again. Progress saves now carry the tab id and the newest progress time the tab knows (`seen`); the server refuses a save when another tab or device saved a newer position since. The reader then asks: jump to the newest position, or keep reading here (which makes this chapter the head again). It checks on open, when the tab becomes visible, and on every save. Reader links carry `at` and the reader keeps it in the address bar, so reloads and restored tabs still know what they had seen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
136 lines
6.6 KiB
JavaScript
136 lines
6.6 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>',
|
|
};
|
|
|
|
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 */ } },
|
|
};
|
|
// One id per browser tab (it survives navigation and reloads in that tab): progress saves carry it, so
|
|
// a reader can tell its own saves from those of another tab or device.
|
|
const tabId = (() => {
|
|
let id = null;
|
|
return () => {
|
|
if (id) return id;
|
|
try { id = sessionStorage.getItem('mc.tab'); } catch (e) { /* storage blocked */ }
|
|
if (!/^[a-zA-Z0-9_-]{8,40}$/.test(id || '')) {
|
|
id = Array.from(crypto.getRandomValues(new Uint8Array(12)), b => b.toString(16).padStart(2, '0')).join('');
|
|
try { sessionStorage.setItem('mc.tab', id); } catch (e) { /* this page keeps it in memory */ }
|
|
}
|
|
return id;
|
|
};
|
|
})();
|
|
|
|
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();
|
|
}
|
|
|
|
function readingDuration(ms, compact = false) {
|
|
const seconds = Math.max(0, Math.round((+ms || 0) / 1000));
|
|
if (!seconds) return 'Not timed';
|
|
if (seconds < 60) return compact ? '<1 min' : 'Under a minute';
|
|
const minutes = Math.max(1, Math.round(seconds / 60));
|
|
if (minutes < 60) return `${minutes} min`;
|
|
const hours = Math.floor(minutes / 60), rest = minutes % 60;
|
|
return rest ? `${hours} h ${rest} min` : `${hours} h`;
|
|
}
|
|
|
|
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(); };
|
|
// `at`: the manga's latest progress time the linking page knew, so the reader can tell it opened from a stale page.
|
|
const readUrl = (m, c, v, at) => 'read.html?' + qsa({ m, c, v, at: at || null });
|
|
const mangaUrl = m => 'manga.html?' + qsa({ m });
|
|
const libraryUrlForTags = tags => {
|
|
const p = new URLSearchParams();
|
|
for (const tag of tags || []) if (tag) p.append('tag', tag);
|
|
const query = p.toString();
|
|
return './' + (query ? '?' + query : '');
|
|
};
|
|
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 settingsLink = h('a', { class: 'btn', href: 'settings.html' }, 'Settings');
|
|
const nav = h('header', { class: 'nav' },
|
|
h('a', { class: 'brand', href: './' }, 'MangaCircus'),
|
|
h('a', { class: 'btn nav-greatest', href: 'greatest.html', title: 'Shijō Saikō no Manga · Greatest of All Time', 'aria-label': 'Shijō Saikō no Manga' }, 'Shijō Saikō no Manga'),
|
|
h('a', { class: 'btn', href: 'discover.html' }, 'Discover'),
|
|
h('div', { class: 'grow' }),
|
|
h('div', { class: 'bell-wrap' }, bell, panel),
|
|
settingsLink,
|
|
h('button', { class: 'btn', onclick: async () => { await api('api/logout', {}); location.href = 'login.html'; } }, 'Log out'));
|
|
document.body.prepend(nav);
|
|
if (me.admin) {
|
|
api('api/admin/peering').then(state => {
|
|
if (state.peers?.length && nav.isConnected) {
|
|
nav.insertBefore(h('a', { class: 'btn', href: 'peer-catalog.html' }, 'Peer catalogs'), settingsLink);
|
|
}
|
|
}).catch(() => {}).finally(() => { nav.dataset.peerCatalogResolved = 'true'; });
|
|
} else nav.dataset.peerCatalogResolved = 'true';
|
|
}
|
|
|
|
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'));
|
|
}
|