Files
MangaCircus/public/peer-catalog.js

186 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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);