Discover page: refresh, harvest admin and seed reasons; docs (BL-0051)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0f87fbe948
commit
613db96355
10 files changed
+265
-58
No files matched your search
+62
-19
@@ -5,13 +5,25 @@
|
||||
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);
|
||||
const clock = iso => new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const fail = error => { note.textContent = error.message; note.className = 'error'; };
|
||||
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'; }
|
||||
catch (error) { fail(error); }
|
||||
}
|
||||
// Something is still working server-side: a scoring run, a harvest, or this user's refresh.
|
||||
const busy = () => adminState?.currentRun?.status === 'running' || !!adminState?.currentHarvest
|
||||
|| ['queued', 'running'].includes(state.refresh?.state);
|
||||
function watch() {
|
||||
if (active || !busy()) return;
|
||||
active = setInterval(async () => {
|
||||
await refresh();
|
||||
if (!busy()) { clearInterval(active); active = null; }
|
||||
}, 1500);
|
||||
}
|
||||
async function preference(key, checked) {
|
||||
try { await api('api/settings', { [key]: checked }); await refresh(); }
|
||||
catch (error) { note.textContent = error.message; note.className = 'error'; }
|
||||
catch (error) { fail(error); }
|
||||
}
|
||||
async function feedback(candidateId, value) {
|
||||
try {
|
||||
@@ -19,19 +31,27 @@
|
||||
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'; }
|
||||
} catch (error) { fail(error); }
|
||||
}
|
||||
async function run() {
|
||||
// Start a server job, then poll until it finishes; on refusal re-read the state (cooldown, pause).
|
||||
async function start(endpoint, message) {
|
||||
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'; }
|
||||
await api(endpoint, {});
|
||||
note.textContent = message; note.className = 'muted small';
|
||||
} catch (error) { fail(error); }
|
||||
await refresh();
|
||||
watch();
|
||||
}
|
||||
const run = () => start('api/admin/discovery/run', 'Discovery running. Existing suggestions remain visible until it finishes.');
|
||||
const refreshMine = () => start('api/discovery/refresh', 'Refresh queued. Your current suggestions stay visible until it finishes.');
|
||||
const harvestNow = () => start('api/admin/discovery/harvest', 'Harvest started. Suggestions are re-scored when it finishes.');
|
||||
function refreshButton() {
|
||||
const refreshState = state.refresh || { state: 'idle' };
|
||||
const label = refreshState.state === 'queued' ? `Queued (position ${refreshState.position})`
|
||||
: refreshState.state === 'running' ? 'Fetching…'
|
||||
: refreshState.blockedReason ? `Paused by AniList (${refreshState.blockedReason}) until the next nightly run`
|
||||
: refreshState.nextAllowedAt ? `Next refresh possible at ${clock(refreshState.nextAllowedAt)}` : null;
|
||||
return h('button', { type: 'button', class: 'btn discovery-refresh', disabled: !!label, onclick: refreshMine }, label || 'Refresh my suggestions');
|
||||
}
|
||||
function toggle(label, key, hint) {
|
||||
return h('label', { class: 'check' }, h('input', { type: 'checkbox', checked: !!state.settings[key],
|
||||
@@ -40,18 +60,37 @@
|
||||
}
|
||||
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('Personalized discovery', 'discoveryEnabled', 'Off by default. When on, the server sends AniList the IDs of titles you liked, without login, to find related work. 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.'));
|
||||
toggle('Include mature titles', 'discoveryMature', 'Only affects titles AniList marks as adult or with a known mature tag.'));
|
||||
}
|
||||
function harvestSettings() {
|
||||
const settings = adminState?.settings || { harvestEnabled: false, harvestAt: '03:30' };
|
||||
const enabled = h('input', { type: 'checkbox', checked: settings.harvestEnabled, onchange: save });
|
||||
const time = h('input', { type: 'time', value: settings.harvestAt, 'aria-label': 'Harvest time', required: true, onchange: save });
|
||||
async function save() {
|
||||
if (!time.value) return;
|
||||
try {
|
||||
const saved = await api('api/admin/discovery/settings', { harvestEnabled: enabled.checked, harvestAt: time.value });
|
||||
note.textContent = `Harvest settings saved (${saved.settings.harvestEnabled ? 'on' : 'off'}, ${saved.settings.harvestAt})`;
|
||||
note.className = 'muted small';
|
||||
await refresh();
|
||||
} catch (error) { fail(error); }
|
||||
}
|
||||
return h('div', { class: 'row discovery-harvest-settings' }, h('label', { class: 'check' }, enabled, h('span', {}, 'Automatic harvest')), time);
|
||||
}
|
||||
function adminPanel() {
|
||||
if (!me.admin) return null;
|
||||
const runState = adminState?.currentRun?.status || adminState?.lastRun?.status || 'never';
|
||||
const harvest = adminState?.harvest, cache = adminState?.cache;
|
||||
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`),
|
||||
h('p', { class: 'muted small' }, `Last harvest: ${harvest?.status || 'never'} · ${harvest?.requests ?? 0}/${harvest?.budget ?? 0} requests · ${harvest?.stopReason || '—'} · cache ${cache?.files ?? 0} files`),
|
||||
h('p', { class: 'muted small' }, adminState?.nextNightlyAt ? `Next nightly harvest: ${new Date(adminState.nextNightlyAt).toLocaleString()}` : 'Automatic harvest is off.'),
|
||||
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.'));
|
||||
h('div', { class: 'row' }, button('Run discovery now', run, 'primary'), button('Harvest now', harvestNow)),
|
||||
harvestSettings(),
|
||||
h('p', { class: 'muted small' }, 'Harvests ask AniList for public metadata only. They never ask image hosters, call an LLM, or edit the scraper queue.'));
|
||||
}
|
||||
function card(item) {
|
||||
const candidate = item.candidate, vote = state.feedback[candidate.id] || null;
|
||||
@@ -64,8 +103,10 @@
|
||||
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('ul', {}, (item.reasons || []).map(reason => h('li', {}, reason)),
|
||||
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,
|
||||
entry.fetchedAt ? h('span', { class: 'muted' }, ` · fetched ${new Date(entry.fetchedAt).toLocaleDateString()}`) : 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));
|
||||
@@ -75,10 +116,12 @@
|
||||
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.'));
|
||||
main.append(h('div', { class: 'row discovery-refresh-row' }, refreshButton()));
|
||||
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();
|
||||
watch();
|
||||
})();
|
||||
Reference in new issue
Block a user