Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions frontend/e2e/preview-reading.spec.ts
Original file line number Diff line number Diff line change
@@ -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$`))
})
})
14 changes: 14 additions & 0 deletions frontend/src/locales/en/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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?"
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/locales/fr/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -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."

Expand Down Expand Up @@ -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 ?"
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/locales/zh-CN/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down Expand Up @@ -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 ""
Expand Down Expand Up @@ -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 ""
Expand All @@ -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"
Expand Down
56 changes: 48 additions & 8 deletions frontend/src/pages/BookDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -190,6 +190,8 @@ export function BookDetailPage() {
const [deleting, setDeleting] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const [downloadingId, setDownloadingId] = useState<number | null>(null)
const [readMenuOpen, setReadMenuOpen] = useState(false)
const readMenuRef = useRef<HTMLDivElement>(null)
const [draft, setDraft] = useState<Partial<BookDetail>>({})
const [draftBookTypeId, setDraftBookTypeId] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -552,14 +565,41 @@ export function BookDetailPage() {
</div>
)

// 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)) ? (
<button
onClick={() => navigate(`/reader/${book.id}`)}
className="mt-4 flex items-center justify-center gap-2 w-full px-3 py-2.5 rounded-lg text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"
>
<BookMarked className="w-4 h-4" />
<Trans context="action">Read</Trans>
</button>
<div ref={readMenuRef} className="relative mt-4 flex w-full">
<button
onClick={() => navigate(`/reader/${book.id}`)}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-l-lg text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
<BookMarked className="w-4 h-4" />
<Trans context="action">Read</Trans>
</button>
<button
onClick={() => setReadMenuOpen(o => !o)}
aria-label={t`More ways to read`}
aria-expanded={readMenuOpen}
className="flex items-center px-2 rounded-r-lg border-l border-primary-foreground/20 bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
<ChevronDown className="w-3.5 h-3.5" />
</button>
{readMenuOpen && (
<div className="absolute left-0 right-0 top-full mt-1 z-20 rounded-lg border border-border bg-card shadow-lg py-1">
<button
onClick={() => { setReadMenuOpen(false); navigate(`/reader/${book.id}?preview=1`) }}
className="w-full flex items-start gap-2 px-3 py-2 text-left hover:bg-muted transition-colors"
>
<Eye className="w-3.5 h-3.5 mt-0.5 shrink-0 text-muted-foreground" />
<span className="flex flex-col">
<span className="text-xs font-medium text-foreground"><Trans>Preview</Trans></span>
<span className="text-[11px] text-muted-foreground"><Trans>Does not update progress or stats</Trans></span>
</span>
</button>
</div>
)}
</div>
) : null

const downloadButtons = book.files.length > 0 ? (
Expand Down
Loading
Loading