'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: '', }; 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')); }