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:
1 parent
41e7ed063e
commit
74842c59e0
15 files changed
+734
-18
No files matched your search
@@ -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.
|
||||
|
||||
+11
-2
@@ -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": ["<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 } } } },
|
||||
"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=<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.
|
||||
|
||||
## 8. Deployment
|
||||
|
||||
+74
@@ -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 };
|
||||
@@ -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; } }
|
||||
@@ -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 };
|
||||
@@ -9,6 +9,8 @@
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#121214">
|
||||
<script src="common.js" defer></script>
|
||||
<script src="keys.js" defer></script>
|
||||
<script src="moments.js" defer></script>
|
||||
<script src="manga.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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 };
|
||||
@@ -14,6 +14,8 @@
|
||||
<script src="common.js" defer></script>
|
||||
<script src="reading-clock-core.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>
|
||||
</head>
|
||||
<body class="reader">
|
||||
@@ -23,6 +25,7 @@
|
||||
<a id="back" class="btn icon" href="./" title="Back to the manga" aria-label="Back">‹</a>
|
||||
<div id="title" class="grow"></div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
+66
-11
@@ -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);
|
||||
|
||||
@@ -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.' },
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#121214">
|
||||
<script src="common.js" defer></script>
|
||||
<script src="keys.js" defer></script>
|
||||
<script src="settings-nav.js" defer></script>
|
||||
<script src="settings.js" defer></script>
|
||||
</head>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user