GET /api/library returns `left` per manga: chapters after the newest saved position that are unfinished, unread, or were last read more than 7 days before it (a reread from the start). The continue row lists manga with left > 0, so caught-up series drop out until a new chapter arrives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
173 lines
11 KiB
JavaScript
173 lines
11 KiB
JavaScript
'use strict';
|
||
/* Library overview: searchable covers with combinable metadata filters. */
|
||
(async function () {
|
||
let me, data;
|
||
try { [me, data] = await Promise.all([api('api/me'), api('api/library')]); } catch (e) { return showError(e); }
|
||
topNav(me);
|
||
const main = $('main');
|
||
main.textContent = '';
|
||
|
||
if (!data.mangas.length) {
|
||
main.append(h('div', { class: 'empty' },
|
||
h('h1', {}, data.scanning ? 'Scanning the library…' : 'The library is empty'),
|
||
h('p', { class: 'muted' }, data.scanning ? 'This page refreshes by itself.' : 'Put manga folders into the library folder (see README.md for the format)' + (me.admin ? ', then rescan it in the settings.' : '.'))));
|
||
if (data.scanning) setTimeout(() => location.reload(), 2000);
|
||
return;
|
||
}
|
||
const storyAvailable = !!(data.reading && data.reading.activeMs > 0);
|
||
|
||
const allTags = [...new Set(data.mangas.flatMap(m => m.tags || []).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
||
const allStatuses = [...new Set(data.mangas.map(m => m.status).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
||
const requestedTags = new URLSearchParams(location.search).getAll('tag').filter(tag => allTags.includes(tag));
|
||
let rememberedTags = [];
|
||
try { rememberedTags = JSON.parse(store.get('tags') || '[]'); } catch (_) { /* ignore old invalid state */ }
|
||
const selectedTags = new Set((requestedTags.length ? requestedTags : rememberedTags).filter(tag => allTags.includes(tag)));
|
||
|
||
const filter = h('input', { type: 'search', placeholder: 'Search titles or authors…', 'aria-label': 'Search library', value: requestedTags.length ? '' : (store.get('filter') || '') });
|
||
const status = h('select', { 'aria-label': 'Filter by publication status' }, h('option', { value: '' }, 'All statuses'),
|
||
allStatuses.map(value => h('option', { value }, value)));
|
||
status.value = !requestedTags.length && allStatuses.includes(store.get('status')) ? store.get('status') : '';
|
||
const sort = h('select', { 'aria-label': 'Sort library' },
|
||
h('option', { value: 'title' }, 'Title A–Z'),
|
||
h('option', { value: 'title-desc' }, 'Title Z–A'),
|
||
h('option', { value: 'release-desc' }, 'First release · newest'),
|
||
h('option', { value: 'release-asc' }, 'First release · oldest'),
|
||
h('option', { value: 'updated' }, 'Recently updated'),
|
||
h('option', { value: 'read' }, 'Recently read'),
|
||
storyAvailable ? h('option', { value: 'timed' }, 'Recently timed') : null,
|
||
storyAvailable ? h('option', { value: 'time' }, 'Time together') : null);
|
||
const allowedSorts = ['title', 'title-desc', 'release-desc', 'release-asc', 'updated', 'read', ...(storyAvailable ? ['timed', 'time'] : [])];
|
||
sort.value = allowedSorts.includes(store.get('sort')) ? store.get('sort') : 'title';
|
||
const resultCount = h('div', { class: 'muted small result-count', 'aria-live': 'polite' });
|
||
const tagButtons = new Map();
|
||
const tagBar = h('div', { class: 'tag-filter', 'aria-label': 'Filter by tags' });
|
||
const clearTags = h('button', { class: 'filter-chip', type: 'button' }, 'All tags');
|
||
clearTags.onclick = () => { selectedTags.clear(); syncTags(); render(); };
|
||
tagBar.append(clearTags);
|
||
for (const tag of allTags) {
|
||
const button = h('button', { class: 'filter-chip', type: 'button' }, tag);
|
||
button.onclick = () => { selectedTags.has(tag) ? selectedTags.delete(tag) : selectedTags.add(tag); syncTags(); render(); };
|
||
tagButtons.set(tag, button);
|
||
tagBar.append(button);
|
||
}
|
||
const grid = h('div', { class: 'grid' });
|
||
const grouping = h('select', { 'aria-label': 'Collection display' },
|
||
h('option', { value: 'grouped' }, 'Group collections'), h('option', { value: 'expanded' }, 'Expand collections'), h('option', { value: 'individual' }, 'Individual manga'));
|
||
grouping.value = ['grouped', 'expanded', 'individual'].includes(store.get('grouping')) ? store.get('grouping') : 'grouped';
|
||
grouping.onchange = render;
|
||
// Started manga with chapters left after the last one read (first read or reread, server `left`),
|
||
// most recently read first; the same manga still appear in the grid.
|
||
const continueItems = data.mangas.filter(m => m.lastRead && m.left > 0)
|
||
.sort((a, b) => b.lastRead - a.lastRead).slice(0, 5);
|
||
const continueRow = h('section', { class: 'continue-row', 'aria-label': 'Continue where you left off', hidden: true },
|
||
h('div', { class: 'continue-banner' }, h('span', { 'aria-hidden': 'true' }, '▶'), 'Continue where you left off'),
|
||
h('div', { class: 'continue-tiles' }, continueItems.map(m => tile(m))));
|
||
if (storyAvailable) {
|
||
const most = data.mangas.find(manga => manga.id === data.reading.mostTimeMangaId);
|
||
const latest = data.mangas.find(manga => manga.id === data.reading.lastMangaId);
|
||
main.append(h('section', { class: 'reading-summary' },
|
||
h('div', { class: 'reading-summary-title' }, h('span', { 'aria-hidden': 'true' }, '⏱'), h('b', {}, 'Your Reading Story')),
|
||
h('div', { class: 'reading-summary-stats' },
|
||
h('strong', {}, readingDuration(data.reading.activeMs)),
|
||
h('span', { class: 'muted' }, `together · ${data.reading.timedChapters} chapter${data.reading.timedChapters === 1 ? '' : 's'} timed`),
|
||
data.reading.rereads ? h('span', { class: 'story-pill' }, `${data.reading.rereads} reread${data.reading.rereads === 1 ? '' : 's'}`) : null),
|
||
h('div', { class: 'reading-summary-links' },
|
||
most ? h('a', { href: mangaUrl(most.id) }, `Most time with ${most.title}`) : null,
|
||
latest && data.reading.lastChapterId ? h('a', { href: readUrl(latest.id, data.reading.lastChapterId, null, latest.lastRead) }, `Continue ${latest.title} →`) : null)));
|
||
}
|
||
main.append(
|
||
h('div', { class: 'library-controls' },
|
||
h('div', { class: 'toolbar' }, filter, status, sort, grouping,
|
||
me.admin ? h('a', { class: 'btn', href: 'collections.html' }, 'Manage collections') : null),
|
||
allTags.length ? h('div', { class: 'filter-group' },
|
||
h('div', { class: 'filter-heading' }, h('b', {}, 'Tags'), h('span', { class: 'muted small' }, 'Manga must match every selected tag')),
|
||
tagBar) : null,
|
||
resultCount),
|
||
continueRow, grid);
|
||
|
||
function syncTags() {
|
||
store.set('tags', JSON.stringify([...selectedTags]));
|
||
const params = new URLSearchParams(location.search);
|
||
params.delete('tag');
|
||
for (const tag of selectedTags) params.append('tag', tag);
|
||
const query = params.toString();
|
||
history.replaceState(null, '', location.pathname + (query ? '?' + query : ''));
|
||
}
|
||
|
||
function releaseSort(a, b, direction) {
|
||
const at = +a.firstReleaseAt || 0, bt = +b.firstReleaseAt || 0;
|
||
if (!at && !bt) return a.title.localeCompare(b.title);
|
||
if (!at) return 1;
|
||
if (!bt) return -1;
|
||
return direction * (at - bt) || a.title.localeCompare(b.title);
|
||
}
|
||
|
||
function tile(m) {
|
||
const cover = m.isCollection && m.coverMode === 'mosaic' && m.covers.length
|
||
? h('div', { class: 'cv collection-mosaic' }, m.covers.map(src => h('img', { loading: 'lazy', src, alt: '' })))
|
||
: h('div', { class: 'cv' }, m.cover ? h('img', { loading: 'lazy', src: m.cover, alt: '' }) : null);
|
||
return h('a', { class: 'tile', href: m.isCollection ? 'collection.html?' + qsa({ id: m.id }) : mangaUrl(m.id) },
|
||
cover, m.isCollection ? h('span', { class: 'collection-count' }, `${m.members.length} manga`) : null,
|
||
m.unread ? h('span', { class: 'badge' }, m.unread + ' new') : null,
|
||
h('div', { class: 't' }, m.title),
|
||
h('div', { class: 's' }, [m.firstRelease, m.status, m.read ? `${m.read} / ${m.chapters} read` : `${m.chapters} chapters`].filter(Boolean).join(' · ')),
|
||
m.read ? h('div', { class: 'prog' }, h('i', { style: `width:${Math.round((100 * m.read) / Math.max(1, m.chapters))}%` })) : null);
|
||
}
|
||
|
||
function render() {
|
||
store.set('filter', filter.value); store.set('sort', sort.value); store.set('status', status.value); store.set('grouping', grouping.value);
|
||
// Only on the plain overview: a search or status filter means the reader is looking for something else,
|
||
// and "Recently read" already puts these first. Tags and grouping keep it.
|
||
continueRow.hidden = !me.settings.continueRow || !continueItems.length || sort.value === 'read'
|
||
|| !!filter.value.trim() || !!status.value;
|
||
const q = filter.value.trim().toLowerCase();
|
||
const groups = (data.collections || []).filter(c => c.grouped);
|
||
const membership = new Set(groups.flatMap(c => c.members.map(m => m.id)));
|
||
const candidates = grouping.value === 'grouped'
|
||
? [...data.mangas.filter(m => !membership.has(m.id)), ...groups.map(c => ({ ...c, isCollection: true }))] : [...data.mangas];
|
||
const matches = m => {
|
||
const haystack = [m.title, ...(m.altTitles || []), ...(m.authors || [])].join(' ').toLowerCase();
|
||
return (!q || haystack.includes(q)) && (!status.value || m.status === status.value) &&
|
||
[...selectedTags].every(tag => (m.tags || []).includes(tag));
|
||
};
|
||
// Match complete tag combinations on a single member, not unrelated tags spread across a group.
|
||
const list = candidates.filter(m => m.isCollection ? m.members.some(matches) || (q && m.title.toLowerCase().includes(q) && !status.value && !selectedTags.size) : matches(m));
|
||
if (sort.value === 'title') list.sort((a, b) => a.title.localeCompare(b.title));
|
||
if (sort.value === 'title-desc') list.sort((a, b) => b.title.localeCompare(a.title));
|
||
if (sort.value === 'release-asc') list.sort((a, b) => releaseSort(a, b, 1));
|
||
if (sort.value === 'release-desc') list.sort((a, b) => releaseSort(a, b, -1));
|
||
if (sort.value === 'updated') list.sort((a, b) => b.updated - a.updated);
|
||
if (sort.value === 'read') list.sort((a, b) => b.lastRead - a.lastRead);
|
||
if (sort.value === 'timed') list.sort((a, b) => (b.reading?.updatedAt || 0) - (a.reading?.updatedAt || 0));
|
||
if (sort.value === 'time') list.sort((a, b) => (b.reading?.activeMs || 0) - (a.reading?.activeMs || 0));
|
||
clearTags.classList.toggle('on', !selectedTags.size);
|
||
clearTags.setAttribute('aria-pressed', String(!selectedTags.size));
|
||
for (const [tag, button] of tagButtons) {
|
||
button.classList.toggle('on', selectedTags.has(tag));
|
||
button.setAttribute('aria-pressed', String(selectedTags.has(tag)));
|
||
}
|
||
resultCount.textContent = `${list.length} results · ${data.mangas.length} manga in the library`;
|
||
grid.textContent = '';
|
||
if (grouping.value === 'expanded') {
|
||
const visible = new Set(list.map(m => m.id));
|
||
for (const c of groups) {
|
||
const members = c.members.filter(m => visible.has(m.id));
|
||
if (!members.length) continue;
|
||
grid.append(h('h2', { class: 'collection-heading' }, h('a', { href: 'collection.html?' + qsa({ id: c.id }) }, c.title)));
|
||
members.forEach(m => { grid.append(tile(m)); visible.delete(m.id); });
|
||
}
|
||
list.filter(m => visible.has(m.id)).forEach(m => grid.append(tile(m)));
|
||
return;
|
||
}
|
||
for (const m of list) {
|
||
grid.append(tile(m));
|
||
}
|
||
if (!list.length) grid.append(h('p', { class: 'muted' }, 'Nothing matches these filters.'));
|
||
}
|
||
filter.addEventListener('input', render);
|
||
status.addEventListener('change', render);
|
||
sort.addEventListener('change', render);
|
||
syncTags();
|
||
render();
|
||
})();
|