Files
MangaCircus/public/library.js
T
MaashkinoozheandClaude Opus 5.5 1ddefaa7ac Library: "Continue where you left off" row with a per-user setting
One fixed row above the library showing the five most recently read,
unfinished manga with the normal tiles (they stay in the grid as well).
It ignores search and filters, hides while sorted by "Recently read",
and can be turned off under Settings > Reading > Library page
(continueRow, default on).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:02:16 +01:00

172 lines
11 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';
/* 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 but unfinished manga, most recently read first. Ignores the filters below on purpose,
// and the same manga still appears in the grid.
const continueItems = data.mangas.filter(m => m.lastRead && m.read < m.chapters)
.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))));
main.append(continueRow);
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) }, `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),
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);
// "Recently read" already puts these first, so the row would only repeat them.
continueRow.hidden = !me.settings.continueRow || sort.value === 'read' || !continueItems.length;
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();
})();