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