Files
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

371 lines
24 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';
/* Manga overview: complete metadata, volume filters and newest-first chapters. */
(async function () {
const id = new URLSearchParams(location.search).get('m') || '';
let me, M;
try { [me, M] = await Promise.all([api('api/me'), api('api/manga?' + qsa({ m: id }))]); } catch (e) { return showError(e); }
topNav(me);
document.title = M.title + ' · MangaCircus';
const meta = M.meta || {}, main = $('main');
main.textContent = '';
const TYPE = { rtl: 'Manga · right to left', ltr: 'Comic · left to right', ttb: 'Webtoon · vertical strip' };
const arr = x => (Array.isArray(x) ? x : x ? [x] : []);
const text = value => {
if (value == null || value === '') return '';
if (Array.isArray(value)) return value.map(text).filter(Boolean).join(', ');
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
};
const releaseDate = () => {
const raw = meta.firstReleaseDate || meta.releaseDate || meta.startDate;
if (!raw) return text(meta.year);
if (typeof raw !== 'object') return text(raw);
return [raw.year, raw.month && String(raw.month).padStart(2, '0'), raw.day && String(raw.day).padStart(2, '0')].filter(Boolean).join('-');
};
const follow = h('button', { class: 'btn' }, '');
const drawFollow = () => { follow.textContent = M.following ? '🔔 Notifying you' : 'Notify me about new chapters'; follow.classList.toggle('on', M.following); };
follow.onclick = async () => { M.following = (await api('api/follow', { m: M.id, on: !M.following })).following; drawFollow(); };
drawFollow();
const sources = arr(meta.sources).filter(source => source && (source.name || source.url));
const tags = arr(meta.tags).filter(Boolean);
const numberedChapters = M.chapters.map(chapter => /^Ch\. (\d+(?:\.\d+)?)/.exec(chapter.label || ''))
.filter(Boolean).map(match => Number(match[1]));
const firstNumber = numberedChapters.length ? Math.min(...numberedChapters) : null;
const books = {};
for (const book of M.books) books[book.key] = book;
const selectedBooks = new Set();
main.append(h('section', { class: 'hero' },
M.cover ? h('img', { src: M.cover, alt: '' }) : null,
h('div', { class: 'info' },
h('h1', {}, M.title),
arr(meta.altTitles).length ? h('div', { class: 'muted' }, arr(meta.altTitles).join(' · ')) : null,
h('div', { class: 'muted' }, [arr(meta.authors).join(', '), meta.status, releaseDate(), TYPE[M.direction]].filter(Boolean).join(' · ')),
firstNumber > 1 ? h('div', { class: 'coverage-indicators', role: 'status' },
h('span', { class: 'coverage-badge coverage-partial' }, 'Series coverage partial'),
h('span', { class: 'small' }, `This library starts at chapter ${firstNumber}; earlier numbered chapters are absent.`)) : null,
h('div', { class: 'chips' }, tags.map(tag => h('a', { class: 'chip chip-link', href: libraryUrlForTags([tag]), title: `Show manga tagged ${tag}` }, tag))),
meta.description ? h('p', { class: 'desc' }, meta.description) : null,
h('div', { class: 'actions' },
M.continue ? h('a', { class: 'btn primary', href: readUrl(M.id, M.continue.c, M.continue.v, M.continue.at) }, M.continue.text) : null,
follow))));
let storyRhythm = null;
if (M.collections?.length || M.relations?.length) main.append(h('section', { class: 'card' },
h('h2', {}, 'Collections & related manga'),
h('div', { class: 'chips' }, (M.collections || []).map(c => h('a', { class: 'btn', href: 'collection.html?' + qsa({ id: c.id }) }, c.title))),
(M.relations || []).map(r => h('p', {}, h('a', { href: mangaUrl(r.otherId) }, r.otherTitle),
` · ${r.from === M.id ? 'target is ' : 'this manga is '}${r.type.replaceAll('-', ' ')}`,
h('details', {}, h('summary', {}, 'Evidence'), h('p', { class: 'muted' }, r.explanation),
r.evidence.map(e => h('a', { class: 'btn small', href: safeHref(e.url), target: '_blank', rel: 'noopener' }, e.provider || 'Source')))))));
if (M.reading && M.reading.activeMs > 0) {
const lastChapter = M.chapters.find(chapter => chapter.id === M.reading.lastChapterId);
storyRhythm = h('div', { class: 'story-rhythm', role: 'list', 'aria-label': 'Completed chapter reading times' });
const openKey = `reading-story-open.${me.name}`;
const story = h('details', { class: 'reading-story card', open: store.get(openKey) !== '0' },
h('summary', { class: 'reading-story-summary' },
h('span', {}, h('span', { 'aria-hidden': 'true' }, '⏱ '), h('b', {}, 'Your Reading Story')),
h('span', { class: 'muted small' }, readingDuration(M.reading.activeMs) + ' together')),
h('div', { class: 'story-stats' },
storyStat(readingDuration(M.reading.activeMs), 'time together'),
storyStat(M.reading.timedChapters, `chapter${M.reading.timedChapters === 1 ? '' : 's'} timed`),
storyStat(M.reading.typicalChapterMs ? readingDuration(M.reading.typicalChapterMs) : '—', 'typical chapter'),
storyStat(M.reading.rereads, `reread${M.reading.rereads === 1 ? '' : 's'}`)),
lastChapter ? h('p', { class: 'story-last muted' }, 'Last timed ',
h('a', { href: readUrl(M.id, lastChapter.id, null, M.continue?.at) }, `${lastChapter.label}${lastChapter.title ? ' · ' + lastChapter.title : ''}`),
` · ${ago(M.reading.updatedAt)}`) : null,
h('div', { class: 'story-rhythm-head' }, h('b', {}, 'Reading rhythm'), h('span', { class: 'muted small' }, 'Completed chapters · active time')),
storyRhythm,
h('div', { class: 'story-actions' },
h('span', { class: 'muted small' }, 'Only you can see these measurements.'),
h('button', { class: 'btn small', onclick: async () => {
if (!confirm(`Forget all reading time recorded for ${M.title}? Reading progress is kept.`)) return;
await api('api/reading/delete', { m: M.id });
location.reload();
} }, 'Forget this manga’s time')));
story.addEventListener('toggle', () => store.set(openKey, story.open ? '1' : '0'));
main.append(story);
drawStoryRhythm();
}
function storyStat(value, label) {
return h('div', { class: 'story-stat' }, h('strong', {}, value), h('span', { class: 'muted small' }, label));
}
function drawStoryRhythm() {
if (!storyRhythm) return;
const chapters = M.chapters.filter(chapter => !selectedBooks.size || selectedBooks.has(chapter.book));
const completed = chapters.map(chapter => chapter.reading?.lastCompleteMs || 0).filter(Boolean);
const scale = Math.max(1, ...completed.map(value => Math.log1p(value)));
storyRhythm.textContent = '';
for (const chapter of chapters) {
const value = chapter.reading?.lastCompleteMs || 0;
const label = `${chapter.label}${chapter.title ? ' · ' + chapter.title : ''}`;
if (!value) {
storyRhythm.append(h('span', { class: 'rhythm-bar empty', role: 'listitem', title: `${label} · Not timed`, 'aria-label': `${label}, not timed` }));
continue;
}
const height = Math.max(12, Math.round(12 + 48 * Math.log1p(value) / scale));
const passes = chapter.reading.completedPasses;
const title = `${label} · ${readingDuration(value)}${passes > 1 ? ` · ${passes} timed reads · ${readingDuration(chapter.reading.totalActiveMs)} total` : ''}`;
storyRhythm.append(h('a', {
class: 'rhythm-bar', role: 'listitem', href: readUrl(M.id, chapter.id, null, M.continue?.at), title,
'aria-label': title, style: `height:${height}px`,
}));
}
if (!chapters.length) storyRhythm.append(h('span', { class: 'muted small' }, 'No chapters in the selected volumes.'));
}
const metadata = h('dl', { class: 'meta-grid' });
function addMeta(label, value) {
if (value == null || value === '' || (Array.isArray(value) && !value.length)) return;
metadata.append(h('dt', {}, label), h('dd', {}, value instanceof Node ? value : text(value)));
}
addMeta('Title', meta.title || M.title);
addMeta('Alternative titles', arr(meta.altTitles));
addMeta('Authors', arr(meta.authors));
addMeta('Status', meta.status);
addMeta('First release', releaseDate());
addMeta('Format', meta['x-anilist'] && meta['x-anilist'].format);
addMeta('Reading style', TYPE[M.direction] || M.direction);
addMeta('Volumes', M.books.length);
addMeta('Chapters', M.chapters.length);
addMeta('Library ID', M.id);
addMeta('Tags', h('span', { class: 'meta-tags' }, tags.map(tag => h('a', { class: 'chip chip-link', href: libraryUrlForTags([tag]) }, tag))));
if (sources.length) addMeta('Content sources', h('span', { class: 'meta-links' }, sources.map(source => {
const label = source.name || source.url;
return safeHref(source.url) ? h('a', { href: safeHref(source.url), target: '_blank', rel: 'noopener noreferrer' }, label) : h('span', {}, label);
})));
const anilist = meta['x-anilist'] || {};
if (anilist.url || anilist.id) addMeta('AniList', safeHref(anilist.url)
? h('a', { href: safeHref(anilist.url), target: '_blank', rel: 'noopener noreferrer' }, anilist.id ? `Title ${anilist.id}` : 'Open title')
: anilist.id);
if (anilist.idMal) addMeta('MyAnimeList', h('a', { href: `https://myanimelist.net/manga/${encodeURIComponent(anilist.idMal)}`, target: '_blank', rel: 'noopener noreferrer' }, `Title ${anilist.idMal}`));
if (anilist.coverUrl) addMeta('Metadata cover', safeHref(anilist.coverUrl)
? h('a', { href: safeHref(anilist.coverUrl), target: '_blank', rel: 'noopener noreferrer' }, 'Open source image') : anilist.coverUrl);
const cover = meta['x-cover'] || {};
addMeta('Cover provider', [cover.source, cover.label].filter(Boolean).join(' · '));
if (cover.url) addMeta('Cover source', safeHref(cover.url)
? h('a', { href: safeHref(cover.url), target: '_blank', rel: 'noopener noreferrer' }, cover.url) : cover.url);
const scraper = meta['x-scraper'] || {};
addMeta('Auto update', typeof scraper.autoUpdate === 'boolean' ? (scraper.autoUpdate ? 'Enabled' : 'Disabled') : '');
addMeta('Scraper strategy', scraper.strategy);
addMeta('Source identifiers', scraper.ids);
const handled = new Set(['title', 'altTitles', 'authors', 'status', 'year', 'firstReleaseDate', 'releaseDate', 'startDate', 'tags', 'description', 'direction', 'sources', 'x-anilist', 'x-cover', 'x-scraper']);
for (const [key, value] of Object.entries(meta)) if (!handled.has(key)) addMeta(key, value);
const rawMeta = h('details', { class: 'raw-meta' },
h('summary', {}, 'Raw metadata'),
h('pre', {}, JSON.stringify(meta, null, 2)));
const metadataOpen = !window.matchMedia('(max-width: 640px)').matches;
main.append(h('details', { class: 'metadata card', open: metadataOpen },
h('summary', { class: 'metadata-summary' }, h('h2', {}, 'Metadata')),
metadata, rawMeta));
main.append(momentsGallery());
const volumeButtons = new Map();
const volumeBar = h('div', { class: 'volume-filter', 'aria-label': 'Filter chapters by volume' });
const allVolumes = h('button', { class: 'filter-chip', type: 'button' }, 'All volumes');
allVolumes.onclick = () => { selectedBooks.clear(); drawVolumeFilter(); drawChapters(); drawStoryRhythm(); };
volumeBar.append(allVolumes);
for (const book of M.books) {
const label = book.label + (book.title ? ' · ' + book.title : '');
const button = h('button', { class: 'filter-chip', type: 'button', title: label }, label);
button.onclick = () => { selectedBooks.has(book.key) ? selectedBooks.delete(book.key) : selectedBooks.add(book.key); drawVolumeFilter(); drawChapters(); drawStoryRhythm(); };
volumeButtons.set(book.key, button);
volumeBar.append(button);
}
const list = h('section', { class: 'chapters' });
const empty = h('p', { class: 'muted chapter-empty', hidden: true }, 'No chapters match these volumes.');
main.append(h('section', { class: 'chapter-section' },
h('div', { class: 'chapter-heading' }, h('h2', {}, 'Chapters'), h('span', { class: 'muted small' }, 'Newest first')),
volumeBar,
h('div', { class: 'chapter-list-wrap' }, list), empty));
function drawVolumeFilter() {
allVolumes.classList.toggle('on', !selectedBooks.size);
allVolumes.setAttribute('aria-pressed', String(!selectedBooks.size));
for (const [key, button] of volumeButtons) {
button.classList.toggle('on', selectedBooks.has(key));
button.setAttribute('aria-pressed', String(selectedBooks.has(key)));
}
}
function drawChapters() {
list.textContent = '';
const chapters = [...M.chapters].reverse().filter(chapter => !selectedBooks.size || selectedBooks.has(chapter.book));
empty.hidden = !!chapters.length;
let lastBook = null;
for (const chapter of chapters) {
if (chapter.book !== lastBook && M.books.length > 1) {
const book = books[chapter.book];
list.append(h('div', { class: 'book-sep' }, book.label + (book.title ? ' · ' + book.title : '')));
}
lastBook = chapter.book;
list.append(chapterRow(chapter));
}
}
function chapterRow(ch) {
const wrap = h('div', { class: 'chapter-entry' });
const panel = h('div', { class: 'versions', hidden: true });
function draw() {
const p = ch.progress, done = p && p.done;
const readAt = done ? readDate(ch.readAt) : null;
const state = h('button', { class: 'state' + (done ? ' done' : ''), title: (readAt ? readAt.title + ' · ' : '') + (done ? 'Mark as unread' : 'Mark as read') },
done ? (readAt ? '✓ ' + readAt.text : '✓ read') : p ? `page ${p.p + 1} / ${p.n}` : 'unread');
state.onclick = async () => {
if (done) { await api('api/unread', { m: M.id, c: ch.id }); ch.progress = null; ch.readAt = null; }
else {
const version = ch.versions[0];
await api('api/progress', { m: M.id, c: ch.id, v: version.id, p: version.pages - 1, n: version.pages, done: true, tab: tabId() });
ch.progress = { v: version.id, p: version.pages - 1, n: version.pages, done: true };
ch.readAt = { at: Date.now(), approx: false };
}
draw();
};
const verBtn = ch.versions.length > 1
? h('button', { class: 'btn small', onclick: () => { panel.hidden = !panel.hidden; } }, ch.versions.length + ' versions')
: null;
const pages = ch.versions[0] ? ch.versions[0].pages : 0;
const timing = chapterTiming(ch), released = chapterRelease(ch.dates);
wrap.textContent = '';
wrap.append(h('div', { class: 'ch' + (done ? ' read' : '') },
h('a', { class: 'ch-main' + (timing ? ' has-time' : '') + (M.chapterDates?.release ? ' has-date' : ''), href: readUrl(M.id, ch.id, null, M.continue?.at) },
h('span', { class: 'ch-label' }, ch.label),
h('span', { class: 'ch-pages' }, `${pages} p.`),
M.chapterDates?.release ? h('span', { class: 'ch-date' + (released ? ' tier-' + released.tier : ''), title: released ? released.title : 'No date known', 'aria-label': released ? released.title : 'No date known' },
released ? released.text : '–') : null,
timing ? h('span', { class: 'ch-time', title: timing.title, 'aria-label': timing.title }, timing.text) : null,
h('span', { class: 'ch-title', title: ch.title || '' }, ch.title || 'Untitled'),
ch.extra ? h('span', { class: 'tag' }, 'extra') : null),
verBtn, state), panel);
drawVersions();
}
function drawVersions() {
panel.textContent = '';
panel.append(h('div', { class: 'muted small', style: 'padding-top:6px' }, 'The best rated version opens by default. Vote to change that for everyone.'));
ch.versions.forEach((version, i) => {
const vote = async value => { ch.versions = (await api('api/vote', { m: M.id, c: ch.id, v: version.id, value: version.my === value ? 0 : value })).versions; drawVersions(); };
panel.append(h('div', { class: 'ver' },
h('div', { class: 'src' },
safeHref(version.sourceUrl) ? h('a', { href: safeHref(version.sourceUrl), target: '_blank', rel: 'noopener noreferrer' }, version.source || version.id) : h('span', {}, version.source || version.id),
i === 0 ? h('span', { class: 'tag', style: 'margin-left:8px' }, 'default') : null,
h('div', { class: 'muted small' }, `${version.pages} pages · added ${new Date(version.added).toLocaleDateString()}`)),
h('span', { class: 'vote' },
h('button', { class: version.my > 0 ? 'on' : '', title: 'Better', onclick: () => vote(1) }, '▲'),
h('b', {}, version.score),
h('button', { class: version.my < 0 ? 'on' : '', title: 'Worse', onclick: () => vote(-1) }, '▼')),
h('a', { class: 'btn small', href: readUrl(M.id, ch.id, version.id, M.continue?.at) }, 'Read')));
});
}
draw();
return wrap;
}
// Phones (the chapter-row breakpoint in app.css) get numeric dates to leave room for titles.
const compact = matchMedia('(max-width: 640px)').matches;
// Read date: the day and the hour (local time) the chapter was first completed.
function readDate(value) {
const mode = M.chapterDates?.read;
if (!value || !value.at || mode === 'off') return null;
const when = new Date(value.at), thisYear = when.getFullYear() === new Date().getFullYear();
const day = when.toLocaleDateString(undefined, compact ? { day: '2-digit', month: '2-digit', ...(thisYear ? {} : { year: '2-digit' }) }
: { day: 'numeric', month: 'short', ...(thisYear ? {} : { year: 'numeric' }) });
const text = (value.approx ? '≤ ' : '') + day + (mode === 'hour' ? `, ${String(when.getHours()).padStart(2, '0')}h` : '');
const full = when.toLocaleDateString(undefined, { dateStyle: 'long' }) + (mode === 'hour' ? `, ${String(when.getHours()).padStart(2, '0')}:00–${String((when.getHours() + 1) % 24).padStart(2, '0')}:00` : '');
return { text, title: (value.approx ? 'Read by ' : 'Read on ') + full + (value.approx ? ' (earlier reads were not recorded)' : '') };
}
// Release date by tier: official > source upload > Manga Circus ecosystem > this library.
const TIER = { official: { mark: '' }, source: { mark: '≈' }, ecosystem: { mark: '◎' }, library: { mark: '↓' } };
const dayText = (at, precision, long) => {
// Day-precision dates are calendar dates, not instants: show them in UTC so they do not shift.
const dayOnly = !String(at).includes('T');
const opts = precision === 'year' ? { year: 'numeric' } : precision === 'month' ? { year: 'numeric', month: 'short' }
: long ? { dateStyle: 'long' } : compact ? { day: '2-digit', month: '2-digit', year: '2-digit' } : { day: 'numeric', month: 'short', year: 'numeric' };
return new Date(at).toLocaleDateString(undefined, { ...opts, ...(dayOnly ? { timeZone: 'UTC' } : {}) });
};
function chapterRelease(dates) {
if (!dates || !dates.release) return null;
const known = dates.known || {}, lines = [];
if (known.official) lines.push(`Released ${dayText(known.official.at, known.official.precision, true)}${known.official.name ? ' (' + known.official.name + ')' : ''}`);
if (known.source) lines.push(`Uploaded at ${known.source.name || 'the source'} ${dayText(known.source.at, 'day', true)}`);
if (known.batch) lines.push(`Bulk upload at ${known.batch.name || 'the source'} ${dayText(known.batch.at, 'day', true)} (not a release date)`);
if (known.ecosystem) lines.push(`First in Manga Circus ${dayText(known.ecosystem.at, 'day', true)}${known.ecosystem.name && known.ecosystem.name !== 'this server' ? ' (via ' + known.ecosystem.name + ')' : ''}`);
if (known.library) lines.push(`Added to this library ${dayText(known.library.at, 'day', true)}`);
const tier = TIER[dates.tier] || TIER.library;
return { tier: dates.tier, text: tier.mark + dayText(dates.release.at, dates.release.precision), title: lines.join('\n') };
}
function chapterTiming(chapter) {
if (!M.reading) return null;
const value = chapter.reading;
if (!value) return { text: 'Not timed', title: 'No reading time recorded' };
if (value.completedPasses) {
const text = readingDuration(value.lastCompleteMs, true) + (value.completedPasses > 1 ? ` ↻${value.completedPasses}` : '');
return {
text,
title: `Last timed read ${readingDuration(value.lastCompleteMs)}; ${value.completedPasses} timed read${value.completedPasses === 1 ? '' : 's'}; ${readingDuration(value.totalActiveMs)} total`,
};
}
const active = value.currentPassMs || value.totalActiveMs;
return { text: `◷ ${readingDuration(active, true)}`, title: `${readingDuration(active)} active; current pass in progress` };
}
// Your moments (BL-0055): hidden until there is at least one. Only the user's own are ever returned.
function momentsGallery() {
const sortKey = `moments-sort.${me.name}`;
let list = [], sort = store.get(sortKey) === 'recent' ? 'recent' : 'story', filter = null;
const section = h('section', { class: 'card moments-card', hidden: true });
const count = h('span', { class: 'muted small' });
const sortButtons = ['story', 'recent'].map(key => h('button', { type: 'button', 'data-sort': key,
onclick: () => { sort = key; store.set(sortKey, key); draw(); } }, key === 'story' ? 'Story' : 'Recent'));
const filters = h('div', { class: 'moment-filters', role: 'group', 'aria-label': 'Filter moments by category' });
const strip = h('div', { class: 'moment-strip' });
section.append(h('div', { class: 'moments-head' }, h('h2', {}, 'Your moments'), count, h('div', { class: 'grow' }),
h('div', { class: 'seg', role: 'group', 'aria-label': 'Sort moments' }, sortButtons)), filters, strip);
const shown = () => {
const items = list.filter(m => !filter || m.cat === filter);
return sort === 'recent' ? items.sort((a, b) => b.t - a.t) : items.sort((a, b) => {
for (let k = 0; k < 3; k++) if (a.order[k] !== b.order[k]) return a.order[k] - b.order[k];
return a.t - b.t;
});
};
function draw() {
section.hidden = !list.length;
count.textContent = `${list.length} saved`;
for (const b of sortButtons) b.classList.toggle('on', b.dataset.sort === sort);
// Category filters only help once there are a few moments in more than one category.
const cats = MOMENT_CATS.filter(c => list.some(m => m.cat === c.id));
if (filter && !cats.some(c => c.id === filter)) filter = null;
filters.hidden = list.length < 5 || cats.length < 2;
filters.replaceChildren(...cats.map(c => h('button', { type: 'button', class: 'filter-chip moment-filter' + (filter === c.id ? ' on' : ''),
'aria-pressed': String(filter === c.id), onclick: () => { filter = filter === c.id ? null : c.id; draw(); } },
momentIcon(c.id, 14), `${c.label} ${list.filter(m => m.cat === c.id).length}`)));
const items = shown();
strip.replaceChildren(...items.map((m, k) => h('button', { type: 'button', class: 'moment-thumb', title: `${momentCat(m.cat).label} · ${m.chapter.label} · ${m.missing ? 'page moved' : momentPageLabel(m.pages)}${m.note ? '\n' + m.note : ''}`,
onclick: () => momentViewer({ mangaId: M.id, mangaTitle: M.title, list: items, index: k, onChange: changed }) },
momentCrop(m.pages, m.region, 200, 120, m.layout),
h('span', { class: 'moment-thumb-cat' }, momentIcon(m.cat, 14)),
h('span', { class: 'moment-thumb-label' }, `${m.chapter.label} · ${m.missing ? 'moved' : momentPageLabel(m.pages)}`))));
}
// The viewer edits its (sorted, filtered) copy; fold the changes back into the full list.
function changed(items) {
const byId = new Map(items.map(m => [m.id, m]));
const visible = new Set(shown().map(m => m.id));
list = list.filter(m => !visible.has(m.id) || byId.has(m.id)).map(m => byId.get(m.id) || m);
draw();
}
api('api/moments?' + qsa({ m: M.id })).then(data => { list = data.moments; draw(); }).catch(() => {});
return section;
}
drawVolumeFilter();
drawChapters();
})();