Expose Greatest source links and safe scraper inspection
This commit is contained in:
1 parent
f493afcc18
commit
d0275223a7
7 files changed
+238
-6
No files matched your search
@@ -37,6 +37,12 @@
|
||||
.greatest-reason-heading { margin: 18px 0 3px; font-size: 15px; color: #e9a47d; }
|
||||
.greatest-dialog-body p { margin: 9px 0; }
|
||||
.greatest-links { display: flex; flex-wrap: wrap; gap: 8px; margin: 17px 0; }
|
||||
.greatest-hoster-panel { margin-top: 15px; border-top: 1px solid var(--line); padding-top: 4px; }
|
||||
.greatest-hoster-row, .greatest-hoster-tools { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 8px 0; }
|
||||
.greatest-hoster-row > a { overflow-wrap: anywhere; }
|
||||
.greatest-hoster-row > .btn { margin-left: auto; }
|
||||
.greatest-hoster-tools > input { flex: 1 1 220px; min-width: 0; }
|
||||
.greatest-hoster-results { max-height: 240px; overflow: auto; padding: 0 8px; border: 1px solid var(--line); border-radius: 8px; }
|
||||
@media (max-width: 680px) {
|
||||
.greatest-toolbar { align-items: stretch; flex-direction: column; }
|
||||
.greatest-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 12px; }
|
||||
|
||||
+92
-2
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user