From 1ddefaa7ac5ad12abbddb4aa82f83ef1b177ca04 Mon Sep 17 00:00:00 2001 From: maashkinoozhe Date: Tue, 27 Oct 2026 12:02:16 +0100 Subject: [PATCH] Library: "Continue where you left off" row with a per-user setting One fixed row above the library showing the five most recently read, unfinished manga with the normal tiles (they stay in the grid as well). It ignores search and filters, hides while sorted by "Recently read", and can be turned off under Settings > Reading > Library page (continueRow, default on). Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + docs/architecture.md | 1 + public/app.css | 11 +++++++++++ public/library.js | 33 ++++++++++++++++++++++----------- public/settings.js | 5 +++++ server.js | 4 ++-- test/services.test.js | 4 ++++ 7 files changed, 46 insertions(+), 13 deletions(-) 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);