mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user