Files
MangaCircus/public/library.js
T
MaashkinoozheandClaude Opus 5.5 618992ec14 Reader: stale reading sessions never move "Continue" back
A reader tab left open on one device (or restored by the browser) used to
save its old chapter as the newest position as soon as it was shown again.
Progress saves now carry the tab id and the newest progress time the tab
knows (`seen`); the server refuses a save when another tab or device saved
a newer position since. The reader then asks: jump to the newest position,
or keep reading here (which makes this chapter the head again). It checks on
open, when the tab becomes visible, and on every save. Reader links carry
`at` and the reader keeps it in the address bar, so reloads and restored
tabs still know what they had seen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:49:52 +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; the same manga still appear 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))));
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();
})();