feat(settings): add option to change default thumbnail click behavior

Introduce a new display preference to control what happens when clicking
a book cover thumbnail. Users can now choose to open the book details
page instead of launching the reader directly.

Closes #240
This commit is contained in:
neon
2026-06-05 04:19:26 +00:00
parent ae8001d7ea
commit cc7ab54a3b
15 changed files with 363 additions and 14 deletions
@@ -24,6 +24,7 @@ function resetDisplaySettings() {
settings.gridCardPrimaryLabel.value = 'hidden'
settings.gridCardSecondaryLabel.value = 'hidden'
settings.cardInfoMode.value = 'hover-overlay'
settings.thumbnailClickAction.value = 'reader'
}
afterEach(() => {
@@ -44,6 +45,7 @@ describe('useDisplaySettings preferences helpers', () => {
cardOverlays: ['format', 'rating'],
bookSpineOverlay: 'strong',
bookCoverDisplayMode: 'natural-bottom',
thumbnailClickAction: 'reader',
})
})
@@ -54,6 +56,7 @@ describe('useDisplaySettings preferences helpers', () => {
coverSizeScope: 'synced',
cardOverlays: ['format', 'unknown', 'format', 'lock-status'],
bookCoverDisplayMode: 'fill-crop',
thumbnailClickAction: 'details',
tableDensity: 'huge',
extra: true,
})
@@ -63,6 +66,7 @@ describe('useDisplaySettings preferences helpers', () => {
coverSizeScope: 'synced',
cardOverlays: ['format', 'lock-status'],
bookCoverDisplayMode: 'fill-crop',
thumbnailClickAction: 'details',
})
})
@@ -75,6 +79,7 @@ describe('useDisplaySettings preferences helpers', () => {
tableZebraStriping: true,
bookShadowStrength: 'strong',
bookCoverDisplayMode: 'natural-bottom',
thumbnailClickAction: 'details',
tableDensity: 'invalid',
})
@@ -86,6 +91,7 @@ describe('useDisplaySettings preferences helpers', () => {
expect(settings.tableDensity.value).toBe('comfortable')
expect(settings.bookShadowStrength.value).toBe('strong')
expect(settings.bookCoverDisplayMode.value).toBe('natural-bottom')
expect(settings.thumbnailClickAction.value).toBe('details')
})
it('ignores non-object payloads', () => {
@@ -160,4 +166,30 @@ describe('useDisplaySettings preferences helpers', () => {
expect(settings.gridCardPrimaryLabel.value).toBe('series-title')
expect(settings.gridCardSecondaryLabel.value).toBe('author')
})
it('includes thumbnailClickAction in snapshot', () => {
settings.thumbnailClickAction.value = 'details'
const snap = getDisplayPreferencesSnapshot()
expect(snap.thumbnailClickAction).toBe('details')
})
it('defaults thumbnailClickAction to reader', () => {
resetDisplaySettings()
expect(settings.thumbnailClickAction.value).toBe('reader')
})
it('sanitizes valid thumbnailClickAction values', () => {
expect(sanitizeDisplayPreferences({ thumbnailClickAction: 'reader' })).toEqual({ thumbnailClickAction: 'reader' })
expect(sanitizeDisplayPreferences({ thumbnailClickAction: 'details' })).toEqual({ thumbnailClickAction: 'details' })
})
it('drops invalid thumbnailClickAction values', () => {
expect(sanitizeDisplayPreferences({ thumbnailClickAction: 'preview' })).toEqual({})
expect(sanitizeDisplayPreferences({ thumbnailClickAction: 1 })).toEqual({})
})
it('applies thumbnailClickAction from preferences', () => {
applyDisplayPreferences({ thumbnailClickAction: 'details' })
expect(settings.thumbnailClickAction.value).toBe('details')
})
})
@@ -24,6 +24,7 @@ function validDisplayPreferences(overrides: Partial<DisplayPreferences> = {}): D
seriesCardCoverMode: 'stack',
gridCardPrimaryLabel: 'hidden',
gridCardSecondaryLabel: 'hidden',
thumbnailClickAction: 'reader',
...overrides,
}
}
@@ -66,13 +67,14 @@ describe('useDisplaySettingsSync', () => {
const { apiMock, displaySettings, sync } = await loadModules()
apiMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ settings: validDisplayPreferences({ bookCoverDisplayMode: 'natural-bottom' }) }),
json: async () => ({ settings: validDisplayPreferences({ bookCoverDisplayMode: 'natural-bottom', thumbnailClickAction: 'details' }) }),
})
await sync.loadDisplaySettingsFromServer()
expect(apiMock).toHaveBeenCalledWith('/api/v1/user-preferences/display')
expect(displaySettings.useDisplaySettings().bookCoverDisplayMode.value).toBe('natural-bottom')
expect(displaySettings.useDisplaySettings().thumbnailClickAction.value).toBe('details')
})
it('ignores missing or non-ok server display settings responses', async () => {
@@ -307,4 +309,34 @@ describe('useDisplaySettingsSync', () => {
expect(s.gridCardPrimaryLabel.value).toBe('series-title')
expect(s.gridCardSecondaryLabel.value).toBe('author')
})
it('syncs thumbnailClickAction changes to the server', async () => {
vi.useFakeTimers()
const { apiMock, displaySettings, sync } = await loadModules()
apiMock.mockResolvedValue({ ok: true })
sync.initDisplaySettingsSync()
displaySettings.useDisplaySettings().thumbnailClickAction.value = 'details'
await vi.advanceTimersByTimeAsync(1500)
expect(apiMock).toHaveBeenCalledWith(
'/api/v1/user-preferences/display',
expect.objectContaining({
method: 'PUT',
body: expect.stringContaining('"thumbnailClickAction":"details"'),
}),
)
})
it('loads thumbnailClickAction from the server', async () => {
const { apiMock, displaySettings, sync } = await loadModules()
apiMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ settings: validDisplayPreferences({ thumbnailClickAction: 'details' }) }),
})
await sync.loadDisplaySettingsFromServer()
expect(displaySettings.useDisplaySettings().thumbnailClickAction.value).toBe('details')
})
})
@@ -4,6 +4,7 @@ import {
BOOK_COVER_DISPLAY_MODES,
BOOK_SHADOW_STRENGTHS,
BOOK_SPINE_OVERLAYS,
BOOK_THUMBNAIL_CLICK_ACTION,
BOOK_VIEW_MODES,
CARD_INFO_MODES,
CARD_OVERLAY_KEYS,
@@ -15,6 +16,7 @@ import {
type BookCoverDisplayMode,
type BookShadowStrength,
type BookSpineOverlay,
type BookThumbnailClickAction,
type BookViewMode,
type CardInfoMode,
type CardOverlayKey,
@@ -31,6 +33,7 @@ export type {
BookCoverDisplayMode,
BookShadowStrength,
BookSpineOverlay,
BookThumbnailClickAction,
BookViewMode,
CardInfoMode,
CardOverlayKey,
@@ -48,6 +51,7 @@ const DEFAULT_BOOK_SPINE_OVERLAY: BookSpineOverlay = 'off'
const DEFAULT_BOOK_SHADOW_STRENGTH: BookShadowStrength = 'default'
const DEFAULT_BOOK_COVER_DISPLAY_MODE: BookCoverDisplayMode = 'blurred-fit'
const DEFAULT_SERIES_CARD_COVER_MODE: SeriesCardCoverMode = 'stack'
const DEFAULT_BOOK_THUMBNAIL_CLICK_ACTION: BookThumbnailClickAction = 'reader'
const DEFAULT_CARD_OVERLAYS: CardOverlayKey[] = ['progress-bar', 'format', 'rating', 'read-status', 'series-position']
function normalizeBookSpineOverlay(value: unknown): BookSpineOverlay {
@@ -78,6 +82,12 @@ function normalizeCardInfoMode(value: unknown): CardInfoMode {
return typeof value === 'string' && CARD_INFO_MODES.includes(value as CardInfoMode) ? (value as CardInfoMode) : 'hover-overlay'
}
function normalizeThumbnailClickAction(value: unknown): BookThumbnailClickAction {
return typeof value === 'string' && BOOK_THUMBNAIL_CLICK_ACTION.includes(value as BookThumbnailClickAction)
? (value as BookThumbnailClickAction)
: DEFAULT_BOOK_THUMBNAIL_CLICK_ACTION
}
function normalizeGridCardLabelField(value: unknown): GridCardLabelField {
return typeof value === 'string' && GRID_CARD_LABEL_FIELDS.includes(value as GridCardLabelField) ? (value as GridCardLabelField) : 'hidden'
}
@@ -144,6 +154,9 @@ const seriesCardCoverMode = ref<SeriesCardCoverMode>(normalizeSeriesCardCoverMod
const gridCardPrimaryLabel = ref<GridCardLabelField>(normalizeGridCardLabelField(storage.get('gridCardPrimaryLabel', 'hidden')))
const gridCardSecondaryLabel = ref<GridCardLabelField>(normalizeGridCardLabelField(storage.get('gridCardSecondaryLabel', 'hidden')))
const cardInfoMode = ref<CardInfoMode>(normalizeCardInfoMode(storage.get('cardInfoMode', 'hover-overlay')))
const thumbnailClickAction = ref<BookThumbnailClickAction>(
normalizeThumbnailClickAction(storage.get('thumbnailClickAction', DEFAULT_BOOK_THUMBNAIL_CLICK_ACTION)),
)
watch(portraitCoverSize, (v) => storage.set('portraitCoverSize', v))
watch(squareCoverSize, (v) => storage.set('squareCoverSize', v))
@@ -165,6 +178,7 @@ watch(seriesCardCoverMode, (value) => storage.set('seriesCardCoverMode', normali
watch(gridCardPrimaryLabel, (value) => storage.set('gridCardPrimaryLabel', normalizeGridCardLabelField(value)))
watch(gridCardSecondaryLabel, (value) => storage.set('gridCardSecondaryLabel', normalizeGridCardLabelField(value)))
watch(cardInfoMode, (value) => storage.set('cardInfoMode', normalizeCardInfoMode(value)))
watch(thumbnailClickAction, (value) => storage.set('thumbnailClickAction', normalizeThumbnailClickAction(value)))
export function getDisplayPreferencesSnapshot(): DisplayPreferences {
return {
@@ -188,6 +202,7 @@ export function getDisplayPreferencesSnapshot(): DisplayPreferences {
gridCardPrimaryLabel: normalizeGridCardLabelField(gridCardPrimaryLabel.value),
gridCardSecondaryLabel: normalizeGridCardLabelField(gridCardSecondaryLabel.value),
cardInfoMode: normalizeCardInfoMode(cardInfoMode.value),
thumbnailClickAction: normalizeThumbnailClickAction(thumbnailClickAction.value),
}
}
@@ -229,6 +244,9 @@ export function sanitizeDisplayPreferences(raw: unknown): Partial<DisplayPrefere
if (CARD_INFO_MODES.includes(obj.cardInfoMode as CardInfoMode)) {
out.cardInfoMode = obj.cardInfoMode as CardInfoMode
}
if (BOOK_THUMBNAIL_CLICK_ACTION.includes(obj.thumbnailClickAction as BookThumbnailClickAction)) {
out.thumbnailClickAction = obj.thumbnailClickAction as BookThumbnailClickAction
}
return out
}
@@ -255,6 +273,7 @@ export function applyDisplayPreferences(raw: unknown): void {
if (prefs.gridCardPrimaryLabel !== undefined) gridCardPrimaryLabel.value = prefs.gridCardPrimaryLabel
if (prefs.gridCardSecondaryLabel !== undefined) gridCardSecondaryLabel.value = prefs.gridCardSecondaryLabel
if (prefs.cardInfoMode !== undefined) cardInfoMode.value = prefs.cardInfoMode
if (prefs.thumbnailClickAction !== undefined) thumbnailClickAction.value = prefs.thumbnailClickAction
}
export function useDisplaySettings() {
@@ -279,5 +298,6 @@ export function useDisplaySettings() {
gridCardPrimaryLabel,
gridCardSecondaryLabel,
cardInfoMode,
thumbnailClickAction,
}
}
@@ -121,6 +121,7 @@ export function initDisplaySettingsSync(): void {
settings.gridCardPrimaryLabel.value,
settings.gridCardSecondaryLabel.value,
settings.cardInfoMode.value,
settings.thumbnailClickAction.value,
] as const,
() => {
if (isApplyingServerPrefs || !isSyncEnabled()) return
@@ -57,6 +57,7 @@ const mockBookCoverDisplayMode = ref('blurred-fit')
const mockGridCardPrimaryLabel = ref('hidden')
const mockGridCardSecondaryLabel = ref('hidden')
const mockCardInfoMode = ref('hover-overlay')
const mockThumbnailClickAction = ref('reader')
vi.mock('@/composables/useDisplaySettings', () => ({
useDisplaySettings: () => ({
cardOverlays: mockCardOverlays,
@@ -66,6 +67,7 @@ vi.mock('@/composables/useDisplaySettings', () => ({
gridCardPrimaryLabel: mockGridCardPrimaryLabel,
gridCardSecondaryLabel: mockGridCardSecondaryLabel,
cardInfoMode: mockCardInfoMode,
thumbnailClickAction: mockThumbnailClickAction,
}),
}))
@@ -215,6 +217,7 @@ describe('BookCoverCard', () => {
mockGridCardPrimaryLabel.value = 'hidden'
mockGridCardSecondaryLabel.value = 'hidden'
mockCardInfoMode.value = 'hover-overlay'
mockThumbnailClickAction.value = 'reader'
mockFetchAuthors.mockResolvedValue({
items: [{ id: 7, name: 'Frank Herbert', sortName: null, bookCount: 1, lastAddedAt: '2024-01-01T00:00:00Z' }],
total: 1,
@@ -296,6 +299,29 @@ describe('BookCoverCard', () => {
expect(mockRouterPush).toHaveBeenCalledWith(expect.objectContaining({ name: 'reader', params: { bookId: 1, fileId: 10 } }))
})
it('opens book details on desktop click when thumbnail clicks prefer details', async () => {
setTouchMode(false)
mockThumbnailClickAction.value = 'details'
const book = makeBook({ id: 42, files: [makeFile({ id: 10, format: 'epub', role: 'primary' })] })
const wrapper = mountCard({ book })
await wrapper.find('.group').trigger('click')
expect(mockRouterPush).toHaveBeenCalledWith({ name: 'book-detail', params: { bookId: 42 } })
expect(mockRouterPush).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'reader' }))
})
it('opens book details for missing books when thumbnail clicks prefer details', async () => {
setTouchMode(false)
mockThumbnailClickAction.value = 'details'
const book = makeBook({ id: 43, status: 'missing', files: [] })
const wrapper = mountCard({ book })
await wrapper.find('.group').trigger('click')
expect(mockRouterPush).toHaveBeenCalledWith({ name: 'book-detail', params: { bookId: 43 } })
})
it('does nothing on desktop click when no readable primary file exists', async () => {
setTouchMode(false)
const book = makeBook({ files: [] })
@@ -331,6 +357,7 @@ describe('BookCoverCard', () => {
})
it('emits select and does not open file in selection mode', async () => {
mockThumbnailClickAction.value = 'details'
const mockSelect = vi.fn<(e: MouseEvent) => void>()
const wrapper = mountCard({ selectionMode: true, onSelect: mockSelect })
await wrapper.find('.group').trigger('click')
@@ -345,6 +372,19 @@ describe('BookCoverCard', () => {
await wrapper.find('.group').trigger('click')
expect(mockRouterPush).not.toHaveBeenCalled()
})
it('keeps the explicit read button opening the reader when thumbnail clicks prefer details', async () => {
setTouchMode(false)
mockThumbnailClickAction.value = 'details'
const book = makeBook({ id: 44, files: [makeFile({ id: 12, format: 'epub', role: 'primary' })] })
const wrapper = mountCard({ book })
const overlayButtons = wrapper.find('.bg-black\\/70').findAll('button')
await overlayButtons[1]!.trigger('click')
expect(mockRouterPush).toHaveBeenCalledWith(expect.objectContaining({ name: 'reader', params: { bookId: 44, fileId: 12 } }))
expect(mockRouterPush).not.toHaveBeenCalledWith({ name: 'book-detail', params: { bookId: 44 } })
})
})
describe('below-cover label click behavior', () => {
@@ -115,7 +115,7 @@ async function handleRefreshMetadata() {
if (updated) emit('update:book', mergeBookCardWithDetail(props.book, updated))
}
const { hasPermission } = usePermissions()
const { cardOverlays, bookCoverDisplayMode, gridCardPrimaryLabel, gridCardSecondaryLabel, cardInfoMode } = useDisplaySettings()
const { cardOverlays, bookCoverDisplayMode, gridCardPrimaryLabel, gridCardSecondaryLabel, cardInfoMode, thumbnailClickAction } = useDisplaySettings()
const injectedCoverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const effectiveCoverAspectRatio = computed<CoverAspectRatio>(() => props.coverAspectRatio ?? injectedCoverAspectRatio.value)
const showSendDialog = ref(false)
@@ -162,7 +162,9 @@ const coverOverlayFrameStyle = computed(() => {
return fittedCoverFrameStyle(coverImageRatio.value, coverSlotRatio.value, 'bottom')
})
const isTouch = computed(() => typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches)
const isTouch = computed(
() => typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches,
)
watch(coverSrc, () => {
coverLoaded.value = false
@@ -252,6 +254,8 @@ function handleSecondaryLabelClick(event: MouseEvent) {
handleLabelClick(gridCardSecondaryLabel.value, event)
}
const isPrimaryClickAvailable = computed(() => thumbnailClickAction.value === 'details' || (primaryFile.value != null && !isMissing.value))
function handleCardClick(event: MouseEvent) {
const target = event.target
if (target instanceof Element && target.closest('button, [data-card-click-blocker]')) return
@@ -260,6 +264,11 @@ function handleCardClick(event: MouseEvent) {
return
}
if (thumbnailClickAction.value === 'details') {
openBookDetails()
return
}
if (isTouch.value) {
showMobileOverlay.value = !showMobileOverlay.value
return
@@ -356,7 +365,7 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
<div
ref="root"
class="flex flex-col @container touch-manipulation"
:class="[selectionMode || (primaryFile && !isMissing) ? 'cursor-pointer' : 'cursor-default', selectionMode ? 'select-none' : '']"
:class="[selectionMode || isPrimaryClickAvailable ? 'cursor-pointer' : 'cursor-default', selectionMode ? 'select-none' : '']"
@click="handleCardClick"
@contextmenu.prevent
>
@@ -30,6 +30,7 @@ import { useRefreshMetadata } from '../composables/useRefreshMetadata'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
import { RATING_STARS, getRatingStarClass } from '@/features/book/lib/rating-stars'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
const router = useRouter()
@@ -47,6 +48,7 @@ const emit = defineEmits<{
}>()
const { hasPermission } = usePermissions()
const { thumbnailClickAction } = useDisplaySettings()
const showSendDialog = ref(false)
const authorLine = computed(() => props.book.authors.join(', ') || null)
@@ -129,6 +131,24 @@ function openAuthorBrowse() {
if (!authorQuery.value) return
void router.push({ name: 'authors', query: { q: authorQuery.value } })
}
function openBookDetails() {
void router.push({ name: 'book-detail', params: { bookId: props.book.id } })
}
function handleRowClick(event: MouseEvent) {
if (props.selectionMode) {
emit('select', event)
return
}
if (thumbnailClickAction.value === 'details') {
openBookDetails()
return
}
emit('action', 'quick-view')
}
</script>
<template>
@@ -139,7 +159,7 @@ function openAuthorBrowse() {
selected ? 'bg-primary/8 ring-1 ring-primary/30' : '',
isMissing ? 'grayscale opacity-60' : 'hover:bg-muted/50',
]"
@click="selectionMode ? emit('select', $event) : emit('action', 'quick-view')"
@click="handleRowClick"
>
<!-- Selection checkbox -->
<div
@@ -258,7 +278,7 @@ function openAuthorBrowse() {
<PanelRight class="size-4 mr-2" />
Quick View
</DropdownMenuItem>
<DropdownMenuItem @click="router.push({ name: 'book-detail', params: { bookId: book.id } })">
<DropdownMenuItem @click="openBookDetails">
<ExternalLink class="size-4 mr-2" />
Book Details
</DropdownMenuItem>
@@ -1,14 +1,16 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import BookCoverCard from '../BookCoverCard.vue'
import type { BookCard } from '@bookorbit/types'
import { nextTick, ref } from 'vue'
import { COVER_ASPECT_RATIO_KEY } from '@/features/book/lib/cover-aspect-ratio'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
const routerPushMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => unknown>())
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-router')>()
return { ...actual, useRoute: () => ({ fullPath: '/' }), useRouter: () => ({ push: vi.fn<(...args: unknown[]) => unknown>() }) }
return { ...actual, useRoute: () => ({ fullPath: '/' }), useRouter: () => ({ push: routerPushMock }) }
})
vi.mock('@/features/author/api/author', () => ({
fetchAuthors: vi.fn<(...args: unknown[]) => unknown>(),
@@ -64,7 +66,12 @@ function mountCard(book: BookCard, coverAspectRatio: '2/3' | '1/1' = '2/3', cove
})
}
const { cardOverlays, bookSpineOverlay, bookShadowStrength, gridCardPrimaryLabel, gridCardSecondaryLabel, cardInfoMode } = useDisplaySettings()
const { cardOverlays, bookSpineOverlay, bookShadowStrength, gridCardPrimaryLabel, gridCardSecondaryLabel, cardInfoMode, thumbnailClickAction } =
useDisplaySettings()
beforeEach(() => {
routerPushMock.mockClear()
})
afterEach(() => {
cardOverlays.value = ['progress-bar', 'format', 'rating', 'read-status']
@@ -73,6 +80,7 @@ afterEach(() => {
gridCardPrimaryLabel.value = 'hidden'
gridCardSecondaryLabel.value = 'hidden'
cardInfoMode.value = 'hover-overlay'
thumbnailClickAction.value = 'reader'
})
const missingBook: BookCard = {
@@ -177,6 +185,51 @@ describe('BookCoverCard — cover aspect override', () => {
})
describe('BookCoverCard — present state', () => {
it('opens the reader on desktop card click by default', async () => {
const wrapper = mountCard(presentBook)
await wrapper.find('.group').trigger('click')
expect(routerPushMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'reader', params: { bookId: 2, fileId: 10 } }))
})
it('opens book details on desktop card click when thumbnail clicks prefer details', async () => {
thumbnailClickAction.value = 'details'
const wrapper = mountCard(presentBook)
await wrapper.find('.group').trigger('click')
expect(routerPushMock).toHaveBeenCalledWith({ name: 'book-detail', params: { bookId: 2 } })
expect(routerPushMock).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'reader' }))
})
it('opens book details for missing books when thumbnail clicks prefer details', async () => {
thumbnailClickAction.value = 'details'
const wrapper = mountCard(missingBook)
await wrapper.find('.group').trigger('click')
expect(routerPushMock).toHaveBeenCalledWith({ name: 'book-detail', params: { bookId: 1 } })
})
it('selects instead of navigating in selection mode', async () => {
thumbnailClickAction.value = 'details'
const wrapper = mount(BookCoverCard, {
props: { book: presentBook, selectionMode: true },
global: {
...globalStubs,
provide: {
[COVER_ASPECT_RATIO_KEY as symbol]: ref('2/3'),
},
},
})
await wrapper.find('.group').trigger('click')
expect(wrapper.emitted('select')).toHaveLength(1)
expect(routerPushMock).not.toHaveBeenCalled()
})
it('does not apply grayscale to the cover container', () => {
const wrapper = mountCard(presentBook)
const coverDiv = wrapper.find('[style*="aspect-ratio"]')
@@ -1,12 +1,14 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, it, expect, vi } from 'vitest'
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'
import BookListRow from '../BookListRow.vue'
import type { BookCard } from '@bookorbit/types'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
const routerPushMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => unknown>())
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-router')>()
return { ...actual, useRouter: () => ({ push: vi.fn<(...args: unknown[]) => unknown>() }) }
return { ...actual, useRouter: () => ({ push: routerPushMock }) }
})
vi.mock('@/features/book/composables/useCoverVersions', () => ({
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn<(...args: unknown[]) => void>() }),
@@ -36,10 +38,15 @@ const globalStubs = {
},
}
const { bookSpineOverlay } = useDisplaySettings()
const { bookSpineOverlay, thumbnailClickAction } = useDisplaySettings()
beforeEach(() => {
routerPushMock.mockClear()
})
afterEach(() => {
bookSpineOverlay.value = 'off'
thumbnailClickAction.value = 'reader'
})
const missingBook: BookCard = {
@@ -118,9 +125,59 @@ describe('BookListRow — missing state', () => {
const root = wrapper.find('.flex.items-center')
expect(root.classes().join(' ')).not.toContain('hover:bg-muted')
})
it('opens book details for missing books when thumbnail clicks prefer details', async () => {
thumbnailClickAction.value = 'details'
const wrapper = mount(BookListRow, { props: { book: missingBook }, global: globalStubs })
await wrapper.find('.flex.items-center').trigger('click')
expect(routerPushMock).toHaveBeenCalledWith({ name: 'book-detail', params: { bookId: 1 } })
})
})
describe('BookListRow — present state', () => {
it('emits quick-view on row click by default', async () => {
const wrapper = mount(BookListRow, { props: { book: presentBook }, global: globalStubs })
await wrapper.find('.flex.items-center').trigger('click')
expect(wrapper.emitted('action')?.[0]).toEqual(['quick-view'])
expect(routerPushMock).not.toHaveBeenCalled()
})
it('opens book details on row click when thumbnail clicks prefer details', async () => {
thumbnailClickAction.value = 'details'
const wrapper = mount(BookListRow, { props: { book: presentBook }, global: globalStubs })
await wrapper.find('.flex.items-center').trigger('click')
expect(routerPushMock).toHaveBeenCalledWith({ name: 'book-detail', params: { bookId: 2 } })
expect(wrapper.emitted('action')).toBeUndefined()
})
it('selects instead of navigating in selection mode', async () => {
thumbnailClickAction.value = 'details'
const wrapper = mount(BookListRow, { props: { book: presentBook, selectionMode: true }, global: globalStubs })
await wrapper.find('.flex.items-center').trigger('click')
expect(wrapper.emitted('select')).toHaveLength(1)
expect(routerPushMock).not.toHaveBeenCalled()
})
it('keeps the explicit format button opening the reader when thumbnail clicks prefer details', async () => {
thumbnailClickAction.value = 'details'
const wrapper = mount(BookListRow, { props: { book: presentBook }, global: globalStubs })
const formatButton = wrapper.findAll('button').find((button) => button.text() === 'epub')
expect(formatButton).toBeDefined()
await formatButton!.trigger('click')
expect(routerPushMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'reader', params: { bookId: 2, fileId: 10 } }))
expect(routerPushMock).not.toHaveBeenCalledWith({ name: 'book-detail', params: { bookId: 2 } })
})
it('does not apply grayscale to the root row', () => {
const wrapper = mount(BookListRow, { props: { book: presentBook }, global: globalStubs })
const root = wrapper.find('.flex.items-center')
@@ -1,23 +1,59 @@
<script setup lang="ts">
import { computed } from 'vue'
import { BookOpen, ExternalLink } from 'lucide-vue-next'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useDisplaySettings, type BookThumbnailClickAction } from '@/composables/useDisplaySettings'
import { useSeriesCollapsePreference } from '@/features/book/composables/useSeriesCollapsePreference'
const { smartScopeFilterExpanded } = useDisplaySettings()
const { smartScopeFilterExpanded, thumbnailClickAction } = useDisplaySettings()
const { prefs, setPreference } = useSeriesCollapsePreference()
const globalCollapseEnabled = computed(() => prefs.value?.global ?? false)
const THUMBNAIL_CLICK_OPTIONS: { id: BookThumbnailClickAction; label: string; hint: string; icon: typeof BookOpen }[] = [
{ id: 'reader', label: 'Read first', hint: 'Open the reader when a readable file exists', icon: BookOpen },
{ id: 'details', label: 'Open details', hint: 'Go to the book details page from grid and list thumbnails', icon: ExternalLink },
]
async function handleGlobalCollapseToggle(value: boolean) {
await setPreference('global', value)
}
function setThumbnailClickAction(action: BookThumbnailClickAction) {
thumbnailClickAction.value = action
}
</script>
<template>
<div>
<p class="settings-group-label">Library Behavior</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
<div class="flex flex-col gap-3 px-4 py-3.5 md:flex-row md:items-center md:justify-between md:px-5 md:py-4 bg-card">
<div class="min-w-0">
<p class="settings-label">Thumbnail clicks</p>
<p class="settings-hint overflow-hidden text-ellipsis whitespace-nowrap md:whitespace-normal md:overflow-visible">
Choose what happens when opening a book from grid and list views
</p>
</div>
<div
class="grid w-full gap-1 rounded-lg border border-border bg-muted/50 p-1 sm:w-auto sm:grid-cols-2"
data-testid="thumbnail-click-action-control"
>
<button
v-for="opt in THUMBNAIL_CLICK_OPTIONS"
:key="opt.id"
class="flex min-w-0 items-center gap-1.5 rounded-md px-3 py-1.5 text-left text-xs font-medium transition-colors"
:class="thumbnailClickAction === opt.id ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="setThumbnailClickAction(opt.id)"
>
<component :is="opt.icon" class="size-3.5 shrink-0" />
<span class="min-w-0">
<span class="block truncate">{{ opt.label }}</span>
<span class="block truncate text-[10px] font-normal opacity-75">{{ opt.hint }}</span>
</span>
</button>
</div>
</div>
<div class="flex items-center justify-between gap-3 px-4 py-3 md:px-5 md:py-3.5 bg-card">
<div class="min-w-0">
<p class="settings-label">Show filter preview by default</p>
@@ -37,6 +37,7 @@ const displaySnapshot = {
gridCardPrimaryLabel: 'hidden',
gridCardSecondaryLabel: 'hidden',
cardInfoMode: 'hover-overlay',
thumbnailClickAction: 'reader',
}
const displayRefs = {
@@ -56,6 +57,7 @@ const displayRefs = {
gridCardPrimaryLabel: ref('hidden'),
gridCardSecondaryLabel: ref('hidden'),
cardInfoMode: ref('hover-overlay'),
thumbnailClickAction: ref('reader'),
}
const themeStore = {
@@ -155,6 +157,7 @@ describe('AppearanceSettings', () => {
routeState.query = {}
userState.settings = { syncThemePreferences: false }
displayRefs.bookCoverDisplayMode.value = 'blurred-fit'
displayRefs.thumbnailClickAction.value = 'reader'
apiMock.mockResolvedValue({ ok: true, json: async () => ({ settings: null }) })
})
@@ -319,4 +322,18 @@ describe('AppearanceSettings', () => {
expect(displayRefs.bookCoverDisplayMode.value).toBe('natural-bottom')
})
it('lets the user choose details-first thumbnail clicks from the behavior tab', async () => {
const wrapper = mountComponent()
await wrapper.get('[data-testid="appearance-tab-behavior"]').trigger('click')
const detailsButton = wrapper
.findAll('[data-testid="thumbnail-click-action-control"] button')
.find((button) => button.text().includes('Open details'))
expect(detailsButton).toBeDefined()
await detailsButton!.trigger('click')
expect(displayRefs.thumbnailClickAction.value).toBe('details')
})
})
@@ -31,6 +31,9 @@ export type SeriesCardCoverMode = (typeof SERIES_CARD_COVER_MODES)[number];
export const CARD_INFO_MODES = ["hover-overlay", "below-cover", "off"] as const;
export type CardInfoMode = (typeof CARD_INFO_MODES)[number];
export const BOOK_THUMBNAIL_CLICK_ACTION = ["reader", "details"] as const;
export type BookThumbnailClickAction = (typeof BOOK_THUMBNAIL_CLICK_ACTION)[number];
export interface DisplayPreferences {
portraitCoverSize: number;
squareCoverSize: number;
@@ -52,4 +55,5 @@ export interface DisplayPreferences {
gridCardPrimaryLabel: GridCardLabelField;
gridCardSecondaryLabel: GridCardLabelField;
cardInfoMode: CardInfoMode;
thumbnailClickAction: BookThumbnailClickAction;
}
@@ -53,6 +53,7 @@ const validDisplayPreferences: DisplayPreferences = {
gridCardPrimaryLabel: 'hidden',
gridCardSecondaryLabel: 'hidden',
cardInfoMode: 'hover-overlay',
thumbnailClickAction: 'reader',
};
describe('UserPreferencesController', () => {
@@ -34,6 +34,7 @@ const validDisplayPreferences: DisplayPreferences = {
gridCardPrimaryLabel: 'hidden',
gridCardSecondaryLabel: 'hidden',
cardInfoMode: 'hover-overlay',
thumbnailClickAction: 'reader',
};
const repo = {
@@ -153,6 +154,30 @@ describe('UserPreferencesService', () => {
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', validDisplayPreferences);
});
it('upsertDisplayPreferences accepts all valid thumbnailClickAction values', async () => {
for (const value of ['reader', 'details']) {
await expect(
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, thumbnailClickAction: value } as unknown as Record<string, unknown>),
).resolves.toBeUndefined();
}
});
it('upsertDisplayPreferences defaults thumbnailClickAction to reader when omitted', async () => {
const { thumbnailClickAction, ...withoutAction } = validDisplayPreferences;
void thumbnailClickAction;
await expect(service.upsertDisplayPreferences(11, withoutAction as unknown as Record<string, unknown>)).resolves.toBeUndefined();
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', expect.objectContaining({ thumbnailClickAction: 'reader' }));
});
it('upsertDisplayPreferences rejects invalid thumbnailClickAction values', async () => {
await expect(
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, thumbnailClickAction: 'preview' } as unknown as Record<string, unknown>),
).rejects.toBeInstanceOf(BadRequestException);
expect(repo.upsert).not.toHaveBeenCalled();
});
it('upsertDisplayPreferences accepts all valid seriesCardCoverMode values', async () => {
for (const value of ['stack', 'mosaic', 'first-volume', 'latest-volume', 'first-unread']) {
await expect(
@@ -5,6 +5,7 @@ import {
BOOK_COVER_DISPLAY_MODES,
BOOK_SHADOW_STRENGTHS,
BOOK_SPINE_OVERLAYS,
BOOK_THUMBNAIL_CLICK_ACTION,
BOOK_VIEW_MODES,
CARD_INFO_MODES,
CARD_OVERLAY_KEYS,
@@ -55,6 +56,7 @@ const DISPLAY_PREFERENCES_SCHEMA = z
gridCardPrimaryLabel: z.enum(GRID_CARD_LABEL_FIELDS).default('hidden'),
gridCardSecondaryLabel: z.enum(GRID_CARD_LABEL_FIELDS).default('hidden'),
cardInfoMode: z.enum(CARD_INFO_MODES).default('hover-overlay'),
thumbnailClickAction: z.enum(BOOK_THUMBNAIL_CLICK_ACTION).default('reader'),
})
.strict()
.superRefine((data, ctx) => {