121 lines
5.9 KiB
JavaScript
121 lines
5.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>',
|
|
};
|
|
|
|
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();
|
|
}
|
|
|
|
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(); };
|
|
const readUrl = (m, c, v) => 'read.html?' + qsa({ m, c, v });
|
|
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'));
|
|
}
|