Checkpoint peering identity and opt-in discovery
This commit is contained in:
1 parent
eba9b50e30
commit
47de081721
44 files changed
+1557
-55
No files matched your search
+35
-9
@@ -101,7 +101,8 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
||||
.toolbar select { flex: 1; }
|
||||
.grid { grid-template-columns: repeat(2, 1fr); gap: 16px 12px; }
|
||||
.nav { padding-left: 12px; padding-right: 12px; }
|
||||
.nav { gap: 5px; }
|
||||
.nav { gap: 5px; flex-wrap: wrap; }
|
||||
.nav .grow { display: none; }
|
||||
.nav .brand { font-size: 14px; }
|
||||
.nav .btn { padding-left: 7px; padding-right: 7px; font-size: 12px; }
|
||||
.nav-greatest { font-size: 0 !important; }
|
||||
@@ -119,6 +120,11 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
||||
.chip-link { text-decoration: none; border: 1px solid transparent; }
|
||||
.chip-link:hover { border-color: var(--accent); color: #ffd8de; }
|
||||
.desc { white-space: pre-line; margin: 10px 0; }
|
||||
.coverage-indicators { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 9px; margin: 6px 0; }
|
||||
.coverage-badge { display: inline-block; border: 1px solid; border-radius: 999px; padding: 3px 9px; font-size: 12px; font-weight: 700; line-height: 1.3; }
|
||||
.coverage-complete { color: #a7f3c7; border-color: #39875d; background: #153323; }
|
||||
.coverage-partial { color: #ffd09b; border-color: #9a6630; background: #332417; }
|
||||
.coverage-unknown { color: var(--fg); border-color: var(--line); background: var(--card2); }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
.sources a { margin-right: 10px; }
|
||||
.metadata.card { max-width: none; }
|
||||
@@ -248,8 +254,16 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
||||
.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-status-ok { color: #a7f3c7; font-weight: 700; }
|
||||
.peer-status-pending { color: #ffe08a; font-weight: 650; }
|
||||
.peer-state { display: grid; gap: 3px; margin: 10px 0 14px; padding: 11px 13px; border: 1px solid var(--line); border-left-width: 4px; border-radius: 8px; background: var(--card2); }
|
||||
.peer-state strong { font-size: 14px; font-weight: 750; }
|
||||
.peer-state span { font-size: 13px; line-height: 1.4; }
|
||||
.peer-state-ok { color: #a7f3c7; border-color: #39875d; background: #153323; }
|
||||
.peer-state-pending { color: #ffe08a; border-color: #987631; background: #332b16; }
|
||||
.peer-state-warn { color: #ffd09b; border-color: #9a6630; background: #332417; }
|
||||
.peer-state-error { color: #ffb4b4; border-color: #9a3e48; background: #351b20; }
|
||||
.peer-state-neutral { color: var(--fg); border-color: var(--line); background: var(--card2); }
|
||||
.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); }
|
||||
@@ -279,6 +293,18 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
||||
.peer-catalog-toolbar > .seg .btn { flex: 1; }
|
||||
.peer-dialog-cover { max-width: 180px; }
|
||||
}
|
||||
.discovery-settings, .discovery-admin { max-width: none; }
|
||||
.discovery-hint { display: block; font-weight: 400; margin-top: 3px; }
|
||||
.discovery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 330px), 1fr)); gap: 14px; }
|
||||
.discovery-card { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 12px; margin: 0; max-width: none; min-width: 0; }
|
||||
.discovery-card > img, .discovery-no-cover { width: 90px; height: 130px; object-fit: cover; border-radius: 5px; background: var(--card2); }
|
||||
.discovery-no-cover { display: grid; place-items: center; text-align: center; color: var(--muted); font-size: 12px; }
|
||||
.discovery-card-body { min-width: 0; overflow-wrap: anywhere; }
|
||||
.discovery-card-body h2 { font-size: 17px; margin-bottom: 4px; }
|
||||
.discovery-card-body p { margin: 6px 0 10px; }
|
||||
.discovery-card-body details { margin: 10px 0; }
|
||||
.discovery-card-body ul { padding-left: 18px; }
|
||||
.discovery-actions { margin: 10px 0; }
|
||||
.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%; }
|
||||
@@ -365,6 +391,10 @@ body.reader { position: fixed; top: 0; left: 0; right: 0; bottom: 0; overflow: h
|
||||
#canvas { position: relative; }
|
||||
.pg { position: absolute; overflow: hidden; background: #19191c; }
|
||||
.pg img { display: block; width: 100%; height: 100%; -webkit-user-drag: none; }
|
||||
#canvas[data-page-tone="inverted"] .pg img { filter: invert(1); }
|
||||
#canvas[data-page-tone="sepia"] .pg img { filter: sepia(.8) brightness(.9); }
|
||||
#canvas[data-page-tone="dim"] .pg img { filter: brightness(.7); }
|
||||
#canvas[data-page-tone="extra-dim"] .pg img { filter: brightness(.45); }
|
||||
.pg.rot img { position: absolute; left: 50%; top: 50%; max-width: none; }
|
||||
/* still loading: a spinner that only appears after 150 ms, so fast loads never flash it */
|
||||
.pg.loading::after {
|
||||
@@ -406,6 +436,8 @@ body.nobars #bot { transform: translateY(100%); opacity: 0; pointer-events: none
|
||||
.seg button.meh:not(.on) { color: var(--muted); }
|
||||
.zoom-controls button { min-width: 34px; padding-left: 9px; padding-right: 9px; }
|
||||
.zoom-controls #zoom { min-width: 58px; font-variant-numeric: tabular-nums; }
|
||||
.page-tone { white-space: nowrap; }
|
||||
.page-tone:not([data-page-tone="original"]) { border-color: var(--accent); }
|
||||
.clock-chip { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.clock-chip.clock-running { border-color: #3d8b62; background: #183426; }
|
||||
.clock-chip.clock-warning { border-color: #c58a27; background: #3b2c14; }
|
||||
@@ -430,9 +462,3 @@ body.nobars #bot { transform: translateY(100%); opacity: 0; pointer-events: none
|
||||
.seg button { padding: 7px 10px; } #bot .btn { padding: 6px 10px; } .wide-only { display: none; }
|
||||
.clock-panel { bottom: calc(124px + env(safe-area-inset-bottom)); }
|
||||
}
|
||||
#canvas[data-page-tone="inverted"] .pg img { filter: invert(1); }
|
||||
#canvas[data-page-tone="sepia"] .pg img { filter: sepia(.8) brightness(.9); }
|
||||
#canvas[data-page-tone="dim"] .pg img { filter: brightness(.7); }
|
||||
#canvas[data-page-tone="extra-dim"] .pg img { filter: brightness(.45); }
|
||||
.page-tone { white-space: nowrap; }
|
||||
.page-tone:not([data-page-tone="original"]) { border-color: var(--accent); }
|
||||
+10
-2
@@ -94,15 +94,23 @@ function topNav(me) {
|
||||
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),
|
||||
...(me.admin ? [h('a', { class: 'btn', href: 'peer-catalog.html' }, 'Peer catalogs')] : []),
|
||||
h('a', { class: 'btn', href: 'settings.html' }, 'Settings'),
|
||||
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) {
|
||||
|
||||
@@ -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>Discover · MangaCircus</title>
|
||||
<link rel="stylesheet" href="app.css"><link rel="icon" href="icon.svg">
|
||||
<script src="common.js" defer></script><script src="discover.js" defer></script>
|
||||
</head>
|
||||
<body><main><p class="loading">Loading…</p></main></body>
|
||||
</html>
|
||||
@@ -0,0 +1,84 @@
|
||||
'use strict';
|
||||
(async () => {
|
||||
let me, state, adminState, active = null;
|
||||
try { me = await api('api/me'); state = await api('api/discovery'); } catch (error) { return showError(error); }
|
||||
topNav(me);
|
||||
const main = $('main'), note = h('p', { role: 'status', 'aria-live': 'polite', class: 'muted small' });
|
||||
const button = (label, action, className = '') => h('button', { type: 'button', class: `btn ${className}`, onclick: action }, label);
|
||||
async function refresh() {
|
||||
try { state = await api('api/discovery'); if (me.admin) adminState = await api('api/admin/discovery'); render(); }
|
||||
catch (error) { note.textContent = error.message; note.className = 'error'; }
|
||||
}
|
||||
async function preference(key, checked) {
|
||||
try { await api('api/settings', { [key]: checked }); await refresh(); }
|
||||
catch (error) { note.textContent = error.message; note.className = 'error'; }
|
||||
}
|
||||
async function feedback(candidateId, value) {
|
||||
try {
|
||||
await api('api/discovery/feedback', { candidateId, value });
|
||||
state.feedback[candidateId] = value;
|
||||
note.textContent = value ? 'Preference saved. The next run will use it.' : 'Preference removed.';
|
||||
render();
|
||||
} catch (error) { note.textContent = error.message; note.className = 'error'; }
|
||||
}
|
||||
async function run() {
|
||||
try {
|
||||
await api('api/admin/discovery/run', {});
|
||||
note.textContent = 'Discovery running. Existing suggestions remain visible until it finishes.';
|
||||
await refresh();
|
||||
if (active) clearInterval(active);
|
||||
active = setInterval(async () => {
|
||||
await refresh();
|
||||
if (adminState?.currentRun?.status !== 'running') { clearInterval(active); active = null; }
|
||||
}, 1500);
|
||||
} catch (error) { note.textContent = error.message; note.className = 'error'; }
|
||||
}
|
||||
function toggle(label, key, hint) {
|
||||
return h('label', { class: 'check' }, h('input', { type: 'checkbox', checked: !!state.settings[key],
|
||||
onchange: event => preference(key, event.target.checked) }),
|
||||
h('span', {}, label, h('small', { class: 'muted discovery-hint' }, hint)));
|
||||
}
|
||||
function preferences() {
|
||||
return h('section', { class: 'card discovery-settings' }, h('h2', {}, 'Your discovery preferences'),
|
||||
toggle('Personalized discovery', 'discoveryEnabled', 'Off by default. Your feed and feedback stay private to this server.'),
|
||||
toggle('Use reading activity', 'discoveryReadingActivity', 'Separately opt in to using completed chapters as a small taste signal.'),
|
||||
toggle('Include mature titles', 'discoveryMature', 'Only affects titles with a known mature tag.'));
|
||||
}
|
||||
function adminPanel() {
|
||||
if (!me.admin) return null;
|
||||
const runState = adminState?.currentRun?.status || adminState?.lastRun?.status || 'never';
|
||||
return h('section', { class: 'card discovery-admin' }, h('h2', {}, 'Discovery job'),
|
||||
h('p', { class: 'muted small' }, `Status: ${runState} · ${adminState?.candidateCount || 0} candidates · ${adminState?.optedInUsers || 0} opted-in users`),
|
||||
adminState?.lastError ? h('p', { class: 'error' }, adminState.lastError) : null,
|
||||
button('Run discovery now', run, 'primary'),
|
||||
h('p', { class: 'muted small' }, 'This first version uses cached public metadata and the curated catalog. It does not ask image hosters, call an LLM, or edit the scraper queue.'));
|
||||
}
|
||||
function card(item) {
|
||||
const candidate = item.candidate, vote = state.feedback[candidate.id] || null;
|
||||
const evidence = item.evidence || candidate.evidence.filter(entry => item.evidenceIds.includes(entry.id));
|
||||
const votes = [['interested', '🔥 Interested'], ['maybe', '👍 Maybe'], ['dismissed', 'No thanks'], ['hidden', 'Hide']];
|
||||
return h('article', { class: 'card discovery-card' },
|
||||
candidate.cover ? h('img', { src: candidate.cover, alt: '', loading: 'lazy', referrerpolicy: 'no-referrer' }) : h('div', { class: 'discovery-no-cover' }, 'No cover'),
|
||||
h('div', { class: 'discovery-card-body' },
|
||||
h('h2', {}, candidate.title),
|
||||
h('p', { class: 'muted small' }, [candidate.year || 'Year unknown', candidate.status || 'Status unknown', candidate.period].filter(Boolean).join(' · ')),
|
||||
h('p', {}, item.reason),
|
||||
h('details', {}, h('summary', {}, 'Why this is here · evidence'),
|
||||
h('ul', {}, evidence.map(entry => h('li', {}, entry.url ? h('a', { href: entry.url, target: '_blank', rel: 'noopener noreferrer' }, entry.label) : entry.label,
|
||||
entry.note ? h('span', { class: 'muted' }, ` — ${entry.note}`) : null))),
|
||||
h('p', { class: 'muted small' }, 'A recommendation does not verify a particular edition or English chapter availability.')),
|
||||
h('div', { class: 'row discovery-actions' }, votes.map(([value, label]) => button(label, () => feedback(candidate.id, vote === value ? null : value), vote === value ? 'on' : ''))),
|
||||
candidate.metadataUrl ? h('a', { class: 'small', href: candidate.metadataUrl, target: '_blank', rel: 'noopener noreferrer' }, 'Public metadata ↗') : null));
|
||||
}
|
||||
function render() {
|
||||
main.textContent = '';
|
||||
main.append(h('h1', {}, 'Discover'), h('p', { class: 'muted' }, 'A small, evidence-backed shelf of manga you might like. Suggestions are not downloads.'), note,
|
||||
preferences(), adminPanel());
|
||||
if (!state.enabled) return main.append(h('p', { class: 'muted' }, 'Turn on personalized discovery to see your suggestions.'));
|
||||
if (!state.items.length) return main.append(h('p', { class: 'muted' }, state.lastRun?.status === 'never'
|
||||
? 'No discovery run yet. An admin can run one above.' : 'No current suggestions passed the filters. Your previous feedback is retained.'));
|
||||
main.append(h('p', { class: 'muted small' }, `Last built ${new Date(state.generatedAt).toLocaleString()} · ${state.items.length} suggestions`),
|
||||
h('div', { class: 'discovery-grid' }, state.items.filter(item => !['hidden', 'dismissed'].includes(state.feedback[item.candidateId])).map(card)));
|
||||
}
|
||||
await refresh();
|
||||
})();
|
||||
@@ -30,6 +30,9 @@
|
||||
|
||||
const sources = arr(meta.sources).filter(source => source && (source.name || source.url));
|
||||
const tags = arr(meta.tags).filter(Boolean);
|
||||
const numberedChapters = M.chapters.map(chapter => /^Ch\. (\d+(?:\.\d+)?)/.exec(chapter.label || ''))
|
||||
.filter(Boolean).map(match => Number(match[1]));
|
||||
const firstNumber = numberedChapters.length ? Math.min(...numberedChapters) : null;
|
||||
const books = {};
|
||||
for (const book of M.books) books[book.key] = book;
|
||||
const selectedBooks = new Set();
|
||||
@@ -39,6 +42,9 @@
|
||||
h('h1', {}, M.title),
|
||||
arr(meta.altTitles).length ? h('div', { class: 'muted' }, arr(meta.altTitles).join(' · ')) : null,
|
||||
h('div', { class: 'muted' }, [arr(meta.authors).join(', '), meta.status, releaseDate(), TYPE[M.direction]].filter(Boolean).join(' · ')),
|
||||
firstNumber > 1 ? h('div', { class: 'coverage-indicators', role: 'status' },
|
||||
h('span', { class: 'coverage-badge coverage-partial' }, 'Series coverage partial'),
|
||||
h('span', { class: 'small' }, `This library starts at chapter ${firstNumber}; earlier numbered chapters are absent.`)) : null,
|
||||
h('div', { class: 'chips' }, tags.map(tag => h('a', { class: 'chip chip-link', href: libraryUrlForTags([tag]), title: `Show manga tagged ${tag}` }, tag))),
|
||||
meta.description ? h('p', { class: 'desc' }, meta.description) : null,
|
||||
h('div', { class: 'actions' },
|
||||
|
||||
+53
-16
@@ -127,11 +127,17 @@
|
||||
let pendingUrl = '';
|
||||
function connectionPanel(peer) {
|
||||
const remote = access.get(peer.id);
|
||||
const identityStatus = !peer.paired
|
||||
? peerState('pending', 'Identity not verified yet', 'Verify the other server after importing its invitation.')
|
||||
: remote?.error
|
||||
? peerState('error', 'Other server status unavailable', `This server is verified; refresh status to check the other side. ${remote.error}`)
|
||||
: remote?.paired
|
||||
? peerState('ok', 'Peering confirmed on both servers', 'Both servers have verified each other’s certificate.')
|
||||
: peerState('pending', 'Identity verified on this server', 'Waiting for the other server to verify this one.');
|
||||
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.'),
|
||||
identityStatus,
|
||||
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) {
|
||||
@@ -161,18 +167,31 @@
|
||||
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 grantHint = h('p', { class: 'muted small', role: 'status' });
|
||||
let grantAction = null;
|
||||
const updateGrantHint = () => {
|
||||
grantHint.textContent = !peer.paired
|
||||
? 'Granting is locked until identity pairing is complete. Finish the invitation exchange and verify this server above.'
|
||||
: (!catalog.checked && !download.checked ? 'Choose a permission above to enable Issue grant.' : '');
|
||||
grantHint.hidden = !grantHint.textContent;
|
||||
if (grantAction) grantAction.disabled = busy || !peer.paired || (!catalog.checked && !download.checked);
|
||||
};
|
||||
catalog.onchange = () => { if (!catalog.checked) download.checked = false; updateGrantHint(); };
|
||||
download.onchange = () => { if (download.checked) catalog.checked = true; updateGrantHint(); };
|
||||
updateGrantHint();
|
||||
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 : '');
|
||||
grantAction = 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'), '', !peer.paired || (!catalog.checked && !download.checked) || busy);
|
||||
updateGrantHint();
|
||||
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.`),
|
||||
localGrantStatus(peer),
|
||||
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),
|
||||
grantHint,
|
||||
grantAction,
|
||||
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),
|
||||
@@ -182,7 +201,7 @@
|
||||
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))),
|
||||
remoteGrantStatus(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')),
|
||||
@@ -200,13 +219,31 @@
|
||||
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}`;
|
||||
function peerState(tone, title, detail) {
|
||||
return h('div', { class: `peer-state peer-state-${tone}`, role: 'status', 'aria-live': 'polite' },
|
||||
h('strong', {}, title), detail ? h('span', {}, detail) : null);
|
||||
}
|
||||
function localGrantStatus(peer) {
|
||||
const scopes = peer.grantScopes || [];
|
||||
const access = scopes.includes('download') ? 'Browse and download chapters' : scopes.includes('catalog') ? 'Browse approved catalog' : '';
|
||||
if (peer.grantStatus === 'active') return peerState('ok', 'Grant active', `${access} · expires ${date(peer.grantExpiresAt)}.`);
|
||||
if (peer.grantStatus === 'paused') return peerState('pending', 'Grant paused', 'The grant is retained. Resume access to make it usable again.');
|
||||
if (peer.grantStatus === 'expired') return peerState('warn', 'Grant expired', 'Issue a new grant to restore access.');
|
||||
return peerState('neutral', 'No grant issued', 'Choose the access level below to create a grant.');
|
||||
}
|
||||
function remoteGrantStatus(value) {
|
||||
if (!value) return peerState('pending', 'Status not checked', 'Refresh to ask the other server for its current access status.');
|
||||
if (value.error) return peerState('error', 'Could not confirm access', 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)}`;
|
||||
if (!value.paired) return peerState('pending', 'Identity not verified by peer', 'Ask the other admin to verify this server.');
|
||||
if (grant.status === 'active') {
|
||||
const role = scopes.includes('download') ? 'Browse and download chapters' : 'Browse approved catalog';
|
||||
return peerState('ok', 'Active grant from peer', `${role} · expires ${date(grant.expiresAt)}.`);
|
||||
}
|
||||
if (grant.status === 'paused') return peerState('pending', 'Peer grant paused', 'The other admin can resume it without exchanging a new token.');
|
||||
if (grant.status === 'expired') return peerState('warn', 'Peer grant expired', 'Ask the other admin to issue a new grant.');
|
||||
return peerState('neutral', 'No active grant from peer', 'The other server has not granted catalog or download access.');
|
||||
}
|
||||
function limitPanel(peer) {
|
||||
const limits = peer.limits || {}, download = peer.download || {};
|
||||
|
||||
+23
-2
@@ -224,16 +224,24 @@
|
||||
const jobRow = (item, movable = false) => {
|
||||
const running = activeEntries.some(entry => entry.current.id === item.id);
|
||||
const availability = item.availability;
|
||||
const expected = availability?.expectedLastChapter;
|
||||
const sparse = /^\d+$/.test(String(expected || '')) && Number(availability?.numberedChapters) < Number(expected);
|
||||
const partialCoverage = availability?.coverage === 'partial' || sparse;
|
||||
const outcome = item.status === 'complete'
|
||||
? `${item.publishedCount ?? 0} chapter${item.publishedCount === 1 ? '' : 's'} published · finished ${when(item.completed)}`
|
||||
: item.status === 'ready' ? 'Awaiting edition confirmation' : item.status.replaceAll('_', ' ');
|
||||
return h('div', { class: 'scraper-item' + (running ? ' active' : ''), 'data-source': item.source },
|
||||
h('div', { class: 'grow' }, h('b', {}, item.title),
|
||||
item.status === 'complete' || availability ? h('div', { class: 'coverage-indicators' },
|
||||
item.status === 'complete' ? h('span', { class: 'coverage-badge coverage-complete' }, 'Hosted selection complete') : null,
|
||||
availability ? h('span', { class: 'coverage-badge ' + (partialCoverage ? 'coverage-partial' : 'coverage-unknown') },
|
||||
partialCoverage ? 'Series coverage partial' : 'Series coverage unverified') : null,
|
||||
item.status === 'complete' && !availability ? h('span', { class: 'coverage-badge coverage-unknown' }, 'Series coverage unverified') : null) : null,
|
||||
h('div', { class: 'muted small' }, `${item.source} · ${item.kind === 'discovery' ? 'Checking for updates' : item.scopeLabel || 'all missing chapters'} · ${outcome}`
|
||||
+ (item.existingPolicy === 'add-source' ? ' · alternate source' : '')),
|
||||
item.error ? h('div', { class: 'small error' }, item.error) : null,
|
||||
item.sourceTitle ? h('div', { class: 'small' }, `Source edition: ${item.sourceTitle}`) : null,
|
||||
availability ? h('div', { class: 'muted small' }, `${availability.selectedChapters} hosted selections${availability.specialReleases ? ` · ${availability.specialReleases} unnumbered releases` : ''}${availability.expectedLastChapter ? ` · metadata last chapter: ${availability.expectedLastChapter}` : ''} · ${availability.coverage === 'partial' ? 'Partial source coverage' : 'Whole-series completeness not verified'}`) : null),
|
||||
availability ? h('div', { class: 'muted small' }, `${availability.selectedChapters} hosted selections${availability.specialReleases ? ` · ${availability.specialReleases} unnumbered releases` : ''}${availability.expectedLastChapter ? ` · metadata last chapter: ${availability.expectedLastChapter}` : ''}`) : null),
|
||||
movable ? h('button', { class: 'btn small', disabled: running || pending[0]?.id === item.id, title: 'Move up', onclick: () => move(item, -1) }, '↑') : null,
|
||||
movable ? h('button', { class: 'btn small', disabled: running || pending.at(-1)?.id === item.id, title: 'Move down', onclick: () => move(item, 1) }, '↓') : null,
|
||||
item.status === 'error' ? h('button', { class: 'btn small primary', onclick: () => command({ command: 'control', action: 'retry', id: item.id }) }, 'Retry') : null,
|
||||
@@ -354,7 +362,20 @@
|
||||
return h('section', { class: 'scraper-source-card' }, h('h3', {}, adapter.label),
|
||||
h('p', { class: 'muted small' }, `${running ? 'Active' : state.sourcePaused?.[adapter.id] ? 'Paused by source protection' : 'Idle'} · ${waiting} waiting · ${problems} need attention`),
|
||||
sourceRateEditors.find(editor => editor.querySelector('b')?.textContent === adapter.label));
|
||||
}), h('p', { class: 'muted small' }, 'Each source runs sequentially with its own limits. New limits apply when that source starts or resumes a manga.'));
|
||||
}), h('p', { class: 'muted small' }, 'Each source runs sequentially with its own limits. New limits apply when that source starts or resumes a manga.'),
|
||||
h('h2', {}, 'Available-source log'),
|
||||
h('p', { class: 'muted small' }, 'A queued URL is only a lead. Catalog found, adapter compatibility and download rights are separate facts. This log does not approve or start a download.'),
|
||||
h('div', { class: 'scraper-queue' }, (state.sourceLedger || []).filter(entry => !sourceFilter || entry.source === sourceFilter).map(entry =>
|
||||
h('div', { class: 'scraper-item' }, h('div', { class: 'grow' },
|
||||
h('b', {}, entry.title || entry.sourceTitle || entry.seriesId || 'Unknown title'),
|
||||
h('div', { class: 'muted small' }, `${entry.source} · catalog ${entry.catalog} · adapter ${entry.adapter} · rights ${entry.rights}`),
|
||||
h('div', { class: 'muted small' }, `${entry.hostedCount == null ? 'Chapter count unknown' : `${entry.hostedCount} selected chapters`} · series coverage ${entry.coverage || 'unknown'} · checked ${when(entry.lastObservedAt)}`),
|
||||
entry.sourceTitle && entry.sourceTitle !== entry.title ? h('div', { class: 'small' }, `Source edition: ${entry.sourceTitle}`) : null,
|
||||
entry.reason ? h('div', { class: 'small' }, `Availability: ${entry.reason}`) : null,
|
||||
entry.lastFailure ? h('div', { class: 'small error' }, `${entry.lastFailure.kind}: ${entry.lastFailure.message}`) : null),
|
||||
entry.url && /^https?:\/\//.test(entry.url) ? h('a', { class: 'btn small', href: entry.url, target: '_blank', rel: 'noopener noreferrer' }, 'Source ↗') : null))),
|
||||
!(state.sourceLedger || []).some(entry => !sourceFilter || entry.source === sourceFilter)
|
||||
? h('p', { class: 'muted small' }, 'No source observations yet for this filter.') : null);
|
||||
card.append(activityPanel, queuePanel, updatesPanel, sourcesPanel);
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user