Compare commits
2
Commits
e9da27e688
...
41e7ed063e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41e7ed063e | ||
|
|
1ddefaa7ac |
No files matched your search
@@ -142,6 +142,7 @@ completeness.
|
|||||||
- **Flow page gap:** Settings → Reading lets each user keep the normal separator between Flow pages or remove it for continuous webtoon artwork and text that crosses image edges.
|
- **Flow page gap:** Settings → Reading lets each user keep the normal separator between Flow pages or remove it for continuous webtoon artwork and text that crosses image edges.
|
||||||
- **Page tone:** the reader's **Tone** button cycles Original → Inverted → Sepia → Dim → Extra dim in Flow, Page and Book. It changes only the artwork, never the controls or site theme. The choice is saved per user; it can also be selected in Settings → User → Reading.
|
- **Page tone:** the reader's **Tone** button cycles Original → Inverted → Sepia → Dim → Extra dim in Flow, Page and Book. It changes only the artwork, never the controls or site theme. The choice is saved per user; it can also be selected in Settings → User → Reading.
|
||||||
- **Active Reading Clock:** opt in under Settings → User → Reading time & privacy. No scrolling-speed limit; a visit counts at 60% viewed-page coverage and saves on exit. Pauses in the background or after 30 seconds without reader activity. Tap the clock for this visit's time, coverage, qualification and loading wait.
|
- **Active Reading Clock:** opt in under Settings → User → Reading time & privacy. No scrolling-speed limit; a visit counts at 60% viewed-page coverage and saves on exit. Pauses in the background or after 30 seconds without reader activity. Tap the clock for this visit's time, coverage, qualification and loading wait.
|
||||||
|
- **Continue where you left off:** the first line of the library content (below the search, filters and tags) is one row of the five manga you read most recently and have not finished (same tiles as the grid; they stay in the grid too). It hides while a search is typed, a status filter is set, or the library is sorted by *Recently read*; tags and collection grouping do not affect it. Turn it off under Settings → User → Reading → *Library page*.
|
||||||
- **Your Reading Story:** private active-time summaries appear on the library and manga pages when history exists. Chapter rows show the last timed read (60%-coverage visit), rereads remain visible as lifetime time, and Settings lets each user hide, export, or delete their own history. No leaderboards or speed ranking are used.
|
- **Your Reading Story:** private active-time summaries appear on the library and manga pages when history exists. Chapter rows show the last timed read (60%-coverage visit), rereads remain visible as lifetime time, and Settings lets each user hide, export, or delete their own history. No leaderboards or speed ranking are used.
|
||||||
- **Tap / click zones:**
|
- **Tap / click zones:**
|
||||||
- Tapping **beside the pages** always shows or hides the controls.
|
- Tapping **beside the pages** always shows or hides the controls.
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ This keeps ids human-readable and derivable without a database. The price: renam
|
|||||||
"flowPageGap": true, // show the 8 px separator between pages in Flow
|
"flowPageGap": true, // show the 8 px separator between pages in Flow
|
||||||
"readingClock": false, // opt in to credible per-chapter active-time tracking
|
"readingClock": false, // opt in to credible per-chapter active-time tracking
|
||||||
"readingStory": true, // show private retrospective summaries when history exists
|
"readingStory": true, // show private retrospective summaries when history exists
|
||||||
|
"continueRow": true, // library: "Continue where you left off" row at the top
|
||||||
"zoom": { "flow": 1, "page": 1.5, "book": 1, "strip": 0.8 }, // page zoom per mode
|
"zoom": { "flow": 1, "page": 1.5, "book": 1, "strip": 0.8 }, // page zoom per mode
|
||||||
"webhook": ""
|
"webhook": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,18 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
|||||||
.prog i { display: block; height: 100%; background: var(--accent); }
|
.prog i { display: block; height: 100%; background: var(--accent); }
|
||||||
.badge { position: absolute; top: 7px; right: 7px; background: var(--accent); color: #fff; font-size: 12px; font-weight: 700; padding: 2px 8px; border-radius: 10px; }
|
.badge { position: absolute; top: 7px; right: 7px; background: var(--accent); color: #fff; font-size: 12px; font-weight: 700; padding: 2px 8px; border-radius: 10px; }
|
||||||
.empty { text-align: center; padding: 60px 10px; }
|
.empty { text-align: center; padding: 60px 10px; }
|
||||||
|
/* One fixed row; tiles sized like a six-column .grid, extra tiles scroll sideways instead of wrapping. */
|
||||||
|
.continue-row { margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
|
||||||
|
.continue-banner {
|
||||||
|
display: inline-flex; gap: 6px; align-items: center; margin-bottom: 9px; padding: 2px 9px; border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 22%, var(--card)); color: #ffd8de; font-size: 12px; font-weight: 650;
|
||||||
|
}
|
||||||
|
.continue-tiles {
|
||||||
|
display: grid; grid-auto-flow: column; grid-auto-columns: max(140px, calc((100% - 80px) / 6)); gap: 16px;
|
||||||
|
overflow-x: auto; padding-bottom: 4px; scrollbar-width: thin;
|
||||||
|
}
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
|
.continue-tiles { grid-auto-columns: calc((100% - 12px) / 2); gap: 12px; }
|
||||||
main { padding: 12px; }
|
main { padding: 12px; }
|
||||||
.toolbar { flex-wrap: wrap; }
|
.toolbar { flex-wrap: wrap; }
|
||||||
.toolbar input { flex-basis: 100%; }
|
.toolbar input { flex-basis: 100%; }
|
||||||
|
|||||||
+23
-12
@@ -55,6 +55,12 @@
|
|||||||
h('option', { value: 'grouped' }, 'Group collections'), h('option', { value: 'expanded' }, 'Expand collections'), h('option', { value: 'individual' }, 'Individual manga'));
|
h('option', { value: 'grouped' }, 'Group collections'), h('option', { value: 'expanded' }, 'Expand collections'), h('option', { value: 'individual' }, 'Individual manga'));
|
||||||
grouping.value = ['grouped', 'expanded', 'individual'].includes(store.get('grouping')) ? store.get('grouping') : 'grouped';
|
grouping.value = ['grouped', 'expanded', 'individual'].includes(store.get('grouping')) ? store.get('grouping') : 'grouped';
|
||||||
grouping.onchange = render;
|
grouping.onchange = render;
|
||||||
|
// Started but unfinished manga, most recently read first; the same manga still appear in the grid.
|
||||||
|
const continueItems = data.mangas.filter(m => m.lastRead && m.read < m.chapters)
|
||||||
|
.sort((a, b) => b.lastRead - a.lastRead).slice(0, 5);
|
||||||
|
const continueRow = h('section', { class: 'continue-row', 'aria-label': 'Continue where you left off', hidden: true },
|
||||||
|
h('div', { class: 'continue-banner' }, h('span', { 'aria-hidden': 'true' }, '▶'), 'Continue where you left off'),
|
||||||
|
h('div', { class: 'continue-tiles' }, continueItems.map(m => tile(m))));
|
||||||
if (storyAvailable) {
|
if (storyAvailable) {
|
||||||
const most = data.mangas.find(manga => manga.id === data.reading.mostTimeMangaId);
|
const most = data.mangas.find(manga => manga.id === data.reading.mostTimeMangaId);
|
||||||
const latest = data.mangas.find(manga => manga.id === data.reading.lastMangaId);
|
const latest = data.mangas.find(manga => manga.id === data.reading.lastMangaId);
|
||||||
@@ -76,7 +82,7 @@
|
|||||||
h('div', { class: 'filter-heading' }, h('b', {}, 'Tags'), h('span', { class: 'muted small' }, 'Manga must match every selected tag')),
|
h('div', { class: 'filter-heading' }, h('b', {}, 'Tags'), h('span', { class: 'muted small' }, 'Manga must match every selected tag')),
|
||||||
tagBar) : null,
|
tagBar) : null,
|
||||||
resultCount),
|
resultCount),
|
||||||
grid);
|
continueRow, grid);
|
||||||
|
|
||||||
function syncTags() {
|
function syncTags() {
|
||||||
store.set('tags', JSON.stringify([...selectedTags]));
|
store.set('tags', JSON.stringify([...selectedTags]));
|
||||||
@@ -95,8 +101,24 @@
|
|||||||
return direction * (at - bt) || a.title.localeCompare(b.title);
|
return direction * (at - bt) || a.title.localeCompare(b.title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tile(m) {
|
||||||
|
const cover = m.isCollection && m.coverMode === 'mosaic' && m.covers.length
|
||||||
|
? h('div', { class: 'cv collection-mosaic' }, m.covers.map(src => h('img', { loading: 'lazy', src, alt: '' })))
|
||||||
|
: h('div', { class: 'cv' }, m.cover ? h('img', { loading: 'lazy', src: m.cover, alt: '' }) : null);
|
||||||
|
return h('a', { class: 'tile', href: m.isCollection ? 'collection.html?' + qsa({ id: m.id }) : mangaUrl(m.id) },
|
||||||
|
cover, m.isCollection ? h('span', { class: 'collection-count' }, `${m.members.length} manga`) : null,
|
||||||
|
m.unread ? h('span', { class: 'badge' }, m.unread + ' new') : null,
|
||||||
|
h('div', { class: 't' }, m.title),
|
||||||
|
h('div', { class: 's' }, [m.firstRelease, m.status, m.read ? `${m.read} / ${m.chapters} read` : `${m.chapters} chapters`].filter(Boolean).join(' · ')),
|
||||||
|
m.read ? h('div', { class: 'prog' }, h('i', { style: `width:${Math.round((100 * m.read) / Math.max(1, m.chapters))}%` })) : null);
|
||||||
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
store.set('filter', filter.value); store.set('sort', sort.value); store.set('status', status.value); store.set('grouping', grouping.value);
|
store.set('filter', filter.value); store.set('sort', sort.value); store.set('status', status.value); store.set('grouping', grouping.value);
|
||||||
|
// Only on the plain overview: a search or status filter means the reader is looking for something else,
|
||||||
|
// and "Recently read" already puts these first. Tags and grouping keep it.
|
||||||
|
continueRow.hidden = !me.settings.continueRow || !continueItems.length || sort.value === 'read'
|
||||||
|
|| !!filter.value.trim() || !!status.value;
|
||||||
const q = filter.value.trim().toLowerCase();
|
const q = filter.value.trim().toLowerCase();
|
||||||
const groups = (data.collections || []).filter(c => c.grouped);
|
const groups = (data.collections || []).filter(c => c.grouped);
|
||||||
const membership = new Set(groups.flatMap(c => c.members.map(m => m.id)));
|
const membership = new Set(groups.flatMap(c => c.members.map(m => m.id)));
|
||||||
@@ -125,17 +147,6 @@
|
|||||||
}
|
}
|
||||||
resultCount.textContent = `${list.length} results · ${data.mangas.length} manga in the library`;
|
resultCount.textContent = `${list.length} results · ${data.mangas.length} manga in the library`;
|
||||||
grid.textContent = '';
|
grid.textContent = '';
|
||||||
const tile = m => {
|
|
||||||
const cover = m.isCollection && m.coverMode === 'mosaic' && m.covers.length
|
|
||||||
? h('div', { class: 'cv collection-mosaic' }, m.covers.map(src => h('img', { loading: 'lazy', src, alt: '' })))
|
|
||||||
: h('div', { class: 'cv' }, m.cover ? h('img', { loading: 'lazy', src: m.cover, alt: '' }) : null);
|
|
||||||
return h('a', { class: 'tile', href: m.isCollection ? 'collection.html?' + qsa({ id: m.id }) : mangaUrl(m.id) },
|
|
||||||
cover, m.isCollection ? h('span', { class: 'collection-count' }, `${m.members.length} manga`) : null,
|
|
||||||
m.unread ? h('span', { class: 'badge' }, m.unread + ' new') : null,
|
|
||||||
h('div', { class: 't' }, m.title),
|
|
||||||
h('div', { class: 's' }, [m.firstRelease, m.status, m.read ? `${m.read} / ${m.chapters} read` : `${m.chapters} chapters`].filter(Boolean).join(' · ')),
|
|
||||||
m.read ? h('div', { class: 'prog' }, h('i', { style: `width:${Math.round((100 * m.read) / Math.max(1, m.chapters))}%` })) : null);
|
|
||||||
};
|
|
||||||
if (grouping.value === 'expanded') {
|
if (grouping.value === 'expanded') {
|
||||||
const visible = new Set(list.map(m => m.id));
|
const visible = new Set(list.map(m => m.id));
|
||||||
for (const c of groups) {
|
for (const c of groups) {
|
||||||
|
|||||||
@@ -90,6 +90,11 @@
|
|||||||
h('button', { class: 'btn small', onclick: async () => { await save({ zoom: {} }); zoomText.textContent = zooms(); } }, 'Reset to fit'),
|
h('button', { class: 'btn small', onclick: async () => { await save({ zoom: {} }); zoomText.textContent = zooms(); } }, 'Reset to fit'),
|
||||||
h('div', { class: 'muted small' }, '100% = the page fits the screen. Change it in the reader with Ctrl + mouse wheel, a pinch, or the + / - keys.'))));
|
h('div', { class: 'muted small' }, '100% = the page fits the screen. Change it in the reader with Ctrl + mouse wheel, a pinch, or the + / - keys.'))));
|
||||||
|
|
||||||
|
section('user/reading').append(h('section', { class: 'card' },
|
||||||
|
h('h2', {}, 'Library page'),
|
||||||
|
check('continueRow', 'Show "Continue where you left off" at the top',
|
||||||
|
'One row with the five manga you read most recently and have not finished. It hides itself while you search, filter by status, or sort by "Recently read".')));
|
||||||
|
|
||||||
section('user/reading').append(h('section', { class: 'card' },
|
section('user/reading').append(h('section', { class: 'card' },
|
||||||
h('h2', {}, 'Chapter list dates'),
|
h('h2', {}, 'Chapter list dates'),
|
||||||
h('label', { class: 'field' }, h('span', {}, 'When I read a chapter'), readDate,
|
h('label', { class: 'field' }, h('span', {}, 'When I read a chapter'), readDate,
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ function checkPw(pw, stored) {
|
|||||||
}
|
}
|
||||||
const DUMMY_HASH = hashPw('x');
|
const DUMMY_HASH = hashPw('x');
|
||||||
const DEFAULTS = { startMode: 'last', lastMode: 'page', adapt: true, skipExtras: false, skipCovers: false, flowPageGap: true, pageTone: 'original', readingClock: false, readingStory: true,
|
const DEFAULTS = { startMode: 'last', lastMode: 'page', adapt: true, skipExtras: false, skipCovers: false, flowPageGap: true, pageTone: 'original', readingClock: false, readingStory: true,
|
||||||
readDate: 'hour', releaseDate: true, discoveryEnabled: false, discoveryReadingActivity: false, discoveryMature: false, zoom: {}, webhook: '' };
|
continueRow: true, readDate: 'hour', releaseDate: true, discoveryEnabled: false, discoveryReadingActivity: false, discoveryMature: false, zoom: {}, webhook: '' };
|
||||||
const settingsOf = u => ({ ...DEFAULTS, ...u.settings, zoom: { ...(u.settings && u.settings.zoom) } });
|
const settingsOf = u => ({ ...DEFAULTS, ...u.settings, zoom: { ...(u.settings && u.settings.zoom) } });
|
||||||
|
|
||||||
function createUser(name, pw, admin, replace) {
|
function createUser(name, pw, admin, replace) {
|
||||||
@@ -530,7 +530,7 @@ const routes = {
|
|||||||
if (['flow', 'page', 'book'].includes(b.lastMode)) s.lastMode = b.lastMode;
|
if (['flow', 'page', 'book'].includes(b.lastMode)) s.lastMode = b.lastMode;
|
||||||
if (['original', 'inverted', 'sepia', 'dim', 'extra-dim'].includes(b.pageTone)) s.pageTone = b.pageTone;
|
if (['original', 'inverted', 'sepia', 'dim', 'extra-dim'].includes(b.pageTone)) s.pageTone = b.pageTone;
|
||||||
if (['off', 'date', 'hour'].includes(b.readDate)) s.readDate = b.readDate;
|
if (['off', 'date', 'hour'].includes(b.readDate)) s.readDate = b.readDate;
|
||||||
for (const k of ['adapt', 'skipExtras', 'skipCovers', 'flowPageGap', 'readingClock', 'readingStory', 'releaseDate',
|
for (const k of ['adapt', 'skipExtras', 'skipCovers', 'flowPageGap', 'readingClock', 'readingStory', 'releaseDate', 'continueRow',
|
||||||
'discoveryEnabled', 'discoveryReadingActivity', 'discoveryMature']) if (typeof b[k] === 'boolean') s[k] = b[k];
|
'discoveryEnabled', 'discoveryReadingActivity', 'discoveryMature']) if (typeof b[k] === 'boolean') s[k] = b[k];
|
||||||
if (b.zoom && typeof b.zoom === 'object') {
|
if (b.zoom && typeof b.zoom === 'object') {
|
||||||
s.zoom = {};
|
s.zoom = {};
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ test('three services integrate collections, auth, custom covers and private heal
|
|||||||
assert.equal((await (await request('/api/settings', { pageTone: 'sepia' })).json()).settings.pageTone, 'sepia');
|
assert.equal((await (await request('/api/settings', { pageTone: 'sepia' })).json()).settings.pageTone, 'sepia');
|
||||||
assert.equal((await (await request('/api/settings', { pageTone: 'invalid' })).json()).settings.pageTone, 'sepia');
|
assert.equal((await (await request('/api/settings', { pageTone: 'invalid' })).json()).settings.pageTone, 'sepia');
|
||||||
assert.equal((await (await anotherRequest('/api/me')).json()).settings.pageTone, 'original');
|
assert.equal((await (await anotherRequest('/api/me')).json()).settings.pageTone, 'original');
|
||||||
|
assert.equal((await (await request('/api/me')).json()).settings.continueRow, true);
|
||||||
|
assert.equal((await (await request('/api/settings', { continueRow: false })).json()).settings.continueRow, false);
|
||||||
|
assert.equal((await (await request('/api/settings', { continueRow: 'no' })).json()).settings.continueRow, false);
|
||||||
|
assert.equal((await (await anotherRequest('/api/me')).json()).settings.continueRow, true);
|
||||||
assert.equal((await fetch(base + '/api/discovery')).status, 401);
|
assert.equal((await fetch(base + '/api/discovery')).status, 401);
|
||||||
assert.equal((await anotherRequest('/api/admin/discovery/run', {})).status, 403);
|
assert.equal((await anotherRequest('/api/admin/discovery/run', {})).status, 403);
|
||||||
assert.equal((await request('/api/settings', { discoveryEnabled: true })).status, 200);
|
assert.equal((await request('/api/settings', { discoveryEnabled: true })).status, 200);
|
||||||
|
|||||||
Reference in new issue
Block a user