Files
MangaCircus/public/greatest.js
MaashkinoozheandClaude Opus 5.5 e9da27e688 Greatest: serve catalog covers from a local copy (BL-0053)
The page hotlinked ~100 AniList CDN covers per load and some failed at random.
The reader now downloads each catalog cover once (sequential, ~1/s, stops on
401/403/429, images up to 2 MB) into data/greatest-covers/ and serves it from
/api/greatest-cover with a year-long browser cache; until a copy exists the
route redirects to the catalog URL. The page also accepts the server's
relative cover URLs, so owned titles show their library cover again.
GREATEST_COVER_WARM=0 turns the downloads off (tests, offline installs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 00:38:55 +01:00

243 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* 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); }
topNav(me);
const main = $('main'); main.textContent = '';
let sortMode = store.get('greatest-sort') === 'all' ? 'all' : 'mine';
let filterMode = 'all';
const query = h('input', { type: 'search', placeholder: 'Search the collection…', 'aria-label': 'Search Greatest of All Time' });
const count = h('span', { class: 'muted small', 'aria-live': 'polite' });
const mine = h('button', { type: 'button', class: 'btn small' }, 'Rank by my vote');
const everyone = h('button', { type: 'button', class: 'btn small' }, 'Rank by everyone');
const filterBar = h('div', { class: 'greatest-filters', 'aria-label': 'Filter by your rating' });
const filters = [
['all', 'All'], ['rated', 'Rated'], ['flame', '🔥 Flame'], ['good', '👍 Looks good'],
['neutral', '● Neutral'], ['skip', '💀 Not for me'], ['unrated', 'Unrated'], ['owned', '📚 In library'],
];
const filterButtons = new Map();
for (const [value, label] of filters) {
const button = h('button', { type: 'button', class: 'filter-chip' }, label);
button.onclick = () => { filterMode = value; render(); };
filterButtons.set(value, button); filterBar.append(button);
}
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(); };
query.oninput = render;
main.append(
h('div', { class: 'greatest-intro' },
h('p', { class: 'greatest-eyebrow' }, 'Greatest of All Time'),
h('h1', {}, data.title || 'Shijō Saikō no Manga'),
h('p', { class: 'muted' }, '史上最高の漫画 · A curated journey through manga history. Your votes are private to your account; the community ranking combines everyone’s votes.'),
h('p', { class: 'muted small' }, '🔥 Definitely want · 👍 Looks good · ● Neutral · 💀 Not for me · 📚 Already in your library')),
h('div', { class: 'greatest-toolbar' }, query, h('div', { class: 'greatest-sort' }, mine, everyone)),
filterBar, count, results);
function ratingButtons(item, compact = false) {
const bar = h('div', { class: 'greatest-votes' + (compact ? ' compact' : ''), 'aria-label': `Rate ${item.title}` });
for (const [value, symbol, label] of [
['flame', '🔥', 'Definitely want'], ['good', '👍', 'Looks good'],
['neutral', '●', 'Neutral'], ['skip', '💀', 'Not for me'],
]) {
const selected = item.myRating === value;
const button = h('button', { type: 'button', class: 'greatest-vote' + (selected ? ' selected' : ''),
'aria-label': `${label}: ${item.title}`, 'aria-pressed': String(selected), title: label }, symbol);
button.onclick = async event => {
event.stopPropagation(); button.disabled = true;
try {
const next = selected ? null : value;
const saved = await api('api/greatest/rating', { id: item.id, rating: next });
Object.assign(item, saved);
render();
if (dialog.open) openDetails(item);
} catch (error) { alert(error.message || String(error)); button.disabled = false; }
};
bar.append(button);
}
return bar;
}
function cover(item) {
const box = h('span', { class: 'greatest-cover' }, h('span', { class: 'greatest-cover-fallback', 'aria-hidden': 'true' }, item.title.slice(0, 1)));
// Covers come from the server (cached catalog covers, owned library covers) as relative URLs.
const src = /^(api\/greatest-cover\?|lib\/)/.test(item.cover || '') ? item.cover : safeHref(item.cover);
if (src) {
const image = h('img', { src, alt: '', loading: 'lazy' });
image.onerror = () => image.remove();
box.append(image);
}
return box;
}
function tile(item) {
const open = h('button', { type: 'button', class: 'greatest-open', 'aria-label': `Details for ${item.title}` },
cover(item),
item.library ? h('span', { class: 'greatest-owned', title: 'Already in your library' }, '📚 In library') : null,
h('span', { class: 'greatest-name' }, item.title),
h('span', { class: 'greatest-meta' }, `${item.year} · ${item.chapters == null ? 'Chapters unknown' : `${item.chapters} chapters`}`),
h('span', { class: 'greatest-meta' }, statusLabel(item.status)),
h('span', { class: 'greatest-reason-label' }, 'Why it matters'),
h('span', { class: 'greatest-reason' }, plainNote(item.note)));
open.onclick = () => openDetails(item);
return h('article', { class: 'greatest-tile' }, open, ratingButtons(item, true));
}
function statusLabel(status) {
return status === 'ongoing' ? 'Ongoing' : status === 'completed' ? 'Completed' : 'Status unknown';
}
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' });
if (item.library) links.append(h('a', { class: 'btn primary', href: mangaUrl(item.library.id) }, 'Open in library'));
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),
h('div', { class: 'greatest-dialog-body' }, cover(item), h('div', {},
h('h2', {}, item.title),
h('p', { class: 'muted' }, `${item.year} · ${item.chapters == null ? 'Chapter count unknown' : `${item.chapters} chapters`} · ${statusLabel(item.status)}`),
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, 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) {
if (q && !`${item.title} ${item.note || ''}`.toLowerCase().includes(q)) return false;
if (filterMode === 'rated') return !!item.myRating;
if (filterMode === 'unrated') return !item.myRating;
if (filterMode === 'owned') return !!item.library;
if (['flame', 'good', 'neutral', 'skip'].includes(filterMode)) return item.myRating === filterMode;
return true;
}
function render() {
mine.classList.toggle('on', sortMode === 'mine'); everyone.classList.toggle('on', sortMode === 'all');
mine.setAttribute('aria-pressed', String(sortMode === 'mine'));
everyone.setAttribute('aria-pressed', String(sortMode === 'all'));
for (const [value, button] of filterButtons) {
button.classList.toggle('on', filterMode === value);
button.setAttribute('aria-pressed', String(filterMode === value));
}
results.textContent = '';
const q = query.value.trim().toLowerCase();
let visible = 0;
for (const period of data.periods) {
const items = data.entries.filter(item => item.period === period && matches(item, q));
if (!items.length) continue;
items.sort((a, b) => sortMode === 'all'
? b.score - a.score || a.rank - b.rank
: (ratingScore(b.myRating) - ratingScore(a.myRating)) || a.rank - b.rank);
visible += items.length;
results.append(h('section', { class: 'greatest-period' },
h('div', { class: 'greatest-period-heading' }, h('h2', {}, period), h('span', { class: 'muted small' }, `${items.length} manga`)),
h('div', { class: 'greatest-grid' }, items.map(tile))));
}
count.textContent = `${visible} of ${data.entries.length} manga · ${sortMode === 'all' ? 'community score' : 'your vote'} within each period`;
if (!visible) results.append(h('p', { class: 'empty muted' }, 'No manga match these filters.'));
}
function ratingScore(value) { return { flame: 3, good: 1, neutral: 0, skip: -2 }[value] ?? -3; }
render();
})();