Files
MangaCircus/public/greatest.js
T

151 lines
8.6 KiB
JavaScript
Raw 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. Rating is the only write; the library and scraper are untouched. */
(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' });
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)));
if (item.cover && safeHref(item.cover)) {
const image = h('img', { src: item.cover, 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) {
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 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,
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();
}
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();
})();