Files
MangaCircus/public/discover.js
T

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();
})();