From 74842c59e0287d1ca78288a36f8c1aa53691d035 Mon Sep 17 00:00:00 2001 From: maashkinoozhe Date: Tue, 27 Oct 2026 12:30:24 +0100 Subject: [PATCH] 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 --- README.md | 5 +- docs/architecture.md | 13 ++- moments.js | 74 +++++++++++++ public/app.css | 62 +++++++++++ public/keys.js | 35 ++++++ public/manga.html | 2 + public/manga.js | 49 +++++++++ public/moments.js | 244 +++++++++++++++++++++++++++++++++++++++++ public/read.html | 3 + public/reader.js | 77 +++++++++++-- public/settings-nav.js | 1 + public/settings.html | 1 + public/settings.js | 1 + server.js | 53 ++++++++- test/moments.test.js | 132 ++++++++++++++++++++++ 15 files changed, 734 insertions(+), 18 deletions(-) create mode 100644 moments.js create mode 100644 public/keys.js create mode 100644 public/moments.js create mode 100644 test/moments.test.js diff --git a/README.md b/README.md index 032a235..14219ab 100644 --- a/README.md +++ b/README.md @@ -150,8 +150,9 @@ completeness. - 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). - A horizontal swipe also turns pages on touch screens. - - Keys: arrows, Space / PageDown, `m` = controls, `f` = fullscreen. -- **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. + - Keys: arrows, Space / PageDown, `i` = controls, `m` = save a moment, `f` = fullscreen, `?` = all shortcuts (also under Settings → User → Keyboard shortcuts). +- **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. - **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. diff --git a/docs/architecture.md b/docs/architecture.md index 8c292f0..a8c4272 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -151,6 +151,10 @@ This keeps ids human-readable and derivable without a database. The price: renam "follows": { "admin": { "Moon Garden": true } }, "notes": { "admin": [ { "id": "a1b2c3d4e5f6", "t": 1790797300000, "m": "Moon Garden", "c": "2/6", "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": [""], // 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 } } } }, "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/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 | +| `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/admin` | admin | Users, last scan result, library path | | `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 | | 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 | -| 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:** - 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. -- **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). **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. - 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=`. 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. ## 8. Deployment diff --git a/moments.js b/moments.js new file mode 100644 index 0000000..032c4f0 --- /dev/null +++ b/moments.js @@ -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 }; diff --git a/public/app.css b/public/app.css index 90bc66d..89c1e69 100644 --- a/public/app.css +++ b/public/app.css @@ -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; } .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; } } diff --git a/public/keys.js b/public/keys.js new file mode 100644 index 0000000..e3e706b --- /dev/null +++ b/public/keys.js @@ -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 }; diff --git a/public/manga.html b/public/manga.html index 3e83c03..2fd3ac9 100644 --- a/public/manga.html +++ b/public/manga.html @@ -9,6 +9,8 @@ + + diff --git a/public/manga.js b/public/manga.js index e705f19..2c9ac75 100644 --- a/public/manga.js +++ b/public/manga.js @@ -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(); })(); diff --git a/public/moments.js b/public/moments.js new file mode 100644 index 0000000..1316059 --- /dev/null +++ b/public/moments.js @@ -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: '' }, + { id: 'hot', label: 'Hot', color: '#f0845c', svg: '' }, + { id: 'awesome', label: 'Awesome', color: '#f2b443', svg: '' }, + { id: 'cute', label: 'Cute', color: '#f07aa5', svg: '' }, + { id: 'funny', label: 'Funny', color: '#4fcfa0', svg: '' }, + { id: 'feels', label: 'Feels', color: '#6aa8ec', svg: '' }, +]; +const momentCat = id => MOMENT_CATS.find(c => c.id === id) || MOMENT_CATS[0]; +function momentIcon(id, size = 18) { + const el = icon(``); + 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 }; diff --git a/public/read.html b/public/read.html index 8740e5c..f3c0ce5 100644 --- a/public/read.html +++ b/public/read.html @@ -14,6 +14,8 @@ + + @@ -23,6 +25,7 @@ ‹
+ diff --git a/public/reader.js b/public/reader.js index 0ae86e7..44305f0 100644 --- a/public/reader.js +++ b/public/reader.js @@ -149,6 +149,8 @@ const k = list.findIndex(x => x.i >= p); 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, // 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(); }); + // 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 => { if ((e.target.closest && e.target.closest('input, select, textarea')) || e.ctrlKey || e.metaKey || e.altKey) return; - const k = e.key; - if (k === 'ArrowLeft' || k === 'ArrowRight') { e.preventDefault(); (k === (rtl ? 'ArrowLeft' : 'ArrowRight') ? next : prev)(); } - else if ((k === ' ' || k === 'PageDown') && mode !== 'flow') { e.preventDefault(); (e.shiftKey ? prev : next)(); } - 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(); + if (document.querySelector('dialog[open]')) return; // an open dialog handles its own keys (Esc closes it) + const action = keyAction(READER_KEYS, e.key); + if (action) keyActions[action](e); }); + 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; stage.addEventListener('scroll', () => { @@ -598,8 +649,8 @@ const key = mode === 'flow' ? 'hint.flow' : 'hint.paged'; if (store.get(key)) return; store.set(key, '1'); - toast(mode === 'flow' ? 'Controls hidden · tap the middle of the screen or beside the pages to show them' - : 'Controls hidden · tap the middle of the page or beside it to show them', 3500); + 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 (or press I) to show them', 3500); } let toastTimer = 0; @@ -625,6 +676,9 @@ sh.hidden = mode !== 'book'; sh.classList.toggle('on', shown.length > 0 && !solo(list[shown[0]]) && shift.has(list[runStart(shown[0])].i)); $('#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; @@ -669,6 +723,7 @@ 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); }; toneButton.onclick = cycleTone; + $('#moment').onclick = saveMoment; $('#shift').onclick = toggleShift; $('#zoomout').onclick = () => stepZoom(-1); $('#zoom').onclick = () => setZoom(1); diff --git a/public/settings-nav.js b/public/settings-nav.js index 7c1ac56..dbf4d9f 100644 --- a/public/settings-nav.js +++ b/public/settings-nav.js @@ -5,6 +5,7 @@ const SETTINGS_SECTIONS = [ { 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-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/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.' }, diff --git a/public/settings.html b/public/settings.html index 03fc00c..8e98b49 100644 --- a/public/settings.html +++ b/public/settings.html @@ -9,6 +9,7 @@ + diff --git a/public/settings.js b/public/settings.js index 35f2160..28e7166 100644 --- a/public/settings.js +++ b/public/settings.js @@ -140,6 +140,7 @@ 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 pwMsg = h('span', { class: 'small' }); + section('user/shortcuts').append(h('section', { class: 'card' }, keyTable())); section('user/account').append(h('section', { class: 'card' }, h('h2', {}, 'Account · ' + me.name), h('div', { class: 'row' }, oldPw, newPw, h('button', { class: 'btn', onclick: async () => { diff --git a/server.js b/server.js index 86479bd..4a9ec36 100644 --- a/server.js +++ b/server.js @@ -35,6 +35,7 @@ const { const { chapterDates } = require('./chapter-dates'); const { parseSchedule, publicSchedule, loadQuestionBank, createReadingAccess } = require('./reading-access'); const { BackupService, BackupError } = require('./backup'); +const moments = require('./moments'); const PORT = +process.env.PORT || 8080; 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 ? `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 = { 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. 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); } @@ -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 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 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 READING_LEASE_MS = 15000; const readingLeases = new Map(); @@ -741,6 +751,9 @@ const routes = { prev: neighbour(m, ch, -1, s), next: neighbour(m, ch, 1, s), progress: prog[ch.id] || 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 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) })), @@ -827,6 +840,40 @@ const routes = { save(); 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() }), 'POST /api/notifications/read': ({ u, b }) => { 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); const name = String(b.name || ''); 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.greatestRatings) delete db.greatestRatings[name]; 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'); } 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 (!res.headersSent) sendJson(res, expected ? e.code : 500, expected ? { error: e.message, ...e.extra } : { error: 'Internal error' }); } diff --git a/test/moments.test.js b/test/moments.test.js new file mode 100644 index 0000000..68420bf --- /dev/null +++ b/test/moments.test.js @@ -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 }); + } +});