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 */}