From 0f60ee91223cc297328f45df467dd1cdf72cbad9 Mon Sep 17 00:00:00 2001
From: bndct-devops
Date: Tue, 3 Nov 2026 19:17:19 +0100
Subject: [PATCH] Preview: open a book in the web reader without updating
progress or stats (#289)
The Read button on the book page is now a split button; its caret offers
"Preview", which opens the reader with ?preview=1. In preview the reader
skips its one write, PUT /books/{id}/status, so reading status, position,
the synced device position, the derived web reading session and everything
downstream stay untouched. All nine write sites (EPUB, CBZ, PDF, finish at
the end) go through one helper. A quiet "Preview" tag shows in the reader
bar. Highlights and notes still work. No server change.
Playwright spec: a normal open writes "reading", a preview open sends no
status request and leaves the book unread; the menu item lands on the
preview URL. Changelog, website reader docs, i18n catalogs.
---
CHANGELOG.md | 9 +++
frontend/e2e/preview-reading.spec.ts | 50 +++++++++++++++++
frontend/src/locales/en/messages.po | 14 +++++
frontend/src/locales/fr/messages.po | 14 +++++
frontend/src/locales/zh-CN/messages.po | 14 +++++
frontend/src/pages/BookDetailPage.tsx | 56 +++++++++++++++---
frontend/src/pages/ReaderPage.tsx | 78 +++++++++++++++++++-------
website/src/pages/docs/reader.astro | 10 ++++
8 files changed, 217 insertions(+), 28 deletions(-)
create mode 100644 frontend/e2e/preview-reading.spec.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 813324e..d664e16 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,15 @@ All notable changes to Tome are documented here. Format loosely follows
## [Unreleased]
+### Added
+- **Preview a book without touching your progress.** The Read button on the
+ book page now has a small menu with "Preview": the book opens in the web
+ reader as usual, but nothing is written back. Reading status, position, the
+ position synced to your devices, reading time and stats all stay as they
+ were, so you can check a file's formatting or flip through a book to decide
+ on it without it turning up as started. The reader shows a "Preview" tag
+ while this is on. Highlights and notes still work. (#289)
+
## [2.8.0] - 2026-10-24 - "Folio"
### Added
diff --git a/frontend/e2e/preview-reading.spec.ts b/frontend/e2e/preview-reading.spec.ts
new file mode 100644
index 0000000..1326faf
--- /dev/null
+++ b/frontend/e2e/preview-reading.spec.ts
@@ -0,0 +1,50 @@
+import { test, expect } from '@playwright/test'
+import { seed, login, apiToken, apiGet } from './helpers'
+
+// Preview (#289): the reader opened with ?preview=1 must not write anything
+// back. Every write the web reader makes is PUT /books/{id}/status, so the
+// test counts those and checks the stored status afterwards.
+test.describe('preview reading', () => {
+ test('a normal open marks the book reading, a preview leaves it untouched', async ({ page, request }) => {
+ seed('many', 2)
+ await login(page)
+ const token = await apiToken(request)
+ const { body: books } = await apiGet<{ id: number; title: string }[]>(request, token, '/api/books?limit=10')
+ const [normal, preview] = books
+
+ const puts: string[] = []
+ await page.route(/\/api\/books\/\d+\/status$/, route => {
+ if (route.request().method() === 'PUT') puts.push(route.request().url())
+ return route.continue()
+ })
+
+ // Control: the normal reader writes "reading" as soon as it opens.
+ await page.goto(`/reader/${normal.id}`)
+ await expect.poll(() => puts.length, { timeout: 10_000 }).toBeGreaterThan(0)
+ const after = await apiGet<{ status: string }>(request, token, `/api/books/${normal.id}/status`)
+ expect(after.body.status).toBe('reading')
+
+ // Preview: same reader, nothing written.
+ puts.length = 0
+ await page.goto(`/reader/${preview.id}?preview=1`)
+ await page.waitForTimeout(4000)
+ expect(puts).toHaveLength(0)
+ const kept = await apiGet<{ status: string; progress_pct: number | null }>(request, token, `/api/books/${preview.id}/status`)
+ expect(kept.body.status).toBe('unread')
+ expect(kept.body.progress_pct).toBeNull()
+ })
+
+ test('the Read button menu opens the preview', async ({ page, request }) => {
+ seed('many', 1)
+ await login(page)
+ const token = await apiToken(request)
+ const { body: books } = await apiGet<{ id: number }[]>(request, token, '/api/books?limit=1')
+
+ await page.goto(`/books/${books[0].id}`)
+ await expect(page.getByRole('button', { name: 'Read', exact: true })).toBeVisible()
+ await page.getByRole('button', { name: 'More ways to read' }).click()
+ await expect(page.getByText('Does not update progress or stats')).toBeVisible()
+ await page.getByText('Preview', { exact: true }).click()
+ await expect(page).toHaveURL(new RegExp(`/reader/${books[0].id}\\?preview=1$`))
+ })
+})
diff --git a/frontend/src/locales/en/messages.po b/frontend/src/locales/en/messages.po
index fdd3ff2..d4c06df 100644
--- a/frontend/src/locales/en/messages.po
+++ b/frontend/src/locales/en/messages.po
@@ -2891,6 +2891,10 @@ msgstr "Do you rate books you spend longer on higher?"
msgid "Docs"
msgstr "Docs"
+#: src/pages/BookDetailPage.tsx
+msgid "Does not update progress or stats"
+msgstr "Does not update progress or stats"
+
#: src/components/ConnectPhoneModal.tsx
#~ msgid "Don't have it yet? Tome Reader is in open beta for iPhone."
#~ msgstr "Don't have it yet? Tome Reader is in open beta for iPhone."
@@ -4579,6 +4583,10 @@ msgstr "More"
msgid "More send options"
msgstr "More send options"
+#: src/pages/BookDetailPage.tsx
+msgid "More ways to read"
+msgstr "More ways to read"
+
#: src/pages/StatsPage.tsx
msgid "Morning vs evening reader?"
msgstr "Morning vs evening reader?"
@@ -5737,6 +5745,8 @@ msgid "Preparing…"
msgstr "Preparing…"
#: src/components/MetadataFetchModal.tsx
+#: src/pages/BookDetailPage.tsx
+#: src/pages/ReaderPage.tsx
#: src/pages/SettingsPage.tsx
msgid "Preview"
msgstr "Preview"
@@ -5745,6 +5755,10 @@ msgstr "Preview"
msgid "Preview:"
msgstr "Preview:"
+#: src/pages/ReaderPage.tsx
+msgid "Preview: progress and stats are not updated"
+msgstr "Preview: progress and stats are not updated"
+
#: src/components/MetadataManager.tsx
#: src/pages/AdminPage.tsx
msgid "Previous"
diff --git a/frontend/src/locales/fr/messages.po b/frontend/src/locales/fr/messages.po
index 3d7d633..63939d8 100644
--- a/frontend/src/locales/fr/messages.po
+++ b/frontend/src/locales/fr/messages.po
@@ -2882,6 +2882,10 @@ msgstr "Notez-vous mieux les livres auxquels vous consacrez plus de temps ?"
msgid "Docs"
msgstr "Documentation"
+#: src/pages/BookDetailPage.tsx
+msgid "Does not update progress or stats"
+msgstr ""
+
#~ msgid "Don't have it yet? Tome Reader is in open beta for iPhone."
#~ msgstr "Don't have it yet? Tome Reader is in open beta for iPhone."
@@ -4560,6 +4564,10 @@ msgstr "Plus"
msgid "More send options"
msgstr "Autres options d’envoi"
+#: src/pages/BookDetailPage.tsx
+msgid "More ways to read"
+msgstr ""
+
#: src/pages/StatsPage.tsx
msgid "Morning vs evening reader?"
msgstr "Plutôt lecture du matin ou du soir ?"
@@ -5713,6 +5721,8 @@ msgid "Preparing…"
msgstr "Préparation…"
#: src/components/MetadataFetchModal.tsx
+#: src/pages/BookDetailPage.tsx
+#: src/pages/ReaderPage.tsx
#: src/pages/SettingsPage.tsx
msgid "Preview"
msgstr "Aperçu"
@@ -5721,6 +5731,10 @@ msgstr "Aperçu"
msgid "Preview:"
msgstr "Aperçu :"
+#: src/pages/ReaderPage.tsx
+msgid "Preview: progress and stats are not updated"
+msgstr ""
+
#: src/components/MetadataManager.tsx
#: src/pages/AdminPage.tsx
msgid "Previous"
diff --git a/frontend/src/locales/zh-CN/messages.po b/frontend/src/locales/zh-CN/messages.po
index 7faf800..8d01d5c 100644
--- a/frontend/src/locales/zh-CN/messages.po
+++ b/frontend/src/locales/zh-CN/messages.po
@@ -2891,6 +2891,10 @@ msgstr ""
msgid "Docs"
msgstr ""
+#: src/pages/BookDetailPage.tsx
+msgid "Does not update progress or stats"
+msgstr ""
+
#: src/components/ConnectPhoneModal.tsx
#~ msgid "Don't have it yet? Tome Reader is in open beta for iPhone."
#~ msgstr ""
@@ -4579,6 +4583,10 @@ msgstr ""
msgid "More send options"
msgstr ""
+#: src/pages/BookDetailPage.tsx
+msgid "More ways to read"
+msgstr ""
+
#: src/pages/StatsPage.tsx
msgid "Morning vs evening reader?"
msgstr ""
@@ -5737,6 +5745,8 @@ msgid "Preparing…"
msgstr ""
#: src/components/MetadataFetchModal.tsx
+#: src/pages/BookDetailPage.tsx
+#: src/pages/ReaderPage.tsx
#: src/pages/SettingsPage.tsx
msgid "Preview"
msgstr ""
@@ -5745,6 +5755,10 @@ msgstr ""
msgid "Preview:"
msgstr ""
+#: src/pages/ReaderPage.tsx
+msgid "Preview: progress and stats are not updated"
+msgstr ""
+
#: src/components/MetadataManager.tsx
#: src/pages/AdminPage.tsx
msgid "Previous"
diff --git a/frontend/src/pages/BookDetailPage.tsx b/frontend/src/pages/BookDetailPage.tsx
index 959dcff..85910cb 100644
--- a/frontend/src/pages/BookDetailPage.tsx
+++ b/frontend/src/pages/BookDetailPage.tsx
@@ -6,7 +6,7 @@ import {
Calendar, Globe, Hash, Building2, FileText, Trash2, Loader2,
Sparkles, Library, Check, BookMarked, ChevronLeft, ChevronRight, Home,
Tag as TagIcon, StickyNote, ChevronDown, Archive, AlignLeft,
- Plus, TrendingUp, TrendingDown, Minus, History as HistoryIcon, Share2, Timer
+ Plus, TrendingUp, TrendingDown, Minus, History as HistoryIcon, Share2, Timer, Eye
} from 'lucide-react'
import { useAuth, isMember } from '@/contexts/AuthContext'
import { useToast } from '@/contexts/ToastContext'
@@ -190,6 +190,8 @@ export function BookDetailPage() {
const [deleting, setDeleting] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const [downloadingId, setDownloadingId] = useState(null)
+ const [readMenuOpen, setReadMenuOpen] = useState(false)
+ const readMenuRef = useRef(null)
const [draft, setDraft] = useState>({})
const [draftBookTypeId, setDraftBookTypeId] = useState(null)
const [error, setError] = useState(null)
@@ -257,6 +259,17 @@ export function BookDetailPage() {
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [isDirty])
+ useEffect(() => {
+ if (!readMenuOpen) return
+ function onMouseDown(e: MouseEvent) {
+ if (readMenuRef.current && !readMenuRef.current.contains(e.target as Node)) {
+ setReadMenuOpen(false)
+ }
+ }
+ document.addEventListener('mousedown', onMouseDown)
+ return () => document.removeEventListener('mousedown', onMouseDown)
+ }, [readMenuOpen])
+
useEffect(() => {
if (!libMenuOpen) return
function onMouseDown(e: MouseEvent) {
@@ -552,14 +565,41 @@ export function BookDetailPage() {
)
+ // Split button, same shape as Send: the primary half opens the reader, the
+ // caret offers Preview, which opens the book without touching progress or
+ // stats (#289). No hover lift here, it would lift the halves independently.
const readButton = book.files.some(f => ['epub', 'cbz', 'cbr', 'pdf'].includes(f.format)) ? (
-
+
+
+
+ {readMenuOpen && (
+
+
+
+ )}
+
) : null
const downloadButtons = book.files.length > 0 ? (
diff --git a/frontend/src/pages/ReaderPage.tsx b/frontend/src/pages/ReaderPage.tsx
index 044ed93..edbc4fc 100644
--- a/frontend/src/pages/ReaderPage.tsx
+++ b/frontend/src/pages/ReaderPage.tsx
@@ -3,7 +3,7 @@ import { Trans } from '@lingui/react/macro'
import { t, msg } from '@lingui/core/macro'
import { i18n } from '@lingui/core'
import type { MessageDescriptor } from '@lingui/core'
-import { useParams, useNavigate } from 'react-router-dom'
+import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import {
ArrowLeft, BookOpen, Settings, Rows4,
ChevronLeft, ChevronRight, Minus, Plus, X,
@@ -715,6 +715,9 @@ const PdfReader = forwardRef(function PdfReader
export default function ReaderPage() {
const { bookId } = useParams<{ bookId: string }>()
const navigate = useNavigate()
+ const [searchParams] = useSearchParams()
+ // Preview (?preview=1): open the book without writing anything back (#289).
+ const preview = searchParams.get('preview') === '1'
const [book, setBook] = useState(null)
const [loadError, setLoadError] = useState(null)
@@ -817,6 +820,14 @@ export default function ReaderPage() {
const saveTimer = useRef | null>(null)
const initialCfi = useRef(null)
const readyToSave = useRef(false)
+
+ // Every write the reader makes is this one call. In preview it is skipped,
+ // which leaves status, progress, the synced device position, the derived
+ // web reading session and everything downstream exactly as they were.
+ const saveStatus = useCallback((body: { status: string; progress_pct?: number | null; cfi?: string | null }) => {
+ if (!bookId || preview) return
+ api.put(`/books/${bookId}/status`, body).catch(() => {})
+ }, [bookId, preview])
// Holds the latest applyStyles so the foliate 'load' listener (registered
// once at init) always re-applies the CURRENT theme/font on each new
// chapter, instead of the stale values captured when the book opened.
@@ -845,23 +856,23 @@ export default function ReaderPage() {
const isLastPage = page >= total - 1
const fraction = isLastPage ? 1 : (page + 1) / total
saveTimer.current = setTimeout(() => {
- api.put(`/books/${bookId}/status`, {
+ saveStatus({
status: isLastPage ? 'read' : 'reading',
progress_pct: fraction,
cfi: `comic:${page}`,
- }).catch(() => {})
+ })
}, 1500)
- }, [bookId])
+ }, [bookId, saveStatus])
const handleComicReadComplete = useCallback(() => {
if (!bookId || comicTotalPages <= 0) return
if (saveTimer.current) clearTimeout(saveTimer.current)
- api.put(`/books/${bookId}/status`, {
+ saveStatus({
status: 'read',
progress_pct: 1,
cfi: `comic:${comicTotalPages - 1}`,
- }).catch(() => {})
- }, [bookId, comicTotalPages])
+ })
+ }, [bookId, comicTotalPages, saveStatus])
// 1-based progress so the last page (index total-1) reads as 100%.
const comicPctFor = (page: number, total: number) =>
@@ -888,13 +899,13 @@ export default function ReaderPage() {
const isLast = page >= total - 1
const fraction = isLast ? 1 : (page + 1) / total
saveTimer.current = setTimeout(() => {
- api.put(`/books/${bookId}/status`, {
+ saveStatus({
status: isLast ? 'read' : 'reading',
progress_pct: fraction,
cfi: `pdf:${page}`,
- }).catch(() => {})
+ })
}, 1500)
- }, [bookId])
+ }, [bookId, saveStatus])
const handlePdfProgress = useCallback((page: number, total: number) => {
setPdfCurrentPage(page)
@@ -905,12 +916,12 @@ export default function ReaderPage() {
const handlePdfReadComplete = useCallback(() => {
if (!bookId || pdfTotalPages <= 0) return
if (saveTimer.current) clearTimeout(saveTimer.current)
- api.put(`/books/${bookId}/status`, {
+ saveStatus({
status: 'read',
progress_pct: 1,
cfi: `pdf:${pdfTotalPages - 1}`,
- }).catch(() => {})
- }, [bookId, pdfTotalPages])
+ })
+ }, [bookId, pdfTotalPages, saveStatus])
// ── Toolbar auto-hide for comic mode ────────────────────────────────────────
@@ -963,7 +974,7 @@ export default function ReaderPage() {
// Will refine once we know totalPages
savedPage = 0 // will be set below after we know total
}
- api.put(`/books/${bookId}/status`, { status: 'reading' }).catch(() => {})
+ saveStatus({ status: 'reading' })
} catch { /* no saved position */ }
try {
@@ -1002,7 +1013,7 @@ export default function ReaderPage() {
setPdfInitialFraction(s.progress_pct)
}
} catch { /* no saved position */ }
- api.put(`/books/${bookId}/status`, { status: 'reading' }).catch(() => {})
+ saveStatus({ status: 'reading' })
// PdfReader loads the document itself; clear the page-level spinner.
setLoading(false)
return
@@ -1025,7 +1036,7 @@ export default function ReaderPage() {
}
} catch { /* no saved position */ }
- api.put(`/books/${bookId}/status`, { status: 'reading' }).catch(() => {})
+ saveStatus({ status: 'reading' })
if (!customElements.get('foliate-view')) {
await new Promise((resolve, reject) => {
@@ -1131,7 +1142,7 @@ export default function ReaderPage() {
const fraction = detail.fraction ?? 0
if (detail.cfi) saveCfi(detail.cfi, fraction)
if (pct >= 95) {
- api.put(`/books/${bookId}/status`, { status: 'read', progress_pct: 1, cfi: detail.cfi ?? null }).catch(() => {})
+ saveStatus({ status: 'read', progress_pct: 1, cfi: detail.cfi ?? null })
}
})
@@ -1196,13 +1207,13 @@ export default function ReaderPage() {
if (!bookId) return
if (saveTimer.current) clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => {
- api.put(`/books/${bookId}/status`, {
+ saveStatus({
status: 'reading',
progress_pct: fraction,
cfi,
- }).catch(() => {})
+ })
}, 1500)
- }, [bookId])
+ }, [bookId, saveStatus])
// ── EPUB navigation ────────────────────────────────────────────────────────
@@ -1418,6 +1429,15 @@ export default function ReaderPage() {
{book?.title ?? ''}
+ {preview && (
+
+ Preview
+
+ )}
{/* RTL toggle */}
+
+
Preview
+
+ The caret next to Read on a book page offers Preview: the
+ book opens in the same reader, but nothing is written back. Reading status, position, the
+ position synced to your devices, reading time and stats all stay as they were. Use it to
+ check a file's formatting or flip through a book before deciding to read it. A small
+ "Preview" tag in the reader bar shows it is on; it applies to that one open only.
+ Highlights and notes still work.
+