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

+3 -2
View File
@@ -150,8 +150,9 @@ completeness.
- In Flow: the bottom part of the screen goes forward, the top part goes back. - In Flow: the bottom part of the screen goes forward, the top part goes back.
- The middle of the page also toggles the controls (useful when a zoomed page fills the screen). - The middle of the page also toggles the controls (useful when a zoomed page fills the screen).
- A horizontal swipe also turns pages on touch screens. - A horizontal swipe also turns pages on touch screens.
- Keys: arrows, Space / PageDown, `m` = controls, `f` = fullscreen. - Keys: arrows, Space / PageDown, `i` = controls, `m` = save a moment, `f` = fullscreen, `?` = all shortcuts (also under Settings → User → Keyboard shortcuts).
- **Controls stay out of the way:** they hide as soon as you turn a page or scroll, and come back only with a tap (middle of the page or beside it). Scrolling up never brings them back. The first time they hide, a short hint says so. - **Moments:** the bookmark button in the top bar (or `m`) saves the page you are on (in Book mode the spread) as a moment. Drag over the preview to keep only a region, and pick exactly one category: Twist, Hot, Awesome, Cute, Funny or Feels, plus an optional note. The button is filled on pages that already have one. Each manga page shows *Your moments* as a strip of crops, sorted by story or by when you saved them; click one to step through them, see the full page, edit, delete or jump into the reader at that page. Moments are private to each user.
- **Controls stay out of the way:** they hide as soon as you turn a page or scroll, and come back only with a tap (middle of the page or beside it) or `i`. Scrolling up never brings them back. The first time they hide, a short hint says so.
- **Controls:** the top bar has a chapter picker with every chapter of the manga, grouped by book, with ✓ for read ones. The bottom bar has the page slider, the mode switch, Shift, zoom, the version picker and previous/next chapter. - **Controls:** the top bar has a chapter picker with every chapter of the manga, grouped by book, with ✓ for read ones. The bottom bar has the page slider, the mode switch, Shift, zoom, the version picker and previous/next chapter.
- **Loading:** the next pages (next 3 in Page mode, next 2 spreads in Book mode) are downloaded and decoded in advance, so page turns don't flash. A page that still isn't there shows a spinner. On phones, "Add to home screen" opens the app without browser bars, and the ⛶ button (where supported) goes fullscreen. - **Loading:** the next pages (next 3 in Page mode, next 2 spreads in Book mode) are downloaded and decoded in advance, so page turns don't flash. A page that still isn't there shows a spinner. On phones, "Add to home screen" opens the app without browser bars, and the ⛶ button (where supported) goes fullscreen.
- **Progress** is saved per user and chapter version: the last page, whether the chapter is done, and book-mode shifts. The manga page offers *Continue*. Tap a chapter's status to mark it read/unread. - **Progress** is saved per user and chapter version: the last page, whether the chapter is done, and book-mode shifts. The manga page offers *Continue*. Tap a chapter's status to mark it read/unread.
+11 -2
View File
@@ -151,6 +151,10 @@ This keeps ids human-readable and derivable without a database. The price: renam
"follows": { "admin": { "Moon Garden": true } }, "follows": { "admin": { "Moon Garden": true } },
"notes": { "admin": [ { "id": "a1b2c3d4e5f6", "t": 1790797300000, "m": "Moon Garden", "c": "2/6", "notes": { "admin": [ { "id": "a1b2c3d4e5f6", "t": 1790797300000, "m": "Moon Garden", "c": "2/6",
"text": "Moon Garden · Ch. 6: Frost", "read": false } ] }, "text": "Moon Garden · Ch. 6: Frost", "read": false } ] },
"moments": { "admin": [ { "id": "9f1c2b3a4d5e", "m": "Moon Garden", "c": "1/2", "v": "c0002 Roots [MangaDex]",
"i": [4], "f": ["0005.png"], "sha": ["<sha256 of the file>"], // pages, left to right as displayed (1 or 2)
"region": { "x": 0.1, "y": 0.5, "w": 0.5, "h": 0.25 }, // fractions of the page(s); null = whole page
"cat": "awesome", "note": "", "t": 1790797400000 } ] },
"votes": { "Moon Garden": { "1/2": { "c0002 Roots [Official]": { "admin": 1, "reader": -1 } } } }, "votes": { "Moon Garden": { "1/2": { "c0002 Roots [Official]": { "admin": 1, "reader": -1 } } } },
"known": { "Moon Garden": ["1/1", "1/2", "1/2.5", "..."] } // every chapter id ever seen (append-only) "known": { "Moon Garden": ["1/1", "1/2", "1/2.5", "..."] } // every chapter id ever seen (append-only)
} }
@@ -201,6 +205,9 @@ All requests and responses are JSON. POSTs require `Content-Type: application/js
| `POST /api/unread` `{m, c}` | user | Forget progress for a chapter | | `POST /api/unread` `{m, c}` | user | Forget progress for a chapter |
| `POST /api/vote` `{m, c, v, value: 1\|-1\|0}` | user | Vote a version; returns the re-ranked versions | | `POST /api/vote` `{m, c, v, value: 1\|-1\|0}` | user | Vote a version; returns the re-ranked versions |
| `POST /api/follow` `{m, on}` | user | Follow a manga for notifications | | `POST /api/follow` `{m, on}` | user | Follow a manga for notifications |
| `GET /api/moments?m=` | user | The caller's moments of that manga in story order: category, note, region, chapter, `order`, page images, `missing` when a page file is gone. `/api/chapter` also lists the caller's moments on the opened version |
| `POST /api/moments` `{m, c, v, pages, region, cat, note}` | user | Save a moment: `pages` are 1–2 indexes left to right as displayed, `cat` one of `twist hot awesome cute funny feels` (required), `region` fractions or null |
| `POST /api/moments/update` `{id, cat?, region?, note?}` · `POST /api/moments/delete` `{id}` | user | Edit or delete one of the caller's own moments (another user's id is `404`) |
| `GET /api/notifications` · `POST /api/notifications/read` `{id \| all}` | user | Notification list and read state | | `GET /api/notifications` · `POST /api/notifications/read` `{id \| all}` | user | Notification list and read state |
| `GET /api/admin` | admin | Users, last scan result, library path | | `GET /api/admin` | admin | Users, last scan result, library path |
| `POST /api/admin/rescan` `{full}` | admin | Scan now, and wait for the result | | `POST /api/admin/rescan` `{full}` | admin | Scan now, and wait for the result |
@@ -323,12 +330,12 @@ mode = adapt && intended ∉ compatible ? compatible[0] : intended
| Horizontal swipe ≥ 50 px within 800 ms (paged, not zoomed in) | Previous/next | | Horizontal swipe ≥ 50 px within 800 ms (paged, not zoomed in) | Previous/next |
| Ctrl + wheel, touchpad pinch, Safari gesture events, two-finger pinch | Continuous page zoom 20–600 %, snapping to 100 % within ±1 %, anchored at the pointer or finger midpoint. Browser zoom is suppressed only over the pages | | Ctrl + wheel, touchpad pinch, Safari gesture events, two-finger pinch | Continuous page zoom 20–600 %, snapping to 100 % within ±1 %, anchored at the pointer or finger midpoint. Browser zoom is suppressed only over the pages |
| Mouse drag (moved ≥ 6 px) | Pan; the following click is swallowed. Hand cursor when the content is larger than the screen | | Mouse drag (moved ≥ 6 px) | Pan; the following click is swallowed. Hand cursor when the content is larger than the screen |
| Keys | Arrows follow the direction; Space/PageDown/PageUp (paged); `+` and `-` zoom in 5-percentage-point steps, `0` fits at 100 %; `m`/Esc bars; `f` fullscreen | | Keys | Dispatched through `READER_KEYS` in `public/keys.js`, which also feeds Settings → Keyboard shortcuts and the reader's `?` list. Arrows follow the direction; Space/PageDown/PageUp (paged); `+` and `-` zoom in 5-percentage-point steps, `0` fits at 100 %; `m` saves a moment; `i`/Esc bars; `f` fullscreen; `?` shortcuts |
**Bars:** **Bars:**
- Shown on open. - Shown on open.
- Hidden on the first page turn or after 100 px of scrolling. The first automatic hide (per device, once for Flow and once for Page/Book) shows a hint on how to get them back. - Hidden on the first page turn or after 100 px of scrolling. The first automatic hide (per device, once for Flow and once for Page/Book) shows a hint on how to get them back.
- **Never shown by scrolling**, only by a tap beside the pages or in the middle of a page (or `m`). Showing them stops a running smooth scroll, so it can't hide them again right away. - **Never shown by scrolling**, only by a tap beside the pages or in the middle of a page (or `i`). Showing them stops a running smooth scroll, so it can't hide them again right away.
- The top bar has the chapter picker (every chapter, grouped by book, ✓ = read). - The top bar has the chapter picker (every chapter, grouped by book, ✓ = read).
**Loading:** **Loading:**
@@ -352,6 +359,8 @@ mode = adapt && intended ∉ compatible ? compatible[0] : intended
- Pulses are sent at most every 10 seconds and on pause/page exit. The server validates the manga/chapter/version and page indices, de-duplicates `(session, seq)`, clamps time to server-observed intervals and stores compact page ranges separately from ordinary progress. - Pulses are sent at most every 10 seconds and on pause/page exit. The server validates the manga/chapter/version and page indices, de-duplicates `(session, seq)`, clamps time to server-observed intervals and stores compact page ranges separately from ordinary progress.
- The bottom toolbar chip shows active time and the current state; its panel adds pass time, verified pages, predominant input and loading wait. - The bottom toolbar chip shows active time and the current state; its panel adds pass time, verified pages, predominant input and loading wait.
**Moments** (`public/moments.js`): the top bar's bookmark button (or `m`) opens a sheet for the page(s) on screen: in Flow the page at the reading line, in Book the spread. A rectangle dragged over the preview keeps only that region; a region inside one page of a spread saves that page alone. Exactly one category is required. The button is filled when the page already has a moment. The manga page shows the user's moments as a strip of crops (sort by story or by date saved, category filters from five moments), with a viewer to step through them, edit, delete or open the reader at `read.html?…&p=<page index>`. Crops position the full page images inside a clipping box; nothing is generated on the server.
**Zoom persistence:** per mode key (`flow`, `page`, `book`, `strip`), saved 800 ms after the last change. **Zoom persistence:** per mode key (`flow`, `page`, `book`, `strip`), saved 800 ms after the last change.
## 8. Deployment ## 8. Deployment
+74
View File
@@ -0,0 +1,74 @@
'use strict';
/*
* Moments: pages, or a region of one or two facing pages, that a reader saved with exactly one category.
* Stored per user in db.moments[user] as a list. A moment points at its pages by version folder and file
* name (plus the file's SHA-256 for a later relocation), so a re-scan that shifts page numbers never makes it
* point at the wrong picture: a file that is gone shows as "page moved" instead.
*/
const CATEGORIES = ['twist', 'hot', 'awesome', 'cute', 'funny', 'feels'];
const NOTE_MAX = 200, MAX_PER_USER = 5000;
class MomentError extends Error { constructor(msg) { super(msg); this.code = 400; } }
const fail = msg => { throw new MomentError(msg); };
const clamp01 = x => Math.min(1, Math.max(0, x));
// A region is a rectangle in fractions (0–1) of the displayed page, or of both pages of a spread side by side.
// Nothing or a rectangle covering (almost) everything means the whole page.
function cleanRegion(r) {
if (r == null) return null;
const n = ['x', 'y', 'w', 'h'].map(k => Number(r[k]));
if (n.some(v => !Number.isFinite(v))) fail('Invalid region.');
const x = clamp01(n[0]), y = clamp01(n[1]), w = clamp01(Math.min(n[2], 1 - x)), h = clamp01(Math.min(n[3], 1 - y));
if (w < 0.01 || h < 0.01) fail('The selected region is too small.');
if (w > 0.995 && h > 0.995) return null;
const round = v => Math.round(v * 1e4) / 1e4;
return { x: round(x), y: round(y), w: round(w), h: round(h) };
}
function cleanCategory(cat) {
return CATEGORIES.includes(cat) ? cat : fail('Pick one category: ' + CATEGORIES.join(', ') + '.');
}
const cleanNote = note => String(note == null ? '' : note).replace(/\s+/g, ' ').trim().slice(0, NOTE_MAX);
// Page indexes (into the version's full page list) in displayed order, left to right. One page, or two facing pages.
function cleanPages(pages, version) {
if (!Array.isArray(pages) || pages.length < 1 || pages.length > 2) fail('A moment covers one page or two facing pages.');
const out = pages.map(Number);
if (out.some(i => !Number.isInteger(i) || i < 0 || i >= version.pages.length)) fail('Page not found.');
if (out.length === 2 && out[0] === out[1]) fail('Page not found.');
return out;
}
// Where a stored moment's pages are now. Returns null when the chapter, the version or a page file is gone.
function locate(moment, manga) {
const chapter = manga && manga.chapterById.get(moment.c), version = chapter && chapter.versions.find(v => v.id === moment.v);
if (!version) return null;
const pages = moment.f.map((file, k) => {
const at = moment.i[k];
return version.pages[at] && version.pages[at].f === file ? at : version.pages.findIndex(p => p.f === file);
});
return pages.every(i => i >= 0) ? { chapter, version, pages } : null;
}
// The browser's view of a moment. `url` turns a library-relative path into an image URL.
function view(moment, manga, url) {
const at = locate(moment, manga);
const chapter = at ? at.chapter : manga && manga.chapterById.get(moment.c);
return {
id: moment.id, c: moment.c, v: moment.v, cat: moment.cat, note: moment.note || '', t: moment.t, region: moment.region || null,
chapter: chapter ? { label: chapter.label, title: chapter.title, book: chapter.book } : { label: moment.c, title: '', book: '' },
// Story order: chapter position in the manga, then page, then how far down the page the region starts.
order: [chapter ? manga.chapters.indexOf(chapter) : 1e9, at ? Math.min(...at.pages) : moment.i[0], moment.region ? moment.region.y : 0],
missing: !at,
pages: at ? at.pages.map(i => {
const p = at.version.pages[i];
return { i, u: url(at.version.rel + '/' + p.f), w: p.w, h: p.h, r: p.r || 0 };
}) : [],
};
}
function storyCompare(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;
}
module.exports = { CATEGORIES, NOTE_MAX, MAX_PER_USER, MomentError, cleanRegion, cleanCategory, cleanNote, cleanPages, locate, view, storyCompare };
+62
View File
@@ -502,3 +502,65 @@ body.nobars #bot { transform: translateY(100%); opacity: 0; pointer-events: none
.seg button { padding: 7px 10px; } #bot .btn { padding: 6px 10px; } .wide-only { display: none; } .seg button { padding: 7px 10px; } #bot .btn { padding: 6px 10px; } .wide-only { display: none; }
.clock-panel { bottom: calc(124px + env(safe-area-inset-bottom)); } .clock-panel { bottom: calc(124px + env(safe-area-inset-bottom)); }
} }
/* ---------------- moments (BL-0055) ---------------- */
.moment-btn.on { color: var(--accent); border-color: var(--accent); }
.moment-btn.on svg path:first-child { fill: currentColor; }
.moment-dialog { width: min(640px, calc(100vw - 16px)); max-height: calc(100vh - 16px); overflow: auto; color: var(--fg); background: var(--bg);
border: 1px solid var(--line); border-radius: 12px; padding: 16px; }
.moment-dialog::backdrop { background: #000c; }
.moment-dialog h2 { margin: 0; font-size: 18px; }
.moment-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.moment-head > :first-child { flex: 1; min-width: 0; }
.moment-context { font-weight: 600; }
.moment-preview-wrap { display: flex; justify-content: center; margin: 12px 0 8px; }
.moment-preview { position: relative; line-height: 0; }
.moment-composite { position: relative; }
.moment-page { position: absolute; top: 0; overflow: hidden; background: #19191c; }
.moment-page img { display: block; width: 100%; height: 100%; -webkit-user-drag: none; user-select: none; }
.region-layer { position: absolute; inset: 0; cursor: crosshair; touch-action: none; overflow: hidden; }
.region-box { position: absolute; border: 2px solid #fff; outline: 1px solid #0008; box-shadow: 0 0 0 9999px #0009; pointer-events: none; }
.moment-region-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 32px; }
.moment-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
.moment-chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 18px; border: 1px solid var(--line);
background: var(--card); color: var(--fg); font: inherit; cursor: pointer; }
.moment-chip:hover { background: var(--card2); }
.moment-chip[aria-checked="true"] { border-color: var(--cat); background: color-mix(in srgb, var(--cat) 22%, var(--card)); box-shadow: 0 0 0 1px var(--cat); }
.moment-dialog input[type=text] { width: 100%; }
.moment-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; }
.moment-dialog .btn[disabled] { opacity: .45; pointer-events: none; }
.moment-crop { position: relative; overflow: hidden; border-radius: 6px; background: #19191c; flex: none; }
.moment-crop.missing { display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 13px; line-height: 1.2; text-align: center; }
.moment-viewer { width: auto; max-width: calc(100vw - 16px); }
.moment-viewer-body { display: flex; align-items: center; justify-content: center; gap: 8px; }
.moment-viewer-stage { display: flex; justify-content: center; touch-action: pan-y; user-select: none; }
.moment-viewer-info { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; }
.moment-tag { display: inline-flex; align-items: center; gap: 5px; padding: 2px 10px; border-radius: 12px; font-size: 13px;
border: 1px solid var(--cat); background: color-mix(in srgb, var(--cat) 18%, var(--card)); }
.moment-note { flex-basis: 100%; color: var(--muted); font-style: italic; }
.moment-nav { font-size: 22px; min-width: 40px; }
@media (max-width: 600px) {
.moment-viewer-body { position: relative; }
.moment-nav { position: absolute; z-index: 1; background: #000a; }
.moment-nav.prev { left: 2px; } .moment-nav.next { right: 2px; }
}
.moments-card { max-width: none; }
.moments-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }
.moments-card h2 { margin: 0; }
.moment-filters { display: flex; gap: 7px; overflow-x: auto; padding-bottom: 6px; }
.moment-filter { display: inline-flex; align-items: center; gap: 5px; }
.moment-strip { display: flex; gap: 10px; overflow-x: auto; padding: 4px 2px 10px; scrollbar-width: thin; }
.moment-thumb { position: relative; flex: none; display: flex; flex-direction: column; gap: 4px; padding: 0; border: 0; background: none;
color: var(--fg); font: inherit; cursor: pointer; text-align: left; }
.moment-thumb .moment-crop { transition: transform .15s; }
.moment-thumb:hover .moment-crop, .moment-thumb:focus-visible .moment-crop { transform: scale(1.03); }
.moment-thumb-cat { position: absolute; top: 5px; left: 5px; display: flex; padding: 3px; border-radius: 50%; background: #000b; }
.moment-thumb-label { font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200px; }
.key-group h3 { margin: 4px 0 8px; font-size: 15px; }
.key-group + .key-group { margin-top: 16px; }
.key-table { border-collapse: collapse; width: 100%; }
.key-table td { padding: 5px 8px; border-top: 1px solid var(--line); vertical-align: top; }
.key-table td:first-child { width: 1%; white-space: nowrap; }
kbd { display: inline-block; min-width: 24px; padding: 1px 7px; border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 5px;
background: var(--card2); font: 13px/1.5 ui-monospace, monospace; text-align: center; }
@media (max-width: 480px) { .moment-dialog { padding: 12px; } .moment-actions .moment-hint { flex-basis: 100%; } .moment-actions .moment-hint:empty { display: none; } }
+35
View File
@@ -0,0 +1,35 @@
'use strict';
/*
* Keyboard shortcuts. The reader dispatches keys through READER_KEYS, and Settings and the reader's "?" list
* are drawn from the same tables, so the documentation cannot drift from what the keys do.
*/
const READER_KEYS = [
{ keys: ['ArrowLeft', 'ArrowRight'], label: '← →', action: 'turn', text: 'Previous / next page, following the reading direction' },
{ keys: [' ', 'PageDown'], label: 'Space · Page Down', action: 'forward', text: 'Next page (Shift + Space: previous) · Page and Book mode' },
{ keys: ['PageUp'], label: 'Page Up', action: 'back', text: 'Previous page · Page and Book mode' },
{ keys: ['+', '='], label: '+', action: 'zoomIn', text: 'Zoom in 5 %' },
{ keys: ['-'], label: '−', action: 'zoomOut', text: 'Zoom out 5 %' },
{ keys: ['0'], label: '0', action: 'zoomFit', text: 'Fit the screen (100 %)' },
{ keys: ['m', 'M'], label: 'M', action: 'moment', text: 'Save a moment of the page you are on' },
{ keys: ['i', 'I'], label: 'I', action: 'bars', text: 'Show or hide the controls' },
{ keys: ['Escape'], label: 'Esc', action: 'bars', text: 'Show or hide the controls (closes an open dialog first)' },
{ keys: ['f', 'F'], label: 'F', action: 'fullscreen', text: 'Fullscreen' },
{ keys: ['?'], label: '?', action: 'help', text: 'Show these shortcuts' },
];
const MOMENT_VIEWER_KEYS = [
{ keys: ['ArrowLeft', 'ArrowRight'], label: '← →', action: 'step', text: 'Previous / next moment' },
{ keys: ['Escape'], label: 'Esc', action: 'close', text: 'Close the viewer' },
];
const KEY_GROUPS = [
{ title: 'Reader', keys: READER_KEYS },
{ title: 'Moments viewer (manga page)', keys: MOMENT_VIEWER_KEYS },
];
const keyAction = (table, key) => (table.find(entry => entry.keys.includes(key)) || {}).action || null;
function keyTable(groups = KEY_GROUPS) {
return groups.map(group => h('section', { class: 'key-group' }, h('h3', {}, group.title),
h('table', { class: 'key-table' }, h('tbody', {}, group.keys.map(entry =>
h('tr', {}, h('td', {}, h('kbd', {}, entry.label)), h('td', {}, entry.text)))))));
}
if (typeof module !== 'undefined') module.exports = { READER_KEYS, MOMENT_VIEWER_KEYS, KEY_GROUPS, keyAction };
+2
View File
@@ -9,6 +9,8 @@
<link rel="manifest" href="manifest.webmanifest"> <link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#121214"> <meta name="theme-color" content="#121214">
<script src="common.js" defer></script> <script src="common.js" defer></script>
<script src="keys.js" defer></script>
<script src="moments.js" defer></script>
<script src="manga.js" defer></script> <script src="manga.js" defer></script>
</head> </head>
<body> <body>
+49
View File
@@ -164,6 +164,7 @@
main.append(h('details', { class: 'metadata card', open: metadataOpen }, main.append(h('details', { class: 'metadata card', open: metadataOpen },
h('summary', { class: 'metadata-summary' }, h('h2', {}, 'Metadata')), h('summary', { class: 'metadata-summary' }, h('h2', {}, 'Metadata')),
metadata, rawMeta)); metadata, rawMeta));
main.append(momentsGallery());
const volumeButtons = new Map(); const volumeButtons = new Map();
const volumeBar = h('div', { class: 'volume-filter', 'aria-label': 'Filter chapters by volume' }); 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` }; 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(); drawVolumeFilter();
drawChapters(); drawChapters();
})(); })();
+244
View File
@@ -0,0 +1,244 @@
'use strict';
/*
* Moments (BL-0055): a page, or a region of one or two facing pages, saved with exactly one category.
* Shared by the reader (save sheet) and the manga page (gallery and viewer). Crops are drawn by positioning the
* full page images inside a clipping box, so no thumbnails are generated on the server.
*/
const MOMENT_CATS = [
{ id: 'twist', label: 'Twist', color: '#a29bf2', svg: '<path d="M12 1.5L13.8 4.6L17.3 3.2L17.4 6.8L21 7.6L19.2 10.8L22 13.2L18.8 14.9L19.6 18.5L16 18.2L14.6 21.6L12 19.2L9.4 21.6L8 18.2L4.4 18.5L5.2 14.9L2 13.2L4.8 10.8L3 7.6L6.6 6.8L6.7 3.2L10.2 4.6Z"/><path d="M9.3 8.3v4.4"/><path d="M12.6 9.2a1.9 1.9 0 1 1 2.6 1.8c-.6.3-.8.7-.8 1.5"/><circle cx="9.3" cy="15.3" r=".5" fill="currentColor"/><circle cx="14.4" cy="15.3" r=".5" fill="currentColor"/>' },
{ id: 'hot', label: 'Hot', color: '#f0845c', svg: '<path d="M12 2.5c.5 3 3 4.5 4.5 7a6.5 6.5 0 1 1-11 1.5c.8 1.2 1.8 1.8 2.8 1.8C7.5 9 9.5 5.5 12 2.5z"/><path d="M12 21a3 3 0 0 1-3-3c0-1.8 1.5-3 3-5c1.5 2 3 3.2 3 5a3 3 0 0 1-3 3z"/><path d="M19.5 3l-1 1.6M21.5 5.5l-1.6.8"/>' },
{ id: 'awesome', label: 'Awesome', color: '#f2b443', svg: '<path d="M12 5.5L13.5 10.5L18.5 12L13.5 13.5L12 18.5L10.5 13.5L5.5 12L10.5 10.5Z"/><path d="M3 3l2.6 2.6M21 3l-2.6 2.6M3 21l2.6-2.6M21 21l-2.6-2.6M12 1.5v1.8M12 20.7v1.8M1.5 12h1.8M20.7 12h1.8"/>' },
{ id: 'cute', label: 'Cute', color: '#f07aa5', svg: '<path d="M11 21.5s-7-4.4-7-9.5a4 4 0 0 1 7-2.6a4 4 0 0 1 7 2.6c0 5.1-7 9.5-7 9.5z"/><path d="M8 13.5l1 1M13 13.5l1 1"/><path d="M19.5 2v4M17.5 4h4"/><path d="M4 3.5v2.4M2.8 4.7h2.4"/>' },
{ id: 'funny', label: 'Funny', color: '#4fcfa0', svg: '<circle cx="11" cy="13" r="8"/><path d="M6.8 11.6l1.6-1.6l1.6 1.6M12 11.6l1.6-1.6l1.6 1.6"/><path d="M7.3 14.5h7.4a3.7 3.7 0 0 1-7.4 0z"/><path d="M20.2 2.5c-1 1.6-1.6 2.6-1.6 3.4a1.6 1.6 0 0 0 3.2 0c0-.8-.6-1.8-1.6-3.4z"/>' },
{ id: 'feels', label: 'Feels', color: '#6aa8ec', svg: '<path d="M2.5 10c2.5-4 6-5.5 9.5-5.5S19 6 21.5 10"/><path d="M5 11.5a7 7 0 0 0 14 0"/><circle cx="12" cy="11" r="4"/><circle cx="13.5" cy="9.5" r="1" fill="currentColor"/><path d="M18 16.5c-1 1.5-1.5 2.4-1.5 3.1a1.5 1.5 0 0 0 3 0c0-.7-.5-1.6-1.5-3.1z"/>' },
];
const momentCat = id => MOMENT_CATS.find(c => c.id === id) || MOMENT_CATS[0];
function momentIcon(id, size = 18) {
const el = icon(`<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="${size < 20 ? 2 : 1.6}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${momentCat(id).svg}</svg>`);
el.style.color = momentCat(id).color;
return el;
}
const momentPageLabel = pages => 'p. ' + pages.map(p => p.i + 1).sort((a, b) => a - b).join('–');
// Displayed size after rotation, as in the reader.
const shapeOf = p => {
const turned = (p.r || 0) % 180, w = (turned ? p.h : p.w) || 1000, hh = (turned ? p.w : p.h) || 1414;
return { ...p, a: w / hh };
};
const FULL = { x: 0, y: 0, w: 1, h: 1 };
// The pages side by side at height s (px), left to right as displayed.
function composite(pages, s) {
const shapes = pages.map(shapeOf), el = h('div', { class: 'moment-composite' });
let x = 0;
for (const p of shapes) {
const w = p.a * s, img = h('img', { src: p.u, alt: '', draggable: 'false', decoding: 'async', loading: 'lazy' });
if (p.r) img.style.cssText = `position:absolute;left:50%;top:50%;max-width:none;width:${p.r % 180 ? s : w}px;height:${p.r % 180 ? w : s}px;transform:translate(-50%,-50%) rotate(${p.r}deg)`;
el.append(h('div', { class: 'moment-page', style: `left:${x}px;width:${w}px;height:${s}px` }, img));
x += w;
}
el.style.width = x + 'px';
el.style.height = s + 'px';
return el;
}
const compositeAspect = pages => pages.map(shapeOf).reduce((sum, p) => sum + p.a, 0);
// A box of at most maxW × maxH showing `region` (or everything) of the pages.
function momentCrop(pages, region, maxW, maxH) {
if (!pages.length) return h('div', { class: 'moment-crop missing', style: `width:${Math.round(maxH * 0.7)}px;height:${maxH}px` }, 'Page moved');
const r = region || FULL, total = compositeAspect(pages), aspect = (r.w * total) / r.h;
const boxW = Math.min(maxW, maxH * aspect), boxH = boxW / aspect, s = boxH / r.h;
const inner = composite(pages, s);
inner.style.transform = `translate(${-r.x * total * s}px, ${-r.y * s}px)`;
return h('div', { class: 'moment-crop', style: `width:${Math.round(boxW)}px;height:${Math.round(boxH)}px` }, inner);
}
// Drag a rectangle over `target` (position: relative). Coordinates are fractions of the target box.
function regionPicker(target, initial, onChange) {
let region = initial || null, start = null;
const box = h('div', { class: 'region-box', hidden: true });
const layer = h('div', { class: 'region-layer' }, box);
const draw = () => {
box.hidden = !region;
if (region) Object.assign(box.style, { left: region.x * 100 + '%', top: region.y * 100 + '%', width: region.w * 100 + '%', height: region.h * 100 + '%' });
layer.classList.toggle('has-region', !!region);
};
const at = e => {
const rect = layer.getBoundingClientRect();
return { x: Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)), y: Math.min(1, Math.max(0, (e.clientY - rect.top) / rect.height)) };
};
layer.addEventListener('pointerdown', e => { e.preventDefault(); layer.setPointerCapture(e.pointerId); start = at(e); });
layer.addEventListener('pointermove', e => {
if (!start) return;
const p = at(e);
region = { x: Math.min(start.x, p.x), y: Math.min(start.y, p.y), w: Math.abs(p.x - start.x), h: Math.abs(p.y - start.y) };
draw();
});
const end = () => {
if (!start) return;
start = null;
if (region && (region.w < 0.03 || region.h < 0.03)) region = null; // a tap, not a selection
draw();
onChange(region);
};
layer.addEventListener('pointerup', end);
layer.addEventListener('pointercancel', end);
target.append(layer);
draw();
return { get: () => region, clear() { region = null; draw(); onChange(null); } };
}
// A region on a spread that lies within one page becomes a moment of that page alone.
function narrowToPage(pages, region) {
if (pages.length < 2 || !region) return { pages, region };
const shapes = pages.map(shapeOf), total = shapes[0].a + shapes[1].a, split = shapes[0].a / total;
const x0 = region.x, x1 = region.x + region.w;
if (x1 <= split + 0.002) return { pages: [pages[0]], region: { ...region, x: x0 / split, w: Math.min(1, region.w / split) } };
if (x0 >= split - 0.002) return { pages: [pages[1]], region: { ...region, x: Math.max(0, (x0 - split) / (1 - split)), w: Math.min(1, region.w / (1 - split)) } };
return { pages, region };
}
function openDialog(className, onClose) {
const dialog = h('dialog', { class: 'moment-dialog ' + className });
dialog.addEventListener('close', () => { dialog.remove(); if (onClose) onClose(); });
dialog.addEventListener('click', e => { if (e.target === dialog) dialog.close(); }); // click on the backdrop
document.body.append(dialog);
return dialog;
}
/*
* The save / edit sheet. `pages` [{i, u, w, h, r}] in displayed order; `context` lines; `initial` {region, cat, note}.
* `save({pages, region, cat, note})` returns a promise. Resolves with its result, or null when cancelled.
*/
function momentSheet({ heading, context, pages, initial = {}, lockPages = false, save }) {
return new Promise(resolve => {
let result = null, cat = initial.cat || null, region = initial.region || null;
const dialog = openDialog('moment-sheet', () => resolve(result));
const maxW = Math.min(560, innerWidth - 48), maxH = Math.min(innerHeight * (innerWidth < 600 ? 0.42 : 0.5), 520);
const total = compositeAspect(pages), boxW = Math.min(maxW, maxH * total), s = boxW / total;
const preview = h('div', { class: 'moment-preview' }, composite(pages, s));
const whole = h('button', { class: 'btn small', type: 'button', hidden: !region }, 'Whole page');
const picker = regionPicker(preview, region, r => { region = r; whole.hidden = !r; });
whole.onclick = () => picker.clear();
const hint = h('span', { class: 'muted small moment-hint' });
const saveBtn = h('button', { class: 'btn primary', type: 'submit' }, 'Save moment');
const chips = MOMENT_CATS.map(c => {
const chip = h('button', { class: 'moment-chip', type: 'button', role: 'radio', 'data-cat': c.id, style: `--cat:${c.color}` }, momentIcon(c.id, 18), c.label);
chip.onclick = () => { cat = c.id; drawChips(); };
return chip;
});
const drawChips = () => {
for (const chip of chips) chip.setAttribute('aria-checked', String(chip.dataset.cat === cat));
saveBtn.disabled = !cat;
hint.textContent = cat ? '' : 'Pick a category to save';
};
const note = h('input', { type: 'text', maxlength: '200', placeholder: 'Optional note', 'aria-label': 'Note', value: initial.note || '' });
const error = h('p', { class: 'error small', hidden: true });
const form = h('form', { method: 'dialog' },
h('div', { class: 'moment-head' }, h('h2', {}, heading),
h('button', { class: 'btn icon', type: 'button', 'aria-label': 'Close', onclick: () => dialog.close() }, '×')),
context.map((line, k) => h('div', { class: k ? 'muted small' : 'moment-context' }, line)),
h('div', { class: 'moment-preview-wrap' }, preview),
h('div', { class: 'moment-region-row' },
h('span', { class: 'muted small' }, 'Drag over the picture to keep just a part. Without a selection the whole ' + (pages.length > 1 ? 'spread' : 'page') + ' is saved.'),
whole),
h('div', { class: 'moment-chips', role: 'radiogroup', 'aria-label': 'Category' }, chips),
note, error,
h('div', { class: 'moment-actions' }, hint, h('div', { class: 'grow' }),
h('button', { class: 'btn', type: 'button', onclick: () => dialog.close() }, 'Cancel'), saveBtn));
form.addEventListener('submit', async e => {
e.preventDefault();
if (!cat) return;
saveBtn.disabled = true; error.hidden = true;
try {
const narrowed = lockPages ? { pages, region } : narrowToPage(pages, region);
result = await save({ pages: narrowed.pages, region: narrowed.region, cat, note: note.value });
dialog.close();
} catch (err) { error.textContent = err.message; error.hidden = false; saveBtn.disabled = false; }
});
dialog.append(form);
drawChips();
dialog.showModal();
});
}
const momentReadUrl = (mangaId, m) => 'read.html?' + qsa({ m: mangaId, c: m.c, v: m.v, p: m.pages.length ? Math.min(...m.pages.map(p => p.i)) : null });
/*
* The large viewer: `list` of moment views, starting at `index`. `onChange(list)` after an edit or a delete.
*/
function momentViewer({ mangaId, mangaTitle, list, index, onChange }) {
let k = index, full = false;
const dialog = openDialog('moment-viewer');
const stage = h('div', { class: 'moment-viewer-stage' });
const info = h('div', { class: 'moment-viewer-info' });
const prevBtn = h('button', { class: 'btn icon moment-nav prev', type: 'button', 'aria-label': 'Previous moment', onclick: () => step(-1) }, '‹');
const nextBtn = h('button', { class: 'btn icon moment-nav next', type: 'button', 'aria-label': 'Next moment', onclick: () => step(1) }, '›');
const counter = h('span', { class: 'muted small' });
const fullBtn = h('button', { class: 'btn small', type: 'button', onclick: () => { full = !full; draw(); } });
const readLink = h('a', { class: 'btn primary small' }, 'Open in reader');
const actions = h('div', { class: 'moment-actions' }, fullBtn,
h('button', { class: 'btn small', type: 'button', onclick: edit }, 'Edit'),
h('button', { class: 'btn small danger', type: 'button', onclick: remove }, 'Delete'),
h('div', { class: 'grow' }), readLink);
dialog.append(
h('div', { class: 'moment-head' }, info, counter, h('button', { class: 'btn icon', type: 'button', 'aria-label': 'Close', onclick: () => dialog.close() }, '×')),
h('div', { class: 'moment-viewer-body' }, prevBtn, stage, nextBtn), actions);
function step(d) {
if (!list.length) return;
k = (k + d + list.length) % list.length; full = false; draw();
}
function draw() {
if (!list.length) return dialog.close();
const m = list[k], c = momentCat(m.cat);
const maxW = Math.min(1100, innerWidth - (innerWidth < 600 ? 32 : 140)), maxH = innerHeight - (innerWidth < 600 ? 230 : 200);
stage.replaceChildren(momentCrop(m.pages, full ? null : m.region, maxW, maxH));
info.replaceChildren(h('span', { class: 'moment-tag', style: `--cat:${c.color}` }, momentIcon(m.cat, 16), c.label),
h('span', {}, `${m.chapter.label}${m.chapter.title ? ' · ' + m.chapter.title : ''} · ${m.missing ? 'page moved' : momentPageLabel(m.pages)}`),
h('span', { class: 'muted small' }, ago(m.t)),
...(m.note ? [h('span', { class: 'moment-note' }, m.note)] : []));
counter.textContent = `${k + 1} / ${list.length}`;
prevBtn.hidden = nextBtn.hidden = list.length < 2;
fullBtn.hidden = !m.region || m.missing;
fullBtn.textContent = full ? 'Show selection' : 'Show full ' + (m.pages.length > 1 ? 'spread' : 'page');
readLink.href = momentReadUrl(mangaId, m); // a moved page still opens its chapter
}
async function edit() {
const m = list[k];
if (m.missing) return;
const saved = await momentSheet({ heading: 'Edit moment', context: [mangaTitle, `${m.chapter.label}${m.chapter.title ? ' · ' + m.chapter.title : ''} · ${momentPageLabel(m.pages)}`],
pages: m.pages, initial: m, lockPages: true,
save: async ({ region, cat, note }) => (await api('api/moments/update', { id: m.id, region, cat, note })).moment });
if (!saved) return;
list[k] = saved; draw(); onChange(list);
}
async function remove() {
if (!confirm('Delete this moment?')) return;
await api('api/moments/delete', { id: list[k].id });
list.splice(k, 1);
k = Math.min(k, list.length - 1);
onChange(list);
draw();
}
dialog.addEventListener('keydown', e => {
if (e.target.closest('input, textarea') || dialog.querySelector('dialog[open]')) return;
const action = keyAction(MOMENT_VIEWER_KEYS, e.key);
if (action === 'step') { e.preventDefault(); step(e.key === 'ArrowRight' ? 1 : -1); }
});
let swipe = null;
stage.addEventListener('pointerdown', e => { swipe = { x: e.clientX, y: e.clientY, t: Date.now() }; });
stage.addEventListener('pointerup', e => {
const s = swipe; swipe = null;
if (!s || Date.now() - s.t > 800) return;
const dx = e.clientX - s.x, dy = e.clientY - s.y;
if (Math.abs(dx) >= 50 && Math.abs(dx) > 2 * Math.abs(dy)) step(dx < 0 ? 1 : -1);
});
addEventListener('resize', draw);
dialog.addEventListener('close', () => removeEventListener('resize', draw));
draw();
dialog.showModal();
}
if (typeof module !== 'undefined') module.exports = { MOMENT_CATS, narrowToPage, compositeAspect };
+3
View File
@@ -14,6 +14,8 @@
<script src="common.js" defer></script> <script src="common.js" defer></script>
<script src="reading-clock-core.js" defer></script> <script src="reading-clock-core.js" defer></script>
<script src="reading-clock.js" defer></script> <script src="reading-clock.js" defer></script>
<script src="keys.js" defer></script>
<script src="moments.js" defer></script>
<script src="reader.js" defer></script> <script src="reader.js" defer></script>
</head> </head>
<body class="reader"> <body class="reader">
@@ -23,6 +25,7 @@
<a id="back" class="btn icon" href="./" title="Back to the manga" aria-label="Back">‹</a> <a id="back" class="btn icon" href="./" title="Back to the manga" aria-label="Back">‹</a>
<div id="title" class="grow"></div> <div id="title" class="grow"></div>
<select id="chsel" title="Jump to a chapter" aria-label="Chapter"></select> <select id="chsel" title="Jump to a chapter" aria-label="Chapter"></select>
<button id="moment" class="btn icon moment-btn" title="Save a moment of this page (M)" aria-label="Save a moment"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3.5h10v17l-5-3.6l-5 3.6z"/><path d="M19.5 2.5v5M17 5h5"/></svg></button>
<button id="fs" class="btn icon" title="Fullscreen" aria-label="Fullscreen"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg></button> <button id="fs" class="btn icon" title="Fullscreen" aria-label="Fullscreen"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg></button>
</div> </div>
+66 -11
View File
@@ -149,6 +149,8 @@
const k = list.findIndex(x => x.i >= p); const k = list.findIndex(x => x.i >= p);
cur = k < 0 ? list.length - 1 : k; cur = k < 0 ? list.length - 1 : k;
} }
// A link to a page (e.g. "Open in reader" from a moment) wins over the saved position.
if (P.get('p') != null) { const k = list.findIndex(x => x.i >= +P.get('p')); if (k >= 0) cur = k; }
// Book mode: pages pair up inside each run of single pages. Runs restart after every double page / cover, // Book mode: pages pair up inside each run of single pages. Runs restart after every double page / cover,
// so one missing pair never shifts the rest of the chapter. "Shift" makes a run start with a lone page. // so one missing pair never shifts the rest of the chapter. "Shift" makes a run start with a lone page.
@@ -480,18 +482,67 @@
else toggleBars(); else toggleBars();
}); });
// Keys are looked up in READER_KEYS (keys.js), the same table Settings and "?" show.
const keyActions = {
turn: e => { e.preventDefault(); (e.key === (rtl ? 'ArrowLeft' : 'ArrowRight') ? next : prev)(); },
forward: e => { if (mode === 'flow') return; e.preventDefault(); (e.shiftKey ? prev : next)(); },
back: e => { if (mode === 'flow') return; e.preventDefault(); prev(); },
zoomIn: () => stepZoom(1),
zoomOut: () => stepZoom(-1),
zoomFit: () => setZoom(1),
moment: e => { e.preventDefault(); saveMoment(); },
bars: () => toggleBars(),
fullscreen: () => toggleFullscreen(),
help: () => showKeys(),
};
addEventListener('keydown', e => { addEventListener('keydown', e => {
if ((e.target.closest && e.target.closest('input, select, textarea')) || e.ctrlKey || e.metaKey || e.altKey) return; if ((e.target.closest && e.target.closest('input, select, textarea')) || e.ctrlKey || e.metaKey || e.altKey) return;
const k = e.key; if (document.querySelector('dialog[open]')) return; // an open dialog handles its own keys (Esc closes it)
if (k === 'ArrowLeft' || k === 'ArrowRight') { e.preventDefault(); (k === (rtl ? 'ArrowLeft' : 'ArrowRight') ? next : prev)(); } const action = keyAction(READER_KEYS, e.key);
else if ((k === ' ' || k === 'PageDown') && mode !== 'flow') { e.preventDefault(); (e.shiftKey ? prev : next)(); } if (action) keyActions[action](e);
else if (k === 'PageUp' && mode !== 'flow') { e.preventDefault(); prev(); }
else if (k === '+' || k === '=') stepZoom(1);
else if (k === '-') stepZoom(-1);
else if (k === '0') setZoom(1);
else if (k === 'm' || k === 'Escape') toggleBars();
else if (k === 'f') toggleFullscreen();
}); });
function showKeys() {
const dialog = h('dialog', { class: 'moment-dialog key-dialog' },
h('div', { class: 'moment-head' }, h('h2', {}, 'Keyboard shortcuts'),
h('button', { class: 'btn icon', type: 'button', 'aria-label': 'Close', onclick: () => dialog.close() }, '×')),
keyTable([{ title: 'Reader', keys: READER_KEYS }]),
h('p', { class: 'muted small' }, 'Also listed in Settings → Keyboard shortcuts.'));
dialog.addEventListener('close', () => { dialog.remove(); stage.focus({ preventScroll: true }); });
document.body.append(dialog);
dialog.showModal();
}
// ---------- moments ----------
const momentPages = new Set((D.moments || []).flatMap(x => x.pages));
// The page(s) on screen, left to right as displayed. In Flow the page at the reading line.
function currentPages() {
if (atEnd && mode !== 'flow') return [];
const ks = mode === 'book' ? shown.slice() : [cur];
if (mode === 'book' && rtl) ks.reverse();
return ks.filter(k => list[k]).map(k => {
const p = list[k], raw = D.pages[p.i];
return { i: p.i, u: p.u, w: raw.w, h: raw.h, r: p.r };
});
}
let momentOpen = false;
async function saveMoment() {
const pages = currentPages();
if (!pages.length) return toast('No page here');
if (momentOpen) return;
momentOpen = true;
const chapter = D.chapters.find(c => c.id === D.chapter.id), book = D.books.length > 1 && chapter && D.books.find(b => b.key === chapter.book);
const saved = await momentSheet({
heading: 'Save moment', pages,
context: [D.manga.title, [book && book.label, D.chapter.label + (D.chapter.title ? ' · ' + D.chapter.title : ''), momentPageLabel(pages)].filter(Boolean).join(' · ')],
save: async v => (await api('api/moments', { m: D.manga.id, c: D.chapter.id, v: D.version, pages: v.pages.map(p => p.i), region: v.region, cat: v.cat, note: v.note })).moment,
});
momentOpen = false;
stage.focus({ preventScroll: true });
if (!saved) return;
for (const p of saved.pages) momentPages.add(p.i);
updateUI();
toast(`Saved · ${momentCat(saved.cat).label} moment`, 1500);
}
let scrollRaf = 0; let scrollRaf = 0;
stage.addEventListener('scroll', () => { stage.addEventListener('scroll', () => {
@@ -598,8 +649,8 @@
const key = mode === 'flow' ? 'hint.flow' : 'hint.paged'; const key = mode === 'flow' ? 'hint.flow' : 'hint.paged';
if (store.get(key)) return; if (store.get(key)) return;
store.set(key, '1'); store.set(key, '1');
toast(mode === 'flow' ? 'Controls hidden · tap the middle of the screen or beside the pages to show them' toast(mode === 'flow' ? 'Controls hidden · tap the middle of the screen or beside the pages (or press I) to show them'
: 'Controls hidden · tap the middle of the page or beside it to show them', 3500); : 'Controls hidden · tap the middle of the page or beside it (or press I) to show them', 3500);
} }
let toastTimer = 0; let toastTimer = 0;
@@ -625,6 +676,9 @@
sh.hidden = mode !== 'book'; sh.hidden = mode !== 'book';
sh.classList.toggle('on', shown.length > 0 && !solo(list[shown[0]]) && shift.has(list[runStart(shown[0])].i)); sh.classList.toggle('on', shown.length > 0 && !solo(list[shown[0]]) && shift.has(list[runStart(shown[0])].i));
$('#zoom').textContent = Math.round(zoom() * 100) + '%'; $('#zoom').textContent = Math.round(zoom() * 100) + '%';
const hasMoment = currentPages().some(p => momentPages.has(p.i));
$('#moment').classList.toggle('on', hasMoment);
$('#moment').title = (hasMoment ? 'This page has a moment · save another' : 'Save a moment of this page') + ' (M)';
} }
let saveTimer = 0; let saveTimer = 0;
@@ -669,6 +723,7 @@
ver.onchange = () => { saveProgress(true); location.href = readUrl(D.manga.id, D.chapter.id, ver.value); }; ver.onchange = () => { saveProgress(true); location.href = readUrl(D.manga.id, D.chapter.id, ver.value); };
for (const b of modeBtns) b.onclick = () => { if (b.dataset.mode !== mode) setMode(b.dataset.mode, true); }; for (const b of modeBtns) b.onclick = () => { if (b.dataset.mode !== mode) setMode(b.dataset.mode, true); };
toneButton.onclick = cycleTone; toneButton.onclick = cycleTone;
$('#moment').onclick = saveMoment;
$('#shift').onclick = toggleShift; $('#shift').onclick = toggleShift;
$('#zoomout').onclick = () => stepZoom(-1); $('#zoomout').onclick = () => stepZoom(-1);
$('#zoom').onclick = () => setZoom(1); $('#zoom').onclick = () => setZoom(1);
+1
View File
@@ -5,6 +5,7 @@ const SETTINGS_SECTIONS = [
{ id: 'user/reading', group: 'User', title: 'Reading', hint: 'Reader modes, page spacing and zoom.' }, { id: 'user/reading', group: 'User', title: 'Reading', hint: 'Reader modes, page spacing and zoom.' },
{ id: 'user/reading-hours', group: 'User', title: 'Reading hours', hint: 'When this server allows chapter reading.' }, { id: 'user/reading-hours', group: 'User', title: 'Reading hours', hint: 'When this server allows chapter reading.' },
{ id: 'user/reading-time', group: 'User', title: 'Reading time & privacy', hint: 'Your clock, Reading Story and personal data.' }, { id: 'user/reading-time', group: 'User', title: 'Reading time & privacy', hint: 'Your clock, Reading Story and personal data.' },
{ id: 'user/shortcuts', group: 'User', title: 'Keyboard shortcuts', hint: 'Every key the reader understands. Press ? in the reader for the same list.' },
{ id: 'user/notifications', group: 'User', title: 'Notifications', hint: 'Chapter alerts and your optional webhook.' }, { id: 'user/notifications', group: 'User', title: 'Notifications', hint: 'Chapter alerts and your optional webhook.' },
{ id: 'user/account', group: 'User', title: 'Account', hint: 'Your account and password.' }, { id: 'user/account', group: 'User', title: 'Account', hint: 'Your account and password.' },
{ id: 'admin/scraper', group: 'Admin', title: 'Scraper', hint: 'Live activity, downloads, update checks and sources.' }, { id: 'admin/scraper', group: 'Admin', title: 'Scraper', hint: 'Live activity, downloads, update checks and sources.' },
+1
View File
@@ -9,6 +9,7 @@
<link rel="manifest" href="manifest.webmanifest"> <link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#121214"> <meta name="theme-color" content="#121214">
<script src="common.js" defer></script> <script src="common.js" defer></script>
<script src="keys.js" defer></script>
<script src="settings-nav.js" defer></script> <script src="settings-nav.js" defer></script>
<script src="settings.js" defer></script> <script src="settings.js" defer></script>
</head> </head>
+1
View File
@@ -140,6 +140,7 @@
const oldPw = h('input', { type: 'password', autocomplete: 'current-password', placeholder: 'Current password' }); const oldPw = h('input', { type: 'password', autocomplete: 'current-password', placeholder: 'Current password' });
const newPw = h('input', { type: 'password', autocomplete: 'new-password', placeholder: 'New password' }); const newPw = h('input', { type: 'password', autocomplete: 'new-password', placeholder: 'New password' });
const pwMsg = h('span', { class: 'small' }); const pwMsg = h('span', { class: 'small' });
section('user/shortcuts').append(h('section', { class: 'card' }, keyTable()));
section('user/account').append(h('section', { class: 'card' }, section('user/account').append(h('section', { class: 'card' },
h('h2', {}, 'Account · ' + me.name), h('h2', {}, 'Account · ' + me.name),
h('div', { class: 'row' }, oldPw, newPw, h('button', { class: 'btn', onclick: async () => { h('div', { class: 'row' }, oldPw, newPw, h('button', { class: 'btn', onclick: async () => {
+50 -3
View File
@@ -35,6 +35,7 @@ const {
const { chapterDates } = require('./chapter-dates'); const { chapterDates } = require('./chapter-dates');
const { parseSchedule, publicSchedule, loadQuestionBank, createReadingAccess } = require('./reading-access'); const { parseSchedule, publicSchedule, loadQuestionBank, createReadingAccess } = require('./reading-access');
const { BackupService, BackupError } = require('./backup'); const { BackupService, BackupError } = require('./backup');
const moments = require('./moments');
const PORT = +process.env.PORT || 8080; const PORT = +process.env.PORT || 8080;
const LIB = path.resolve(process.env.LIBRARY || 'library'); const LIB = path.resolve(process.env.LIBRARY || 'library');
@@ -71,7 +72,7 @@ const greatestCovers = new GreatestCovers(path.join(DATA, 'greatest-covers'));
const greatestCoverUrl = entry => entry.cover const greatestCoverUrl = entry => entry.cover
? `api/greatest-cover?id=${encodeURIComponent(entry.id)}&v=${greatestCovers.version(entry.cover)}` : null; ? `api/greatest-cover?id=${encodeURIComponent(entry.id)}&v=${greatestCovers.version(entry.cover)}` : null;
const DB_FILE = path.join(DATA, 'db.json'), CACHE_FILE = path.join(DATA, 'scan-cache.json'), INDEX_FILE = path.join(DATA, 'library-index.json'); const DB_FILE = path.join(DATA, 'db.json'), CACHE_FILE = path.join(DATA, 'scan-cache.json'), INDEX_FILE = path.join(DATA, 'library-index.json');
const db = { users: {}, sessions: {}, progress: {}, reading: {}, readingAccess: {}, follows: {}, notes: {}, votes: {}, greatestRatings: {}, discoveryFeedback: {}, known: {} }; const db = { users: {}, sessions: {}, progress: {}, reading: {}, readingAccess: {}, follows: {}, notes: {}, votes: {}, greatestRatings: {}, discoveryFeedback: {}, moments: {}, known: {} };
// A corrupt db.json stops the server instead of silently starting empty and overwriting it. // A corrupt db.json stops the server instead of silently starting empty and overwriting it.
if (fs.existsSync(DB_FILE)) Object.assign(db, JSON.parse(fs.readFileSync(DB_FILE, 'utf8'))); if (fs.existsSync(DB_FILE)) Object.assign(db, JSON.parse(fs.readFileSync(DB_FILE, 'utf8')));
function writeAtomic(file, text) { fs.writeFileSync(file + '.tmp', text); fs.renameSync(file + '.tmp', file); } function writeAtomic(file, text) { fs.writeFileSync(file + '.tmp', text); fs.renameSync(file + '.tmp', file); }
@@ -231,6 +232,15 @@ const progressOf = (u, m) => (db.progress[u.name] && db.progress[u.name][m.id])
const mangaReadingOf = (u, m) => (db.reading[u.name] && db.reading[u.name][m.id]) || {}; const mangaReadingOf = (u, m) => (db.reading[u.name] && db.reading[u.name][m.id]) || {};
const readingOf = (u, m, ch) => (db.reading[u.name] && db.reading[u.name][m.id] && db.reading[u.name][m.id][ch.id]) || null; const readingOf = (u, m, ch) => (db.reading[u.name] && db.reading[u.name][m.id] && db.reading[u.name][m.id][ch.id]) || null;
const unreadOf = u => (db.notes[u.name] || []).filter(n => !n.read).length; const unreadOf = u => (db.notes[u.name] || []).filter(n => !n.read).length;
const momentsOf = u => (db.moments[u.name] ||= []);
const momentOf = (u, id) => momentsOf(u).find(x => x.id === String(id || '')) || bad(404, 'Moment not found');
const fileSha = async rel => sha256File(path.join(LIB, rel)).catch(() => null);
function sha256File(file) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
fs.createReadStream(file).on('error', reject).on('data', d => hash.update(d)).on('end', () => resolve(hash.digest('hex')));
});
}
const admin = u => u.admin || bad(403, 'Admins only'); const admin = u => u.admin || bad(403, 'Admins only');
const READING_LEASE_MS = 15000; const READING_LEASE_MS = 15000;
const readingLeases = new Map(); const readingLeases = new Map();
@@ -741,6 +751,9 @@ const routes = {
prev: neighbour(m, ch, -1, s), next: neighbour(m, ch, 1, s), prev: neighbour(m, ch, -1, s), next: neighbour(m, ch, 1, s),
progress: prog[ch.id] || null, progress: prog[ch.id] || null,
reading: s.readingClock ? publicSummary(readingOf(u, m, ch), v.id, eligiblePages(v, s)) : null, reading: s.readingClock ? publicSummary(readingOf(u, m, ch), v.id, eligiblePages(v, s)) : null,
// the user's moments on this version, so the reader can mark pages that already have one
moments: momentsOf(u).filter(x => x.m === m.id && x.c === ch.id && x.v === v.id)
.map(x => ({ id: x.id, cat: x.cat, pages: (moments.locate(x, m) || { pages: [] }).pages })),
// for the chapter picker: the whole manga across all books // for the chapter picker: the whole manga across all books
books: m.books.map(b => ({ key: b.key, label: b.label, title: b.title })), books: m.books.map(b => ({ key: b.key, label: b.label, title: b.title })),
chapters: m.chapters.map(c => ({ id: c.id, book: c.book, label: c.label, title: c.title, extra: c.extra, done: !!(prog[c.id] && prog[c.id].done) })), chapters: m.chapters.map(c => ({ id: c.id, book: c.book, label: c.label, title: c.title, extra: c.extra, done: !!(prog[c.id] && prog[c.id].done) })),
@@ -827,6 +840,40 @@ const routes = {
save(); save();
return { following: !!f[m.id] }; return { following: !!f[m.id] };
}, },
// Moments (BL-0055): private to their user; one category each, no uncategorised saves.
'GET /api/moments': ({ u, q }) => {
const m = mangaOf(q.get('m'));
return { moments: momentsOf(u).filter(x => x.m === m.id).map(x => moments.view(x, m, url)).sort(moments.storyCompare) };
},
'POST /api/moments': async ({ u, b }) => {
const m = mangaOf(b.m), ch = chapterOf(m, b.c), v = ch.versions.find(x => x.id === b.v) || bad(404, 'Version not found');
const pages = moments.cleanPages(b.pages, v);
const moment = { id: crypto.randomBytes(6).toString('hex'), m: m.id, c: ch.id, v: v.id, i: pages, f: pages.map(i => v.pages[i].f),
region: moments.cleanRegion(b.region), cat: moments.cleanCategory(b.cat), note: moments.cleanNote(b.note), t: Date.now() };
const list = momentsOf(u);
if (list.length >= moments.MAX_PER_USER) bad(409, `You already have ${moments.MAX_PER_USER} moments.`);
moment.sha = await Promise.all(moment.f.map(f => fileSha(v.rel + '/' + f)));
list.push(moment);
save();
return { moment: moments.view(moment, m, url) };
},
'POST /api/moments/update': ({ u, b }) => {
const moment = momentOf(u, b.id);
if ('cat' in b) moment.cat = moments.cleanCategory(b.cat);
if ('region' in b) moment.region = moments.cleanRegion(b.region);
if ('note' in b) moment.note = moments.cleanNote(b.note);
moment.edited = Date.now();
save();
return { moment: moments.view(moment, lib.byId.get(moment.m), url) };
},
'POST /api/moments/delete': ({ u, b }) => {
const list = momentsOf(u), moment = momentOf(u, b.id);
list.splice(list.indexOf(moment), 1);
save();
return { ok: true };
},
'GET /api/notifications': ({ u }) => ({ items: (db.notes[u.name] || []).slice().reverse() }), 'GET /api/notifications': ({ u }) => ({ items: (db.notes[u.name] || []).slice().reverse() }),
'POST /api/notifications/read': ({ u, b }) => { 'POST /api/notifications/read': ({ u, b }) => {
for (const n of db.notes[u.name] || []) if (b.all || n.id === b.id) n.read = true; for (const n of db.notes[u.name] || []) if (b.all || n.id === b.id) n.read = true;
@@ -868,7 +915,7 @@ const routes = {
admin(u); admin(u);
const name = String(b.name || ''); const name = String(b.name || '');
if (name === u.name) bad(400, "You can't delete your own account."); if (name === u.name) bad(400, "You can't delete your own account.");
delete db.users[name]; delete db.progress[name]; delete db.reading[name]; delete db.follows[name]; delete db.notes[name]; delete db.users[name]; delete db.progress[name]; delete db.reading[name]; delete db.follows[name]; delete db.notes[name]; delete db.moments[name];
if (db.discoveryFeedback) delete db.discoveryFeedback[name]; if (db.discoveryFeedback) delete db.discoveryFeedback[name];
if (db.greatestRatings) delete db.greatestRatings[name]; if (db.greatestRatings) delete db.greatestRatings[name];
for (const [k, s] of Object.entries(db.sessions)) if (s.u === name) delete db.sessions[k]; for (const [k, s] of Object.entries(db.sessions)) if (s.u === name) delete db.sessions[k];
@@ -1080,7 +1127,7 @@ http.createServer(async (req, res) => {
} }
serveFile(req, res, PUB, pathname === '/' ? 'index.html' : pathname.slice(1), 'no-cache'); serveFile(req, res, PUB, pathname === '/' ? 'index.html' : pathname.slice(1), 'no-cache');
} catch (e) { } catch (e) {
const expected = e instanceof HttpError || e instanceof CatalogError; const expected = e instanceof HttpError || e instanceof CatalogError || e instanceof moments.MomentError;
if (!expected) console.error(e); if (!expected) console.error(e);
if (!res.headersSent) sendJson(res, expected ? e.code : 500, expected ? { error: e.message, ...e.extra } : { error: 'Internal error' }); if (!res.headersSent) sendJson(res, expected ? e.code : 500, expected ? { error: e.message, ...e.extra } : { error: 'Internal error' });
} }
+132
View File
@@ -0,0 +1,132 @@
'use strict';
// BL-0055: Moments, a reader's saved pages or page regions with exactly one category.
const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('fs'), os = require('os'), path = require('path'), net = require('net');
const { spawn } = require('child_process');
const { Canvas } = require('../tools/png');
const moments = require('../moments');
const { narrowToPage } = require('../public/moments');
const { READER_KEYS, MOMENT_VIEWER_KEYS, keyAction } = require('../public/keys');
test('regions are clamped fractions; a whole-page rectangle means no region', () => {
assert.deepEqual(moments.cleanRegion({ x: 0.1, y: 0.2, w: 0.3, h: 0.4 }), { x: 0.1, y: 0.2, w: 0.3, h: 0.4 });
assert.deepEqual(moments.cleanRegion({ x: -1, y: 0.5, w: 3, h: 2 }), { x: 0, y: 0.5, w: 1, h: 0.5 });
assert.equal(moments.cleanRegion({ x: 0, y: 0, w: 1, h: 1 }), null);
assert.equal(moments.cleanRegion(null), null);
assert.throws(() => moments.cleanRegion({ x: 0.5, y: 0.5, w: 0.001, h: 0.5 }), /too small/);
assert.throws(() => moments.cleanRegion({ x: 'a', y: 0, w: 1, h: 1 }), /Invalid/);
});
test('exactly one known category is required; notes are trimmed and capped', () => {
for (const cat of ['twist', 'hot', 'awesome', 'cute', 'funny', 'feels']) assert.equal(moments.cleanCategory(cat), cat);
assert.throws(() => moments.cleanCategory(undefined), /Pick one category/);
assert.throws(() => moments.cleanCategory('meh'), /Pick one category/);
assert.equal(moments.cleanNote(' so good \n'), 'so good');
assert.equal(moments.cleanNote('x'.repeat(500)).length, moments.NOTE_MAX);
});
test('a spread region inside one page becomes that page alone', () => {
const left = { i: 5, w: 700, h: 1000 }, right = { i: 4, w: 700, h: 1000 };
const one = narrowToPage([left, right], { x: 0.6, y: 0.1, w: 0.2, h: 0.3 });
assert.deepEqual(one.pages, [right]);
assert.ok(Math.abs(one.region.x - 0.2) < 1e-9 && Math.abs(one.region.w - 0.4) < 1e-9);
const both = narrowToPage([left, right], { x: 0.3, y: 0.1, w: 0.4, h: 0.3 });
assert.equal(both.pages.length, 2);
});
test('every reader key is bound once and the M / I split holds', () => {
const keys = READER_KEYS.flatMap(entry => entry.keys);
assert.equal(new Set(keys).size, keys.length);
assert.equal(keyAction(READER_KEYS, 'm'), 'moment');
assert.equal(keyAction(READER_KEYS, 'i'), 'bars');
assert.equal(keyAction(READER_KEYS, 'Escape'), 'bars');
assert.equal(keyAction(READER_KEYS, '?'), 'help');
assert.equal(keyAction(MOMENT_VIEWER_KEYS, 'ArrowLeft'), 'step');
assert.ok(READER_KEYS.every(entry => entry.label && entry.text));
});
async function freePort() {
const listener = net.createServer();
await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve));
const port = listener.address().port;
await new Promise(resolve => listener.close(resolve));
return port;
}
test('moments are private, validated, editable and survive page renumbering as "moved"', { timeout: 30000 }, async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-moments-'));
let child;
try {
const library = path.join(root, 'library'), data = path.join(root, 'data');
const chapterDir = path.join(library, 'Moon Garden', 'v01', 'c001 Roots');
fs.mkdirSync(chapterDir, { recursive: true }); fs.mkdirSync(data);
for (const n of [1, 2, 3]) fs.writeFileSync(path.join(chapterDir, `00${n}.png`), new Canvas(60, 90, [40 * n, 60, 120]).png());
const port = await freePort(), output = [];
child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true,
env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0',
SCRAPER_URL: 'http://127.0.0.1:9', SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '', BACKUP: '', GREATEST_COVER_WARM: '0' } });
child.stdout.on('data', d => output.push(String(d)));
child.stderr.on('data', d => output.push(String(d)));
const base = `http://127.0.0.1:${port}`;
let ready = false;
for (let n = 0; n < 100 && !ready; n++) {
ready = await fetch(base + '/health').then(r => r.ok).catch(() => false);
if (!ready) await new Promise(resolve => setTimeout(resolve, 50));
}
assert.ok(ready, output.join(''));
const post = (cookie, url, body) => fetch(base + url, { method: 'POST',
headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: JSON.stringify(body) });
const get = (cookie, url) => fetch(base + url, { headers: { cookie } }).then(r => r.json());
const session = r => r.headers.get('set-cookie').split(';')[0];
const alice = session(await post(null, '/api/signup', { name: 'alice', password: 'alice-password' }));
await post(alice, '/api/admin/user', { name: 'bob', password: 'bob-password' });
const bob = session(await post(null, '/api/login', { name: 'bob', password: 'bob-password' }));
await post(alice, '/api/admin/rescan', { full: true });
const manga = await get(alice, '/api/manga?m=' + encodeURIComponent('Moon Garden'));
const c = manga.chapters[0].id, v = manga.chapters[0].versions[0].id;
const base3 = { m: 'Moon Garden', c, v };
// A category is required: nothing is ever dumped into an uncategorised pile.
assert.equal((await post(alice, '/api/moments', { ...base3, pages: [1] })).status, 400);
assert.equal((await post(alice, '/api/moments', { ...base3, pages: [9], cat: 'hot' })).status, 400);
const created = await (await post(alice, '/api/moments', { ...base3, pages: [1], cat: 'awesome', region: { x: 0.1, y: 0.5, w: 0.5, h: 0.25 }, note: ' big fight ' })).json();
assert.equal(created.moment.cat, 'awesome');
assert.equal(created.moment.note, 'big fight');
assert.equal(created.moment.pages[0].i, 1);
assert.match(created.moment.pages[0].u, /^lib\/Moon%20Garden\/v01\/c001%20Roots\/002\.png$/);
const spread = (await (await post(alice, '/api/moments', { ...base3, pages: [2, 0], cat: 'twist' })).json()).moment;
assert.deepEqual(spread.pages.map(p => p.i), [2, 0]);
// Story order: page 0/2 spread starts before page 1.
const listed = (await get(alice, '/api/moments?m=Moon%20Garden')).moments;
assert.deepEqual(listed.map(x => x.id), [spread.id, created.moment.id]);
const chapter = await get(alice, `/api/chapter?m=Moon%20Garden&c=${encodeURIComponent(c)}`);
assert.deepEqual(chapter.moments.map(x => x.pages).sort(), [[1], [2, 0]].sort());
// Private: bob sees nothing and cannot touch alice's moments.
assert.deepEqual((await get(bob, '/api/moments?m=Moon%20Garden')).moments, []);
assert.equal((await get(bob, `/api/chapter?m=Moon%20Garden&c=${encodeURIComponent(c)}`)).moments.length, 0);
assert.equal((await post(bob, '/api/moments/update', { id: created.moment.id, cat: 'hot' })).status, 404);
assert.equal((await post(bob, '/api/moments/delete', { id: created.moment.id })).status, 404);
const edited = (await (await post(alice, '/api/moments/update', { id: created.moment.id, cat: 'feels', region: null })).json()).moment;
assert.equal(edited.cat, 'feels'); assert.equal(edited.region, null);
assert.equal((await post(alice, '/api/moments/update', { id: created.moment.id, cat: '' })).status, 400);
// The file of page 2 goes away: the moment shows as moved instead of pointing at another picture.
fs.renameSync(path.join(chapterDir, '002.png'), path.join(chapterDir, '009.png'));
await post(alice, '/api/admin/rescan', { full: true });
const after = (await get(alice, '/api/moments?m=Moon%20Garden')).moments;
assert.equal(after.find(x => x.id === created.moment.id).missing, true);
assert.equal(after.find(x => x.id === spread.id).missing, false);
assert.equal((await post(alice, '/api/moments/delete', { id: spread.id })).status, 200);
assert.equal((await get(alice, '/api/moments?m=Moon%20Garden')).moments.length, 1);
} finally {
if (child) child.kill();
fs.rmSync(root, { recursive: true, force: true });
}
});