Expose Greatest source links and safe scraper inspection

This commit is contained in:
Codex committed 2026-10-09 14:53:52 +02:00
1 parent f493afcc18
commit d0275223a7
7 files changed
+238 -6

No files matched your search

+92 -2
View File
@@ -1,5 +1,5 @@
'use strict';
/* Curated catalog UI. Rating is the only write; the library and scraper are untouched. */
/* Curated catalog UI. Ratings are personal; scraper inspection is admin-only. */
(async function () {
let me, data;
try { [me, data] = await Promise.all([api('api/me'), api('api/greatest')]); } catch (error) { return showError(error); }
@@ -24,6 +24,8 @@
}
const results = h('div', { class: 'greatest-periods' });
const dialog = h('dialog', { class: 'greatest-dialog', 'aria-label': 'Manga details' });
const sourceCache = new Map();
let detailId = null;
document.body.append(dialog);
mine.onclick = () => { sortMode = 'mine'; store.set('greatest-sort', sortMode); render(); };
everyone.onclick = () => { sortMode = 'all'; store.set('greatest-sort', sortMode); render(); };
@@ -90,6 +92,7 @@
function plainNote(note) { return String(note || '').replace(/\*/g, ''); }
function openDetails(item) {
detailId = item.id;
dialog.textContent = '';
const close = h('button', { type: 'button', class: 'btn small', onclick: () => dialog.close() }, 'Close');
const links = h('div', { class: 'greatest-links' });
@@ -97,6 +100,7 @@
if (safeHref(item.source?.url)) links.append(h('a', { class: 'btn', href: item.source.url, target: '_blank', rel: 'noopener noreferrer' },
`Official ${item.source.kind || 'source'} ↗`));
if (safeHref(item.metadata?.url)) links.append(h('a', { class: 'btn', href: item.metadata.url, target: '_blank', rel: 'noopener noreferrer' }, 'AniList metadata ↗'));
const hosterPanel = h('section', { class: 'greatest-hoster-panel', 'aria-label': 'Scraper sources' });
const votes = item.votes || {};
dialog.append(
h('div', { class: 'greatest-dialog-head' }, h('span', { class: 'muted small' }, `${item.period} · curator #${item.rank}`), close),
@@ -106,9 +110,95 @@
h('h3', { class: 'greatest-reason-heading' }, 'Why it matters'),
h('p', {}, plainNote(item.note)),
h('p', { class: 'muted small' }, `Community votes: 🔥 ${votes.flame || 0} · 👍 ${votes.good || 0} · ● ${votes.neutral || 0} · 💀 ${votes.skip || 0}`),
ratingButtons(item), links,
ratingButtons(item), links, hosterPanel,
h('p', { class: 'muted small' }, 'Chapter counts and status come from public catalog metadata, or from your local library when present. Availability may vary by region.'))));
if (!dialog.open) dialog.showModal();
renderHosters(item, hosterPanel, sourceCache.get(item.id));
if (me.admin && !sourceCache.has(item.id)) refreshHosters(item, hosterPanel);
}
async function refreshHosters(item, panel) {
try {
const state = await api(`api/greatest/sources?id=${encodeURIComponent(item.id)}`);
sourceCache.set(item.id, state);
if (detailId === item.id && dialog.open) renderHosters(item, panel, state);
} catch (error) {
if (detailId === item.id && dialog.open) renderHosters(item, panel, { available: false, error: error.message, links: item.hosters || [] });
}
}
function renderHosters(item, panel, state) {
panel.textContent = '';
panel.append(h('h3', { class: 'greatest-reason-heading' }, 'MangaDex / WeebCentral'));
if (me.admin && !state) {
panel.append(h('p', { class: 'muted small' }, 'Checking scraper queue…'));
return;
}
const links = state?.links || item.hosters || [];
const jobs = state?.jobs || [];
if (!links.length) panel.append(h('p', { class: 'muted small' }, 'No confirmed series link yet. A title search may find a different edition; check it before adding.'));
for (const link of links) {
const job = jobs.find(job => job.source === link.source && job.seriesId === link.seriesId);
panel.append(h('div', { class: 'greatest-hoster-row' },
h('a', { href: link.url, target: '_blank', rel: 'noopener noreferrer' }, `${link.label} ↗`),
link.origin === 'queue title match' ? h('span', { class: 'muted small' }, 'Queue title match · verify edition') : null,
me.admin ? h('span', { class: 'muted small' }, job ? `In scraper · ${job.status.replaceAll('_', ' ')}` : state?.available === false ? 'Queue status unavailable' : 'Not in scraper queue') : null,
me.admin && state?.available !== false ? queueButton(item, link.url, job) : null));
}
if (me.admin) {
if (state?.available === false) panel.append(h('p', { class: 'small error' }, `Scraper status unavailable: ${state.error || 'unknown error'}. Queue actions are disabled.`));
const message = h('span', { class: 'small', role: 'status' });
const search = h('button', { class: 'btn small', type: 'button', onclick: async () => {
search.disabled = true; message.textContent = 'Looking up MangaDex titles…';
try {
const found = await api(`api/greatest/mangadex-search?id=${encodeURIComponent(item.id)}`);
message.textContent = '';
const results = h('div', { class: 'greatest-hoster-results' });
for (const candidate of found.results || []) {
const existing = jobs.find(job => job.source === 'mangadex' && job.seriesId === candidate.url.split('/').at(-1));
results.append(h('div', { class: 'greatest-hoster-row' },
h('a', { href: candidate.url, target: '_blank', rel: 'noopener noreferrer' }, `${candidate.title}${candidate.year ? ` (${candidate.year})` : ''} ↗`),
existing ? h('span', { class: 'muted small' }, `In scraper · ${existing.status}`) : null,
state?.available !== false ? queueButton(item, candidate.url, existing) : null));
}
if (!found.results?.length) results.append(h('p', { class: 'muted small' }, 'No MangaDex candidates found.'));
panel.append(h('p', { class: 'muted small' }, 'Search candidates are not verified matches. Check title, year, and edition before adding.'), results);
} catch (error) { message.textContent = error.message || String(error); }
finally { search.disabled = false; }
} }, 'Search MangaDex');
const refresh = h('button', { class: 'btn small', type: 'button', onclick: () => refreshHosters(item, panel) }, 'Refresh queue status');
panel.append(h('div', { class: 'greatest-hoster-tools' }, search,
h('a', { class: 'btn small', href: 'https://weebcentral.com/search', target: '_blank', rel: 'noopener noreferrer' }, 'Search WeebCentral ↗'),
refresh, message));
const manual = h('input', { type: 'url', placeholder: 'Paste a MangaDex or WeebCentral series URL', 'aria-label': 'Series URL' });
const add = h('button', { class: 'btn small', type: 'button', disabled: state?.available === false, onclick: () => inspectUrl(item, manual.value, add, message, panel) }, 'Add URL to scraper');
panel.append(h('div', { class: 'greatest-hoster-tools' }, manual, add));
}
}
function queueButton(item, url, job) {
return h('button', { class: 'btn small', type: 'button', disabled: !!job,
title: job ? `Already in scraper: ${job.status}` : 'Queue an inspection; confirm in Admin → Scraper before downloading',
onclick: async event => {
const panel = event.currentTarget.closest('.greatest-hoster-panel');
await inspectUrl(item, url, event.currentTarget, null, panel);
} }, job ? 'Already added' : 'Add to scraper');
}
async function inspectUrl(item, url, button, message, panel) {
button.disabled = true;
try {
await api('api/admin/scraper', { command: 'enqueue', url: String(url || '').trim(), title: item.title,
metadataSearch: item.title, libraryMangaId: item.library?.id, scope: { mode: 'missing' },
existingPolicy: 'skip-any', inspect: true });
if (message) message.textContent = 'Added for inspection. Confirm any download in Admin → Scraper.';
sourceCache.delete(item.id);
await refreshHosters(item, panel);
} catch (error) {
if (message) message.textContent = error.message || String(error);
else alert(error.message || String(error));
button.disabled = false;
}
}
function matches(item, q) {