diff --git a/README.md b/README.md index 37afbd3..d168e8a 100644 --- a/README.md +++ b/README.md @@ -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. - **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. +- **Continue where you left off:** the library opens with 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). The row ignores the search and filters and hides while the library is sorted by *Recently read*. 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. - **Tap / click zones:** - Tapping **beside the pages** always shows or hides the controls. diff --git a/docs/architecture.md b/docs/architecture.md index 139df65..8c292f0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 "readingClock": false, // opt in to credible per-chapter active-time tracking "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 "webhook": "" } diff --git a/public/app.css b/public/app.css index 2312b57..90bc66d 100644 --- a/public/app.css +++ b/public/app.css @@ -94,7 +94,18 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; } .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; } .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) { + .continue-tiles { grid-auto-columns: calc((100% - 12px) / 2); gap: 12px; } main { padding: 12px; } .toolbar { flex-wrap: wrap; } .toolbar input { flex-basis: 100%; } diff --git a/public/library.js b/public/library.js index 0e5ebda..db408a5 100644 --- a/public/library.js +++ b/public/library.js @@ -55,6 +55,14 @@ 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.onchange = render; + // Started but unfinished manga, most recently read first. Ignores the filters below on purpose, + // and the same manga still appears 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)))); + main.append(continueRow); if (storyAvailable) { const most = data.mangas.find(manga => manga.id === data.reading.mostTimeMangaId); const latest = data.mangas.find(manga => manga.id === data.reading.lastMangaId); @@ -95,8 +103,22 @@ 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() { store.set('filter', filter.value); store.set('sort', sort.value); store.set('status', status.value); store.set('grouping', grouping.value); + // "Recently read" already puts these first, so the row would only repeat them. + continueRow.hidden = !me.settings.continueRow || sort.value === 'read' || !continueItems.length; const q = filter.value.trim().toLowerCase(); const groups = (data.collections || []).filter(c => c.grouped); 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`; 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') { const visible = new Set(list.map(m => m.id)); for (const c of groups) { diff --git a/public/settings.js b/public/settings.js index 2deb48d..15076d0 100644 --- a/public/settings.js +++ b/public/settings.js @@ -90,6 +90,11 @@ 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.')))); + 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 the library is sorted by "Recently read".'))); + section('user/reading').append(h('section', { class: 'card' }, h('h2', {}, 'Chapter list dates'), h('label', { class: 'field' }, h('span', {}, 'When I read a chapter'), readDate, diff --git a/server.js b/server.js index 9a2b5e5..86479bd 100644 --- a/server.js +++ b/server.js @@ -111,7 +111,7 @@ function checkPw(pw, stored) { } 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, - 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) } }); function createUser(name, pw, admin, replace) { @@ -530,7 +530,7 @@ const routes = { 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 (['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]; if (b.zoom && typeof b.zoom === 'object') { s.zoom = {}; diff --git a/test/services.test.js b/test/services.test.js index 970be98..71cc5a9 100644 --- a/test/services.test.js +++ b/test/services.test.js @@ -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: 'invalid' })).json()).settings.pageTone, 'sepia'); 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 anotherRequest('/api/admin/discovery/run', {})).status, 403); assert.equal((await request('/api/settings', { discoveryEnabled: true })).status, 200);