From 7057d350fddf6e82f242f593bf9fb9d96ad1ff87 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:42:27 +0000 Subject: [PATCH] feat(reader): show book titles in reader tabs Use library metadata so browser history and time tracking identify the book being read. Closes #1147 --- client/src/features/reader/ReaderView.vue | 2 + .../__tests__/useReaderPageTitle.spec.ts | 65 +++++++++++++++++++ .../shared/composables/useReaderPageTitle.ts | 17 +++++ 3 files changed, 84 insertions(+) create mode 100644 client/src/features/reader/shared/composables/__tests__/useReaderPageTitle.spec.ts create mode 100644 client/src/features/reader/shared/composables/useReaderPageTitle.ts diff --git a/client/src/features/reader/ReaderView.vue b/client/src/features/reader/ReaderView.vue index 5f9b71e20..5501e7bee 100644 --- a/client/src/features/reader/ReaderView.vue +++ b/client/src/features/reader/ReaderView.vue @@ -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' diff --git a/client/src/features/reader/shared/composables/__tests__/useReaderPageTitle.spec.ts b/client/src/features/reader/shared/composables/__tests__/useReaderPageTitle.spec.ts new file mode 100644 index 000000000..864587404 --- /dev/null +++ b/client/src/features/reader/shared/composables/__tests__/useReaderPageTitle.spec.ts @@ -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, + fetchBook: vi.fn<(bookId: number) => Promise>(), + pageTitle: null as MaybeRefOrGetter | null, +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string, params?: Record) => { + 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) => { + mocks.pageTitle = title + }, +})) + +const { useReaderPageTitle } = await import('../useReaderPageTitle') + +describe('useReaderPageTitle', () => { + beforeEach(() => { + mocks.detail = ref(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() + }) +}) diff --git a/client/src/features/reader/shared/composables/useReaderPageTitle.ts b/client/src/features/reader/shared/composables/useReaderPageTitle.ts new file mode 100644 index 000000000..a4c221fa8 --- /dev/null +++ b/client/src/features/reader/shared/composables/useReaderPageTitle.ts @@ -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) +}