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