Redesign admin peering settings and catalogs

This commit is contained in:
Codex committed 2026-10-11 01:06:03 +02:00
1 parent 492477f86a
commit da40d73f02
16 files changed
+1037 -85

No files matched your search

+63
View File
@@ -216,6 +216,69 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; }
.settings-title-row { display: flex; align-items: center; gap: 12px; }
.settings-hint { margin: 5px 0 20px; }
.settings-content .card { max-width: none; }
.peer-card { min-width: 0; overflow-wrap: anywhere; }
.peer-card textarea, .peer-card input { min-width: 0; max-width: 100%; }
.peer-card .row > .field { min-width: 0; }
.peer-name { color: #9ac8ff; font-weight: 650; white-space: nowrap; }
.peer-name.local { color: #ffc78a; }
.peer-page-tabs, .peer-section-head, .peer-catalog-heading, .peer-catalog-toolbar, .peer-catalog-actions, .peer-card-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.peer-page-tabs { margin: 0 0 16px; }
.peer-section-head, .peer-catalog-heading { justify-content: space-between; }
.peer-catalog-heading { margin-bottom: 16px; }
.peer-catalog-heading h1, .peer-section-head h2 { margin: 0; }
.peer-catalog-toolbar { padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--card); margin-bottom: 14px; }
.peer-catalog-peer { min-width: min(220px, 100%); }
.peer-catalog-actions { padding: 12px; margin: 12px 0; border: 1px solid var(--line); border-radius: 10px; background: var(--card); }
.peer-search { flex: 1 1 220px; max-width: 420px; }
.peer-catalog-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 14px; align-items: start; }
.peer-catalog-card { min-width: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--card); }
.peer-cover-wrap { position: relative; overflow: hidden; aspect-ratio: 2 / 3; background: #17181b; }
.peer-cover { display: block; width: 100%; height: 100%; object-fit: cover; }
.peer-cover-placeholder { display: grid; place-items: center; width: 100%; height: 100%; color: var(--muted); }
.peer-approval-badge { position: absolute; top: 8px; left: 8px; display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; background: #1f9d61; color: white; font-weight: 800; }
.peer-select-overlay { position: absolute; top: 8px; right: 8px; width: 32px; height: 32px; display: grid; place-items: center; border-radius: 7px; background: #101114d9; }
.peer-select-overlay input { width: 19px; height: 19px; margin: 0; accent-color: #4aa675; }
.peer-catalog-card-info { display: grid; gap: 7px; padding: 10px; }
.peer-catalog-card-info .small { min-height: 2.5em; }
.peer-title { padding: 0; border: 0; background: transparent; color: var(--fg); font: inherit; font-weight: 650; text-align: left; cursor: pointer; }
.peer-selected { border-color: #278256; color: #8fe0b2; }
.peer-catalog-message { margin: 10px 0; }
.peer-table { overflow-x: auto; }
.peer-table-row { display: grid; grid-template-columns: minmax(140px, 1.3fr) minmax(150px, 1.1fr) minmax(130px, 1fr) minmax(130px, 1fr) auto; align-items: center; gap: 12px; min-width: 720px; padding: 12px; border-bottom: 1px solid var(--line); }
.peer-table-head { color: var(--muted); font-size: 12px; text-transform: uppercase; }
.peer-row-button { width: 100%; color: var(--fg); background: transparent; border: 0; border-bottom: 1px solid var(--line); text-align: left; cursor: pointer; font: inherit; }
.peer-row-button:hover { background: #ffffff08; }
.peer-status-ok { color: #8fe0b2; }
.peer-status-pending { color: #efc36e; }
.peer-row-action { white-space: nowrap; color: var(--accent); }
.peer-wizard-grid, .peer-limits-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.peer-step { min-width: 0; padding: 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--card); }
.peer-step h3, .peer-step h4 { margin-top: 0; }
.peer-fingerprint { overflow-wrap: anywhere; }
.peer-danger-zone { padding: 16px 0; border-top: 1px solid var(--line); }
.peer-manga-dialog { width: min(860px, calc(100vw - 24px)); max-height: min(90vh, 900px); overflow: auto; color: var(--fg); background: var(--bg); border: 1px solid var(--line); border-radius: 12px; padding: 22px; }
.peer-manga-dialog::backdrop { background: #000b; }
.peer-dialog-close { float: right; font-size: 20px; }
.peer-dialog-layout { display: grid; grid-template-columns: minmax(120px, 220px) minmax(0, 1fr); gap: 20px; }
.peer-dialog-cover { width: 100%; aspect-ratio: 2 / 3; object-fit: cover; border-radius: 8px; }
.peer-chapter-list { max-height: 40vh; overflow: auto; padding-left: 24px; }
.peer-chapter-list li { padding: 4px 0; }
.peer-detail-queue { display: grid; gap: 10px; margin-top: 14px; }
.peer-detail-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.peer-chapter-picker { margin-top: 12px; border-top: 1px solid var(--line); padding-top: 10px; }
.peer-chapter-picker summary { cursor: pointer; color: var(--muted); }
.peer-chapter-picker .peer-chapter-list { display: grid; gap: 4px; margin-top: 10px; padding: 8px; border: 1px solid var(--line); border-radius: 8px; }
.peer-chapter-option { display: flex; align-items: flex-start; gap: 8px; padding: 5px; }
.peer-chapter-option input { min-height: 0; margin-top: 4px; accent-color: #4aa675; }
.peer-tags { display: flex; gap: 6px; flex-wrap: wrap; }
.peer-tags .tag { padding: 3px 8px; border-radius: 999px; background: var(--card); color: var(--muted); }
@media (max-width: 620px) {
.peer-wizard-grid, .peer-limits-grid, .peer-dialog-layout { grid-template-columns: 1fr; }
.peer-catalog-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; }
.peer-catalog-toolbar > .seg { width: 100%; }
.peer-catalog-toolbar > .seg .btn { flex: 1; }
.peer-dialog-cover { max-width: 180px; }
}
.catalog-workspace { min-width: 0; margin: 0; padding: 0; border: 0; }
.catalog-workspace > .row { margin-bottom: 16px; }
.catalog-workspace input, .catalog-workspace select, .catalog-workspace textarea { min-width: 0; max-width: 100%; }
+1
View File
@@ -99,6 +99,7 @@ function topNav(me) {
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('div', { class: 'grow' }),
h('div', { class: 'bell-wrap' }, bell, panel),
...(me.admin ? [h('a', { class: 'btn', href: 'peer-catalog.html' }, 'Peer catalogs')] : []),
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);
+10
View File
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Peer catalogs · MangaCircus</title>
<link rel="stylesheet" href="app.css"><link rel="icon" href="icon.svg">
<script src="common.js" defer></script><script src="peer-catalog.js" defer></script>
</head>
<body><main><p class="loading">Loading peer catalogs…</p></main><dialog id="peer-manga-dialog" class="peer-manga-dialog"></dialog></body>
</html>
+185
View File
@@ -0,0 +1,185 @@
'use strict';
(async function () {
const me = await api('api/me');
if (!me.admin) return showError(new Error('Peer catalogs are available to administrators only.'));
topNav(me);
const main = $('main'); main.textContent = '';
const state = await api('api/admin/peering');
const peersResult = await api('api/peering');
let peers = peersResult.peers || [], peerId = new URLSearchParams(location.search).get('peer') || peers[0]?.id || '';
const peer = () => peers.find(item => item.id === peerId);
let mode = location.hash === '#browse' ? 'browse' : 'share', localSearch = '', remoteSearch = '',
localSelected = new Set((peer()?.shares || []).map(item => item.mangaId)), remoteSelected = new Set();
let access = null, remoteItems = [], noteText = '', busy = false;
const shareDirty = () => { const saved = new Set((peer()?.shares || []).map(item => item.mangaId)); return saved.size !== localSelected.size || [...saved].some(id => !localSelected.has(id)); };
const button = (label, click, cls = '', disabled = false) => h('button', { type: 'button', class: `btn ${cls}`, onclick: click, disabled }, label);
function coverUrl(item) { return `api/peering/cover?${qsa({ peer: peerId, manga: item.id })}`; }
function tabs() {
const select = h('select', { 'aria-label': 'Select peer', onchange: async event => { peerId = event.target.value; history.replaceState(null, '', `?peer=${encodeURIComponent(peerId)}${mode === 'browse' ? '#browse' : ''}`); localSelected = new Set((peer()?.shares || []).map(item => item.mangaId)); remoteSelected.clear(); await loadRemote(); draw(); } },
peers.map(item => h('option', { value: item.id, selected: item.id === peerId }, item.alias)));
const bar = h('div', { class: 'peer-catalog-toolbar' }, h('label', { class: 'field peer-catalog-peer' }, h('span', {}, 'Peer'), select),
h('div', { class: 'seg' }, button('Share my library', () => setMode('share', true), mode === 'share' ? 'on' : ''), button('Browse peer catalog', () => setMode('browse', true), mode === 'browse' ? 'on' : '')),
button('Peer settings', () => { location.href = `peers.html#admin/peering/${encodeURIComponent(peerId)}`; }));
return bar;
}
function setMode(value, reload = false) { mode = value; history.replaceState(null, '', `?peer=${encodeURIComponent(peerId)}${value === 'browse' ? '#browse' : ''}`); if (reload) { loadRemote().then(draw); } else draw(); }
async function loadRemote() {
access = null; remoteItems = [];
if (!peer()?.paired) return;
try {
access = await api(`api/peering/access?peer=${encodeURIComponent(peerId)}`);
if (access.grant?.status === 'active' && access.grant.scopes?.includes('catalog')) {
const result = await api(`api/peering/catalog?peer=${encodeURIComponent(peerId)}`); remoteItems = result.items || [];
}
} catch (error) { access = { error: error.message }; }
}
function statusBanner() {
if (!peers.length) return h('section', { class: 'card' }, h('h2', {}, 'Add a peer first'), h('p', { class: 'muted' }, 'Connect another MangaCircus server and set up a catalog grant before sharing or browsing.'), button('Open peering settings', () => { location.href = 'peers.html#admin'; }, 'primary'));
const p = peer();
if (mode === 'browse' && !p?.paired) return h('section', { class: 'card peer-catalog-message' }, h('b', {}, `Setup is incomplete for <${p?.alias || 'peer'}>.`), h('p', { class: 'muted' }, 'Finish the two-way identity exchange in Peering settings.'), button('Open peer settings', () => { location.href = `peers.html#admin/peering/${encodeURIComponent(peerId)}`; }));
if (mode === 'browse') {
if (access?.error) return h('section', { class: 'card error' }, `Could not read ${p.alias}’s current permissions: ${access.error}`);
if (!access) return h('section', { class: 'card muted' }, 'Checking the peer’s current access grant…');
if (access.grant?.status !== 'active' || !access.grant.scopes?.includes('catalog')) return h('section', { class: 'card peer-catalog-message' },
h('b', {}, `<${p.alias}> has not granted catalog browsing.`), h('p', { class: 'muted' }, 'Ask that server’s admin to issue an active Catalog grant. Download permission is not needed to browse.'));
return null;
}
return h('section', { class: 'card peer-catalog-message' }, h('b', {}, `Choose which of your titles <${p.alias}> may see.`),
h('p', { class: 'muted' }, 'Only approved manga titles and their available chapter manifests are shared. User accounts and reading history are never included.'),
!p.paired ? h('p', { class: 'muted small' }, 'You can prepare this list now; sharing begins after identity pairing and an active Catalog grant.') : null);
}
function tile(item, kind) {
const isLocal = kind === 'local', p = peer();
const selected = isLocal ? localSelected.has(item.id) : remoteSelected.has(item.id);
const approved = isLocal && (p?.shares || []).some(share => share.mangaId === item.id);
const quickAllowed = !isLocal && p?.receive && access?.grant?.status === 'active' && access.grant.scopes?.includes('download');
const image = h('div', { class: 'peer-cover-wrap' }, item.coverAvailable && !isLocal ? h('img', { class: 'peer-cover', src: coverUrl(item), alt: '', loading: 'lazy' })
: isLocal && item.cover ? h('img', { class: 'peer-cover', src: item.cover, alt: '', loading: 'lazy' }) : h('div', { class: 'peer-cover-placeholder' }, 'No cover'),
approved ? h('span', { class: 'peer-approval-badge', title: 'Shared with this peer', 'aria-label': 'Shared with this peer' }, '✓') : null,
!isLocal ? h('label', { class: 'peer-select-overlay', title: `Select ${item.title}` }, h('input', { type: 'checkbox', checked: selected, 'aria-label': `Select ${item.title}`, onchange: event => { event.stopPropagation(); selected ? remoteSelected.delete(item.id) : remoteSelected.add(item.id); draw(); } })) : null);
const selectLocal = button(selected ? 'Will share ✓' : 'Will not share', () => {
selected ? localSelected.delete(item.id) : localSelected.add(item.id); draw();
}, selected ? 'peer-selected' : '', false);
const open = button(item.title, () => isLocal ? null : showDetails(item), 'peer-title');
return h('article', { class: 'peer-catalog-card' }, image,
h('div', { class: 'peer-catalog-card-info' }, open,
h('div', { class: 'muted small' }, [item.year, item.status, item.chapters ? `${item.chapters} chapters` : ''].filter(Boolean).join(' · ') || 'Metadata not supplied'),
isLocal ? selectLocal : h('div', { class: 'peer-card-actions' }, quickAllowed ? button('Queue missing', () => queueOne(item), 'small') : null,
button('Details', () => showDetails(item), 'small'))));
}
function libraryView() {
const p = peer();
const search = h('input', { type: 'search', value: localSearch, placeholder: 'Search your library', oninput: event => { localSearch = event.target.value; draw(); } });
const items = (state.library || []).filter(item => item.title.toLowerCase().includes(localSearch.toLowerCase()));
const selectVisible = button('Select visible', () => { for (const item of items) localSelected.add(item.id); draw(); });
const clearVisible = button('Clear visible', () => { for (const item of items) localSelected.delete(item.id); draw(); });
const save = button('Save approvals', async () => {
await runAction('Saving title approvals…', async () => { const result = await api('api/admin/peering', { command: 'share-set', id: peerId, mangaIds: [...localSelected] });
peers = result.peers || peers; localSelected = new Set((peer()?.shares || []).map(item => item.mangaId)); noteText = 'Approved titles updated.'; });
}, 'primary', !p || !shareDirty() || busy);
const toolbar = h('div', { class: 'peer-catalog-actions' }, h('label', { class: 'field peer-search' }, h('span', {}, 'Search'), search), selectVisible, clearVisible, save);
const changedCount = [...new Set([...(p?.shares || []).map(item => item.mangaId), ...localSelected])].filter(id => localSelected.has(id) !== (p?.shares || []).some(item => item.mangaId === id)).length;
const summary = h('p', { class: 'muted small' }, `${p?.shares?.length || 0} title${p?.shares?.length === 1 ? '' : 's'} approved for <${p?.alias || 'peer'}> · ${changedCount} changes`);
return h('section', {}, toolbar, summary, h('div', { class: 'peer-catalog-grid' }, items.map(item => tile(item, 'local'))));
}
function remoteView() {
const p = peer();
const search = h('input', { type: 'search', value: remoteSearch, placeholder: 'Search peer catalog', oninput: event => { remoteSearch = event.target.value; draw(); } });
const eligible = !!(p?.receive && access?.grant?.status === 'active' && access.grant.scopes?.includes('download'));
const items = remoteItems.filter(item => item.title.toLowerCase().includes(remoteSearch.toLowerCase()));
const actions = h('div', { class: 'peer-catalog-actions' }, h('label', { class: 'field peer-search' }, h('span', {}, 'Search'), search),
button('Select visible', () => { for (const item of items) remoteSelected.add(item.id); draw(); }),
button('Clear selection', () => { remoteSelected.clear(); draw(); }),
button(`Queue ${remoteSelected.size} missing`, queueSelected, 'primary', !eligible || !remoteSelected.size || busy));
const banner = h('p', { class: 'muted small' }, eligible ? 'Download permission is active. Queueing is sequential and skips existing chapters.' : 'Browsing is separate from downloads. Queue actions require an active Download grant and local receiving enabled.');
const cards = h('div', { class: 'peer-catalog-grid' }, items.map(item => tile(item, 'remote')));
return h('section', {}, actions, banner, cards);
}
async function queueOne(item, scope = { mode: 'missing' }, label = 'missing chapters') {
await runAction(`Queueing ${label} from ${item.title}…`, async () => {
const result = await api('api/peering/queue', { peer: peerId, manga: item.id, scope, existingPolicy: 'skip-any' });
noteText = `Queued ${label} from ${item.title}. ${result?.added ? `${result.added} added.` : ''}`;
});
}
async function queueSelected() {
if (!remoteSelected.size) return;
const titles = new Map(remoteItems.map(item => [item.id, item.title]));
await runAction('Queueing selected manga sequentially…', async () => {
const ids = [...remoteSelected], failed = []; let queued = 0;
for (let offset = 0; offset < ids.length; offset += 50) {
const result = await api('api/peering/queue-bulk', { peer: peerId, mangas: ids.slice(offset, offset + 50) });
queued += result.queued || 0; failed.push(...(result.results || []).filter(item => item.status !== 'queued'));
}
noteText = `${queued} manga queued; ${failed.length} unavailable or failed.`;
remoteSelected.clear();
if (failed.length) noteText += ` Check results for: ${failed.map(item => titles.get(item.id) || item.id).join(', ')}.`;
});
}
async function runAction(label, action) {
if (busy) return;
busy = true; noteText = label; draw();
try { await action(); }
catch (error) { noteText = error.message; }
finally { busy = false; await reloadCurrent(); draw(); }
}
async function reloadCurrent() { try { peers = (await api('api/peering')).peers || peers; if (mode === 'browse') await loadRemote(); } catch (error) { noteText = error.message; } }
function showDetails(item) {
const dialog = $('#peer-manga-dialog'); dialog.textContent = 'Loading manga details…';
dialog.showModal();
api(`api/peering/manga?peer=${encodeURIComponent(peerId)}&manga=${encodeURIComponent(item.id)}`).then(detail => {
dialog.textContent = '';
dialog.append(h('button', { class: 'btn peer-dialog-close', type: 'button', onclick: () => dialog.close(), 'aria-label': 'Close details' }, '×'),
h('div', { class: 'peer-dialog-layout' }, item.coverAvailable ? h('img', { class: 'peer-dialog-cover', src: coverUrl(item), alt: '' }) : null,
h('div', {}, h('h2', {}, detail.title || item.title), h('p', { class: 'muted' }, [detail.year, detail.status, detail.direction].filter(Boolean).join(' · ')),
detail.description ? h('p', {}, detail.description) : null,
[...(detail.tags || []), ...(detail.genres || [])].length ? h('p', { class: 'peer-tags' }, [...new Set([...(detail.tags || []), ...(detail.genres || [])])].map(tag => h('span', { class: 'tag' }, tag))) : null,
h('h3', {}, `${detail.chapters.length} complete chapters`),
(() => {
const scope = h('select', { 'aria-label': 'Queue scope' },
h('option', { value: 'missing' }, 'All missing chapters'), h('option', { value: 'latest' }, 'Newest chapter only'),
h('option', { value: 'last-5' }, 'Last five chapters'), h('option', { value: 'first-10' }, 'First ten chapters'),
h('option', { value: 'after' }, 'After chapter number…'),
[...new Set(detail.chapters.map(chapter => chapter.volume).filter(value => value !== null && value !== undefined && value !== ''))]
.map(volume => h('option', { value: `volume:${volume}` }, `Volume ${volume}`)));
const after = h('input', { type: 'number', min: '0', step: '0.1', value: '0', 'aria-label': 'Chapter threshold' });
const afterField = h('label', { class: 'field peer-scope-number', hidden: true }, h('span', {}, 'Chapter threshold'), after);
scope.onchange = () => { afterField.hidden = scope.value !== 'after'; };
const checks = detail.chapters.map(chapter => h('label', { class: 'peer-chapter-option' },
h('input', { type: 'checkbox', value: chapter.id, 'aria-label': `Select chapter ${chapter.number}` }),
h('span', {}, `Vol. ${chapter.volume} · Ch. ${chapter.number}${chapter.title ? ` · ${chapter.title}` : ''} · ${chapter.pageCount} pages`)));
const canQueue = !!(peer()?.receive && access?.grant?.status === 'active' && access.grant.scopes?.includes('download'));
const applyRule = () => {
const value = scope.value;
const selection = value === 'latest' ? { mode: 'latest' } : value === 'last-5' ? { mode: 'last', count: 5 }
: value === 'first-10' ? { mode: 'first', count: 10 } : value === 'after' ? { mode: 'after', chapter: after.value }
: value.startsWith('volume:') ? { mode: 'volume', volume: value.slice(7) } : { mode: 'missing' };
dialog.close(); queueOne(item, selection, scope.selectedOptions[0]?.textContent.toLowerCase() || 'selected chapters');
};
const selectedChapters = () => [...dialog.querySelectorAll('.peer-chapter-option input:checked')].map(input => input.value);
const exactButton = button('Queue checked chapters', () => { const ids = selectedChapters(); if (!ids.length) return; dialog.close(); queueOne(item, { mode: 'ids', ids }, `${ids.length} selected chapters`); }, '', true);
const refreshExactButton = () => { exactButton.disabled = !selectedChapters().length; };
for (const option of checks) option.querySelector('input').addEventListener('change', refreshExactButton);
return h('div', { class: 'peer-detail-queue' },
h('label', { class: 'field' }, h('span', {}, 'Queue scope'), scope),
afterField,
canQueue ? h('div', { class: 'peer-detail-actions' }, button('Queue with selected rule', applyRule, 'primary'), exactButton)
: h('p', { class: 'muted small' }, 'Queueing requires active Download permission and local receiving enabled.'),
h('details', { class: 'peer-chapter-picker' }, h('summary', {}, 'Choose exact chapters'), h('div', { class: 'peer-chapter-list' }, checks)));
})())));
}).catch(error => { dialog.textContent = ''; dialog.append(h('p', { class: 'error' }, error.message), button('Close', () => dialog.close())); });
}
function draw() {
main.textContent = '';
main.append(h('div', { class: 'peer-catalog-heading' }, h('div', {}, h('p', { class: 'muted small' }, 'Admin / Peering'), h('h1', {}, 'Peer catalogs'), h('p', { class: 'muted' }, 'Approve the titles you serve, or browse catalogs granted to you. These actions do not change grants.')),
button('Peering settings', () => { location.href = 'peers.html#admin'; })));
main.append(tabs());
if (noteText) main.append(h('p', { role: 'status', class: 'muted small peer-catalog-status' }, noteText));
const banner = statusBanner();
if (banner) { main.append(banner); if (mode === 'browse' || !peers.length) return; }
if (mode === 'share') main.append(libraryView()); else main.append(remoteView());
}
const libraryResponse = await api('api/admin/peering');
state.library = libraryResponse.library || [];
async function init() { await loadRemote(); draw(); }
await init();
})().catch(showError);
+10
View File
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Peering settings · MangaCircus</title>
<link rel="stylesheet" href="app.css"><link rel="icon" href="icon.svg">
<script src="common.js" defer></script><script src="settings-nav.js" defer></script><script src="peers.js" defer></script>
</head>
<body><main><p class="loading">Loading…</p></main></body>
</html>
+262
View File
@@ -0,0 +1,262 @@
'use strict';
(async function () {
let me;
try { me = await api('api/me'); } catch (error) { return showError(error); }
if (!me.admin) return showError(new Error('Peering settings are available to administrators only.'));
topNav(me);
const shell = settingsShell(me, 'admin/peering'), main = shell.content;
let state = null, peers = [], access = new Map(), route = '', busy = false;
let pendingInvitation = '', pendingGrant = null, messageText = '', messageError = false;
const note = (text, error = false) => { messageText = text; messageError = error; draw(); };
const field = (label, control, hint) => h('label', { class: 'field' }, h('span', {}, label), control,
hint ? h('small', { class: 'muted' }, hint) : null);
const button = (text, click, cls = '', disabled = false) => h('button', { class: `btn ${cls}`, type: 'button', onclick: click, disabled }, text);
const peerTag = (name, local = false) => h('span', { class: `peer-name${local ? ' local' : ''}` }, `<${name || 'Unnamed'}>${local ? ' · this server' : ''}`);
const date = value => value ? new Date(value).toLocaleString() : '—';
async function command(payload, success = 'Saved') {
if (busy) return null;
busy = true; messageText = 'Working…'; messageError = false; draw();
try {
const result = await api('api/admin/peering', payload);
if (result.identity || result.peers) state = { ...state, ...result };
messageText = success; messageError = false;
await reload(false); return result;
} catch (error) { messageText = error.message; messageError = true; draw(); return null; }
finally { busy = false; draw(); }
}
async function reload(redraw = true) {
try {
[state, peers] = await Promise.all([api('api/admin/peering'), api('api/peering').then(value => value.peers)]);
await Promise.all(peers.filter(peer => peer.paired).map(async peer => {
try { access.set(peer.id, await api(`api/peering/access?peer=${encodeURIComponent(peer.id)}`)); }
catch (error) { access.set(peer.id, { error: error.message }); }
}));
if (redraw) draw();
} catch (error) { messageText = error.message; messageError = true; if (redraw) draw(); }
}
async function refreshAccess(id) {
access.delete(id);
try { access.set(id, await api(`api/peering/access?peer=${encodeURIComponent(id)}`)); }
catch (error) { access.set(id, { error: error.message }); }
draw();
}
function setRoute(value) { location.hash = value; }
function statusLabel(peer) {
if (!peer.paired) return 'Identity not verified here';
const remote = access.get(peer.id);
if (remote?.paired) return 'Verified on both servers';
if (remote?.error) return 'Waiting for the other server';
return 'Verified here · checking other side';
}
function serverIdentityPanel() {
const identity = state?.identity, currentName = state?.name || identity?.name || '';
const panel = h('section', { class: 'card peer-card' }, h('h2', {}, 'This server'),
h('p', { class: 'muted' }, 'Choose the short name other admins will see. It is a display name; changing it does not replace this server’s certificate.'),
peerTag(currentName || 'Set a name', true));
const name = h('input', { value: currentName, maxlength: '25', pattern: '[A-Za-z0-9]{1,25}', autocomplete: 'off', placeholder: 'MangaCircus' });
panel.append(field('Server name · letters and numbers, up to 25 characters', name),
button('Save server name', () => command({ command: 'set-name', name: name.value }, 'Server name saved'), '', busy));
if (!identity) {
panel.append(h('h3', {}, 'Create this server’s certificate'),
h('p', { class: 'muted small' }, 'The certificate is this server’s durable cryptographic identity. It currently lasts one year. It is separate from the 48-hour invitation code and is not recreated when you edit the display name.'),
button('Create certificate', () => command({ command: 'generate', name: currentName }, 'Certificate created'), 'primary', !currentName || busy));
return panel;
}
panel.append(h('h3', {}, 'Server certificate'),
h('p', { class: 'muted small' }, `Valid until ${date(identity.expiresAt)}. This is the certificate lifetime; an invitation expires after 48 hours, and a grant lasts up to 90 days.`),
h('details', {}, h('summary', {}, 'Certificate fingerprint'), h('code', { class: 'peer-fingerprint' }, identity.fingerprint)));
if (state.invitations?.length) panel.append(h('p', { class: 'muted small' }, `Unexpired invitations: ${state.invitations.map(item => date(item.expiresAt)).join(' · ')}`));
return panel;
}
function peerTable() {
const panel = h('section', { class: 'card peer-card' },
h('div', { class: 'peer-section-head' }, h('div', {}, h('h2', {}, 'Known peers'), h('p', { class: 'muted small' }, 'Each row shows identity setup and permissions in both directions.')),
button('Add a peer', () => setRoute('#admin/peering/add'), 'primary')));
if (!peers.length) return panel.append(h('p', { class: 'muted' }, 'No peers yet. Add a peer to start the two-way identity exchange.')), panel;
const table = h('div', { class: 'peer-table', role: 'table', 'aria-label': 'Known peers' });
table.append(h('div', { class: 'peer-table-row peer-table-head', role: 'row' },
...['Peer', 'Connection', 'I grant them', 'They grant me', ''].map(label => h('span', { role: 'columnheader' }, label))));
for (const peer of peers) {
const remote = access.get(peer.id), mine = peer.grantStatus === 'active' ? peer.grantScopes : [];
const theirs = remote?.grant?.status === 'active' ? remote.grant.scopes : [];
const summary = scopes => scopes.includes('download') ? 'Browse + download' : scopes.includes('catalog') ? 'Browse catalog' : '—';
table.append(h('button', { class: 'peer-table-row peer-row-button', type: 'button', role: 'row', onclick: () => setRoute(`#admin/peering/${encodeURIComponent(peer.id)}`) },
h('span', { role: 'cell' }, peerTag(peer.alias)),
h('span', { role: 'cell', class: peer.paired && remote?.paired ? 'peer-status-ok' : 'peer-status-pending' }, peer.paired && remote?.paired ? '✓ Fully paired' : '○ Setup incomplete'),
h('span', { role: 'cell' }, peer.grantStatus === 'paused' ? 'Paused' : summary(mine)),
h('span', { role: 'cell' }, remote?.error ? 'Check status' : summary(theirs)),
h('span', { role: 'cell', class: 'peer-row-action' }, 'Manage →')));
}
panel.append(table); return panel;
}
function invitationWizard() {
const identity = state?.identity;
const panel = h('section', { class: 'card peer-card' }, h('div', { class: 'peer-section-head' },
h('div', {}, h('h2', {}, 'Add a peer'), h('p', { class: 'muted small' }, 'Both admins exchange one invitation, then verify the other server. Pairing sets up identity in both directions; sharing manga remains a separate choice.')),
button('Back to peers', () => setRoute('#admin/peering'))));
if (!identity) return panel.append(h('p', { class: 'error' }, 'Set this server name and create its certificate before inviting a peer.'));
const url = h('input', { type: 'url', placeholder: 'https://your-tailnet-name:8093', value: pendingUrl, class: 'grow' });
const outgoing = h('textarea', { rows: '7', readonly: true, placeholder: 'Create your invitation first. It will appear here with its 48-hour setup expiry.', 'aria-label': 'Invitation to send' }, pendingInvitation);
const incoming = h('textarea', { rows: '7', placeholder: 'Paste the invitation JSON from the other admin.', 'aria-label': 'Invitation received' });
const send = h('section', { class: 'peer-step' }, h('h3', {}, '1 · Create and send your invitation'),
h('p', { class: 'muted small' }, `Tell the other admin to use this gateway URL and send their invitation back. Your server: `, peerTag(state.name, true)),
field('This server’s reachable peer gateway URL', url, 'Use its HTTPS gateway address on port 8093. This is not the reader URL.'),
button('Create 48-hour invitation', async () => {
const parsed = (() => { try { return new URL(url.value); } catch { return null; } })();
if (!parsed || parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.search || parsed.hash || parsed.pathname !== '/') {
url.setCustomValidity('Enter an HTTPS peer gateway origin, without a path.'); url.reportValidity(); return;
}
const result = await command({ command: 'invite', url: url.value }, 'Invitation created. Send it privately to the other admin.');
if (result?.invitation) { pendingInvitation = JSON.stringify(result.invitation, null, 2); pendingUrl = url.value; draw(); }
}, '', busy), field('Invitation to send', outgoing),
button('Copy invitation', async () => { try { await navigator.clipboard.writeText(outgoing.value); note('Invitation copied. It contains a one-use setup code; share it privately.'); } catch { note('Copy failed; select and copy the invitation text.', true); } }, '', !pendingInvitation));
const receive = h('section', { class: 'peer-step' }, h('h3', {}, '2 · Receive and import their invitation'),
h('p', { class: 'muted small' }, 'This tells your server which certificate and gateway belong to the other server. Compare the displayed fingerprint with the other admin through a separate channel.'),
field('Invitation received', incoming), button('Import invitation', async () => {
let invitation; try { invitation = JSON.parse(incoming.value); } catch { note('That is not valid invitation JSON.', true); return; }
const result = await command({ command: 'import', invitation }, 'Invitation imported. Next, verify the other server’s identity.');
if (result?.peers) {
const match = result.peers.find(p => p.fingerprint === invitation.fingerprint);
if (match) setRoute(`#admin/peering/${encodeURIComponent(match.id)}`);
}
}, 'primary', busy));
panel.append(h('div', { class: 'peer-wizard-grid' }, send, receive),
h('p', { class: 'muted small' }, 'An invitation expires after 48 hours. It does not expire an established peering connection or the server certificate.'));
return panel;
}
let pendingUrl = '';
function connectionPanel(peer) {
const remote = access.get(peer.id);
const panel = h('section', { class: 'peer-step' }, h('h3', {}, 'Connection and identity'),
h('p', { class: peer.paired ? 'peer-status-ok' : 'peer-status-pending' }, peer.paired ? 'This server has verified the peer certificate.' : 'Import the other server’s invitation before verifying it.'),
h('p', { class: 'muted small' }, `Our fingerprint: ${state.identity?.fingerprint || 'certificate not created'}`),
h('p', { class: 'muted small' }, `Their fingerprint: ${peer.fingerprint}`),
h('p', { class: 'muted small' }, remote?.paired ? 'The other server has verified this server too.' : remote?.error ? `Other side: ${remote.error}` : 'The other server has not confirmed its side yet.'),
peer.paired ? null : button('Verify the other server', () => command({ command: 'probe', id: peer.id }, 'Identity verified here. Ask the other admin to verify back.'), 'primary', busy),
button('Refresh other-side status', () => refreshAccess(peer.id), '', busy));
if (!peer.paired || !remote?.paired) {
const url = h('input', { type: 'url', placeholder: 'https://this-server:8093', value: pendingUrl, class: 'grow' });
const invitation = h('textarea', { rows: '6', readonly: true, placeholder: 'Create your invitation to send to the other admin.', 'aria-label': 'Invitation to send' }, pendingInvitation);
const received = h('textarea', { rows: '5', placeholder: 'Paste the other server’s invitation JSON.', 'aria-label': 'Invitation received' });
panel.append(h('details', { class: 'peer-setup-again', open: !peer.paired }, h('summary', {}, 'Continue the two-way invitation exchange'),
h('div', { class: 'peer-wizard-grid' },
h('section', { class: 'peer-step' }, h('h4', {}, 'Send from this server'), field('Gateway URL', url),
button('Create invitation', async () => {
const parsed = (() => { try { return new URL(url.value); } catch { return null; } })();
if (!parsed || parsed.protocol !== 'https:' || parsed.pathname !== '/') return note('Enter this server’s HTTPS gateway origin.', true);
const result = await command({ command: 'invite', url: url.value });
if (result?.invitation) { pendingInvitation = JSON.stringify(result.invitation, null, 2); pendingUrl = url.value; draw(); }
}), field('JSON to send', invitation), button('Copy invitation', async () => { try { await navigator.clipboard.writeText(invitation.value); note('Invitation copied privately.'); } catch { note('Copy failed; select and copy it.', true); } }, '', !pendingInvitation)),
h('section', { class: 'peer-step' }, h('h4', {}, 'Receive from this server'), field('Their invitation', received),
button('Import invitation', async () => { let value; try { value = JSON.parse(received.value); } catch { return note('Invalid invitation JSON.', true); }
const result = await command({ command: 'import', invitation: value }, 'Invitation imported. Verify this server next.');
if (result?.peers) setRoute(`#admin/peering/${encodeURIComponent(peer.id)}`);
})))));
}
return panel;
}
function grantPanel(peer) {
const panel = h('section', { class: 'peer-step' }, h('h3', {}, 'Access between servers'),
h('div', { class: 'peer-wizard-grid' }));
const grid = panel.lastChild;
const catalog = h('input', { type: 'checkbox', checked: (peer.grantScopes || []).includes('catalog') || (peer.grantScopes || []).includes('download') });
const download = h('input', { type: 'checkbox', checked: (peer.grantScopes || []).includes('download') });
catalog.onchange = () => { if (!catalog.checked) download.checked = false; };
download.onchange = () => { if (download.checked) catalog.checked = true; };
const token = h('textarea', { rows: '3', readonly: true, placeholder: 'Issue a grant to create a one-time token.', 'aria-label': 'Grant token' }, pendingGrant?.id === peer.id ? pendingGrant.token : '');
const outbound = h('section', { class: 'peer-step' }, h('h4', {}, 'We grant access to ', peerTag(peer.alias)),
h('p', { class: 'muted small' }, `Status: ${peer.grantStatus}. Expires: ${date(peer.grantExpiresAt)}. Pausing preserves the grant; revoke only when you want to invalidate it.`),
h('label', { class: 'check' }, catalog, h('span', {}, 'Browse approved catalog')),
h('label', { class: 'check' }, download, h('span', {}, 'Download chapters (includes catalog access)')),
peer.grantStatus === 'none' || peer.grantStatus === 'expired'
? button('Issue grant', async () => { const result = await command({ command: 'grant', id: peer.id,
scopes: [...(catalog.checked ? ['catalog'] : []), ...(download.checked ? ['download'] : [])] }, 'Grant issued; send the token privately.');
if (result?.grant) { pendingGrant = { id: peer.id, token: result.grant.token }; draw(); } }, 'primary', !peer.paired || (!catalog.checked && !download.checked) || busy)
: button('Save grant permissions', () => command({ command: 'set', id: peer.id, patch: { grantScopes: [...(catalog.checked ? ['catalog'] : []), ...(download.checked ? ['download'] : [])] } }, 'Grant permissions updated'), '', (!catalog.checked && !download.checked) || busy),
peer.grantStatus === 'active' ? button('Pause access', () => command({ command: 'set', id: peer.id, patch: { servingPaused: true } }, 'Access paused; grant retained'))
: peer.grantStatus === 'paused' ? button('Resume access', () => command({ command: 'set', id: peer.id, patch: { servingPaused: false } }, 'Access resumed')) : null,
field('Grant token · copy once and send privately', token),
button('Copy grant token', async () => { try { await navigator.clipboard.writeText(token.value); note('Grant token copied privately.'); } catch { note('Copy failed; select and copy it.', true); } }, '', !token.value),
peer.grantStatus !== 'none' ? button('Revoke grant', async () => {
if (!confirm(`Revoke access for ${peer.alias}? Their saved token will stop working. You can issue a new grant later.`)) return;
pendingGrant = null; await command({ command: 'revoke-grant', id: peer.id }, 'Grant revoked.');
}, 'danger small') : null);
const inbound = h('section', { class: 'peer-step' }, h('h4', {}, peerTag(peer.alias), ' grants access to us'),
h('p', { class: 'muted small' }, remoteGrantText(access.get(peer.id))),
h('label', { class: 'check' }, h('input', { type: 'checkbox', checked: !!peer.receive, disabled: !peer.hasGrant,
onchange: event => command({ command: 'set', id: peer.id, patch: { receive: event.target.checked } }, 'Local download permission saved') }),
h('span', {}, 'Allow this server to download from them')),
field('Grant token received', h('input', { type: 'password', autocomplete: 'off', placeholder: 'Paste their grant token', 'aria-label': 'Grant token received' })),
button('Import their grant', async () => {
const input = panel.querySelector('[aria-label="Grant token received"]');
if (await command({ command: 'accept-grant', id: peer.id, token: input.value }, 'Their grant imported')) {
input.value = ''; access.delete(peer.id); await refreshAccess(peer.id);
}
}, 'primary', busy),
button('Refresh their grant status', () => refreshAccess(peer.id), '', busy),
h('p', { class: 'muted small' }, `Allow ordinary readers to request queue items: ${peer.readerQueue ? 'yes' : 'no'}.`),
h('label', { class: 'check' }, h('input', { type: 'checkbox', checked: !!peer.readerQueue,
onchange: event => command({ command: 'set', id: peer.id, patch: { readerQueue: event.target.checked } }, 'Reader queue permission saved') }),
h('span', {}, 'Let ordinary readers queue downloads')));
grid.append(outbound, inbound); return panel;
}
function remoteGrantText(value) {
if (!value) return 'Refresh to ask the other server for its current access status.';
if (value.error) return `Status unavailable: ${value.error}`;
const grant = value.grant || {};
const scopes = grant.scopes || [];
const role = scopes.includes('download') ? 'Browse + download' : scopes.includes('catalog') ? 'Browse catalog' : 'No access grant';
return `${value.paired ? 'Identity verified' : 'The other server has not verified us'} · ${role} · ${grant.status || 'unknown'} · expires ${date(grant.expiresAt)}`;
}
function limitPanel(peer) {
const limits = peer.limits || {}, download = peer.download || {};
const concurrent = h('input', { type: 'number', min: '1', max: '8', value: limits.concurrent || 2 });
const upload = h('input', { type: 'number', min: '1', max: '100', value: Math.round((limits.bytesPerSecond || 2097152) / 1048576) });
const daily = h('input', { type: 'number', min: '1', value: Math.round((limits.bytesPerDay || 10737418240) / 1073741824) });
const inFlight = h('input', { type: 'number', min: '1', max: '4', value: download.concurrent || 2 });
const inbound = h('input', { type: 'number', min: '1', max: '100', value: Math.round((download.bytesPerSecond || 2097152) / 1048576) });
return h('details', { class: 'card peer-card peer-details' }, h('summary', {}, 'Transfer limits'),
h('p', { class: 'muted small' }, `These are local caps for ${peer.alias}; the other server’s upload cap still applies.`),
h('div', { class: 'peer-limits-grid' }, field('Upload streams to them', concurrent), field('Upload MiB/s', upload), field('Upload GiB/day', daily),
field('Download pages in flight', inFlight), field('Download MiB/s', inbound)),
button('Save limits', () => command({ command: 'set', id: peer.id, patch: {
limits: { concurrent: +concurrent.value, bytesPerSecond: Math.round(+upload.value * 1048576), bytesPerDay: Math.round(+daily.value * 1073741824) },
download: { concurrent: +inFlight.value, bytesPerSecond: Math.round(+inbound.value * 1048576) },
} }, 'Transfer limits saved')));
}
function managePeer(peer) {
const remote = access.get(peer.id), name = h('input', { value: peer.alias, maxlength: '25', pattern: '[A-Za-z0-9]{1,25}' });
return h('div', { class: 'peer-workspace' },
h('div', { class: 'peer-section-head' }, h('div', {}, h('p', { class: 'muted small' }, 'Settings / Peering / Peer'), h('h2', {}, peerTag(peer.alias))),
button('Back to peers', () => setRoute('#admin/peering'))),
h('section', { class: 'card peer-card' }, h('h3', {}, 'Peer name and address'),
h('div', { class: 'peer-manage-name' }, field('Local name for this peer', name), button('Save name', () => command({ command: 'set', id: peer.id, patch: { alias: name.value } }, 'Peer name saved'))),
h('p', { class: 'muted small' }, `${peer.url} · certificate ${peer.fingerprint}`)),
connectionPanel(peer), grantPanel(peer), limitPanel(peer),
h('section', { class: 'peer-danger-zone' }, button('Remove peer', async () => {
if (!confirm(`Remove ${peer.alias}, delete its grants and pause future transfers? Already downloaded chapters remain.`)) return;
await command({ command: 'remove', id: peer.id }, 'Peer removed'); setRoute('#admin/peering');
}, 'danger small')));
}
function draw() {
const open = new Set([...main.querySelectorAll('details[data-peer-section][open]')].map(item => item.dataset.peerSection));
main.textContent = '';
const parts = location.hash.split('/');
route = parts[1] === 'peering' ? decodeURIComponent(parts[2] || '') : '';
const status = h('p', { class: messageError ? 'error' : 'muted small', role: 'status', 'aria-live': 'polite' }, messageText);
const tabs = h('div', { class: 'peer-page-tabs' }, h('a', { class: 'btn', href: 'peers.html#admin' }, 'Peering settings'),
h('a', { class: 'btn', href: 'peer-catalog.html' }, 'Peer catalogs'));
main.append(tabs, status);
if (route === 'add') main.append(invitationWizard());
else if (route) {
const peer = peers.find(item => item.id === decodeURIComponent(route));
if (!peer) main.append(button('Back to peers', () => setRoute('#admin/peering')), h('p', { class: 'muted' }, 'Peer not found.'));
else main.append(managePeer(peer));
} else main.append(serverIdentityPanel(), peerTable());
for (const details of main.querySelectorAll('details[data-peer-section]')) details.open = open.has(details.dataset.peerSection);
}
window.addEventListener('hashchange', () => { draw(); const peer = peers.find(item => item.id === decodeURIComponent(route)); if (peer) refreshAccess(peer.id); });
await reload(); draw();
const first = peers.find(peer => peer.id === decodeURIComponent(location.hash.split('/')[2] || ''));
if (first) refreshAccess(first.id);
})().catch(showError);
+2
View File
@@ -7,6 +7,8 @@ const SETTINGS_SECTIONS = [
{ id: 'user/notifications', group: 'User', title: 'Notifications', hint: 'Chapter alerts and your optional webhook.' },
{ id: 'user/account', group: 'User', title: 'Account', hint: 'Your account and password.' },
{ id: 'admin/scraper', group: 'Admin', title: 'Scraper', hint: 'Live activity, downloads, update checks and sources.' },
{ id: 'admin/peering', group: 'Admin', title: 'Peering', hint: 'Pair servers and manage their permissions.', href: 'peers.html#admin' },
{ id: 'admin/peer-catalog', group: 'Admin', title: 'Peer catalogs', hint: 'Approve titles to share and browse peer libraries.', href: 'peer-catalog.html' },
{ id: 'admin/library', group: 'Admin', title: 'Server & library', hint: 'Library location, scan status and maintenance.' },
{ id: 'admin/users', group: 'Admin', title: 'Users & access', hint: 'Manage accounts, passwords and administrators.' },
{ id: 'admin/collections', group: 'Admin', title: 'Collections & metadata', hint: 'Group manga and review their relationships.', href: 'collections.html' },