85 lines
5.8 KiB
JavaScript
85 lines
5.8 KiB
JavaScript
'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();
|
|
})();
|