186 lines
17 KiB
JavaScript
186 lines
17 KiB
JavaScript
'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);
|