Moments: per-user saved pages and regions with one category (BL-0055)

Reader: bookmark button and M save the page (Flow: page at the reading
line, Book: the spread) with an optional dragged region, exactly one
category (Twist, Hot, Awesome, Cute, Funny, Feels) and a note. I now
toggles the bars; ? lists all keys. Keys dispatch through public/keys.js,
which also feeds the new Settings -> Keyboard shortcuts section.

Manga page: "Your moments" strip of crops (story or recent order,
category filters), viewer with step, full page, edit, delete and
"Open in reader" (read.html ...&p=). Moments point at pages by file
name and show "page moved" when the file is gone. Private per user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MaashkinoozheandClaude Opus 5.5 committed 2026-10-27 12:30:24 +01:00
1 parent 41e7ed063e
commit 74842c59e0
15 files changed
+734 -18

No files matched your search

+49
View File
@@ -164,6 +164,7 @@
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' });
@@ -316,6 +317,54 @@
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),
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();
})();