feat(reader): show book titles in reader tabs

Use library metadata so browser history and time tracking identify the book being read.

Closes #1147
This commit is contained in:
neon
2026-08-31 22:42:27 +00:00
parent 2498e09083
commit 7057d350fd
3 changed files with 84 additions and 0 deletions
@@ -7,6 +7,7 @@ import { useFoliate, type RelocateDetail } from './epub/composables/useFoliate'
import type { SelectionDetail } from './epub/composables/useFoliateSelection'
import { useReaderProgress } from './shared/composables/useReaderProgress'
import { useReadingSession } from './shared/composables/useReadingSession'
import { useReaderPageTitle } from './shared/composables/useReaderPageTitle'
import { useReaderState } from './epub/composables/useReaderState'
import { useReaderSettings } from './shared/composables/useReaderSettings'
import { useCustomFonts } from './epub/composables/useCustomFonts'
@@ -49,6 +50,7 @@ const router = useRouter()
const bookId = Number(route.params.bookId)
const fileId = Number(route.params.fileId)
const fileFormat = (route.query.format as string) || 'epub'
useReaderPageTitle(bookId)
const isAudioFormat = getFormatGroup(fileFormat) === 'audio'
const isPdfFormat = fileFormat === 'pdf'
const isComicFormat = fileFormat === 'cbz' || fileFormat === 'cbr' || fileFormat === 'cb7'
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref, toValue, type MaybeRefOrGetter, type Ref } from 'vue'
import type { BookDetail } from '@bookorbit/types'
const mocks = vi.hoisted(() => ({
detail: null as unknown as Ref<BookDetail | null>,
fetchBook: vi.fn<(bookId: number) => Promise<void>>(),
pageTitle: null as MaybeRefOrGetter<string | null | undefined> | null,
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) => {
if (key === 'views.bookDetail.titleWithId') return `Book #${String(params?.id)}`
if (key === 'views.bookDetail.title') return 'Book'
return key
},
}),
}))
vi.mock('@/features/book/composables/useBookDetail', () => ({
useBookDetail: () => ({ detail: mocks.detail, fetch: mocks.fetchBook }),
}))
vi.mock('@/composables/usePageTitle', () => ({
usePageTitle: (title: MaybeRefOrGetter<string | null | undefined>) => {
mocks.pageTitle = title
},
}))
const { useReaderPageTitle } = await import('../useReaderPageTitle')
describe('useReaderPageTitle', () => {
beforeEach(() => {
mocks.detail = ref<BookDetail | null>(null)
mocks.fetchBook.mockReset().mockResolvedValue()
mocks.pageTitle = null
})
it('loads the requested book and uses its title when available', () => {
useReaderPageTitle(42)
expect(mocks.fetchBook).toHaveBeenCalledExactlyOnceWith(42)
expect(toValue(mocks.pageTitle)).toBe('Book #42')
mocks.detail.value = { id: 42, title: ' The Left Hand of Darkness ' } as BookDetail
expect(toValue(mocks.pageTitle)).toBe('The Left Hand of Darkness')
})
it('keeps the localized ID fallback when the book title is blank', () => {
mocks.detail.value = { id: 42, title: ' ' } as BookDetail
useReaderPageTitle(42)
expect(toValue(mocks.pageTitle)).toBe('Book #42')
})
it('uses the generic book fallback when the route ID is invalid', () => {
useReaderPageTitle(Number.NaN)
expect(toValue(mocks.pageTitle)).toBe('Book')
expect(mocks.fetchBook).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,17 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePageTitle } from '@/composables/usePageTitle'
import { useBookDetail } from '@/features/book/composables/useBookDetail'
export function useReaderPageTitle(bookId: number): void {
const { t } = useI18n()
const { detail, fetch } = useBookDetail()
const pageTitle = computed(() => {
const title = detail.value?.title?.trim()
const fallback = Number.isFinite(bookId) ? t('views.bookDetail.titleWithId', { id: bookId }) : t('views.bookDetail.title')
return title || fallback
})
usePageTitle(pageTitle)
if (Number.isFinite(bookId)) void fetch(bookId)
}