Files
MangaCircus/public/library.js
MaashkinoozheandClaude Opus 5.5 9b529d7709 Continue row: only manga with something left after the last chapter read
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>
2026-11-01 11:27:02 +01:00

173 lines
11 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';
/* 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();
})();