From cc7ab54a3bc794cf7d3d061fef0f3bc9140e8b7f Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Fri, 5 Jun 2026 04:19:26 +0000 Subject: [PATCH] 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 --- .../__tests__/useDisplaySettings.spec.ts | 32 ++++++++++ .../__tests__/useDisplaySettingsSync.spec.ts | 34 +++++++++- client/src/composables/useDisplaySettings.ts | 20 ++++++ .../src/composables/useDisplaySettingsSync.ts | 1 + .../book/components/BookCoverCard.test.ts | 40 ++++++++++++ .../book/components/BookCoverCard.vue | 15 ++++- .../features/book/components/BookListRow.vue | 24 ++++++- .../__tests__/BookCoverCard.spec.ts | 59 ++++++++++++++++- .../components/__tests__/BookListRow.spec.ts | 63 ++++++++++++++++++- .../settings/AppearanceBehaviorSettings.vue | 40 +++++++++++- .../__tests__/AppearanceSettings.spec.ts | 17 +++++ packages/types/src/display-preferences.ts | 4 ++ .../user-preferences.controller.test.ts | 1 + .../user-preferences.service.test.ts | 25 ++++++++ .../user-preferences.service.ts | 2 + 15 files changed, 363 insertions(+), 14 deletions(-) diff --git a/client/src/composables/__tests__/useDisplaySettings.spec.ts b/client/src/composables/__tests__/useDisplaySettings.spec.ts index 6d13d17b2..d6ca74c0e 100644 --- a/client/src/composables/__tests__/useDisplaySettings.spec.ts +++ b/client/src/composables/__tests__/useDisplaySettings.spec.ts @@ -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') + }) }) diff --git a/client/src/composables/__tests__/useDisplaySettingsSync.spec.ts b/client/src/composables/__tests__/useDisplaySettingsSync.spec.ts index c1ac0a926..1be169e5f 100644 --- a/client/src/composables/__tests__/useDisplaySettingsSync.spec.ts +++ b/client/src/composables/__tests__/useDisplaySettingsSync.spec.ts @@ -24,6 +24,7 @@ function validDisplayPreferences(overrides: Partial = {}): 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') + }) }) diff --git a/client/src/composables/useDisplaySettings.ts b/client/src/composables/useDisplaySettings.ts index 3ab9e0474..ead0eff9d 100644 --- a/client/src/composables/useDisplaySettings.ts +++ b/client/src/composables/useDisplaySettings.ts @@ -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(normalizeSeriesCardCoverMod const gridCardPrimaryLabel = ref(normalizeGridCardLabelField(storage.get('gridCardPrimaryLabel', 'hidden'))) const gridCardSecondaryLabel = ref(normalizeGridCardLabelField(storage.get('gridCardSecondaryLabel', 'hidden'))) const cardInfoMode = ref(normalizeCardInfoMode(storage.get('cardInfoMode', 'hover-overlay'))) +const thumbnailClickAction = ref( + 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 { if (isApplyingServerPrefs || !isSyncEnabled()) return diff --git a/client/src/features/book/components/BookCoverCard.test.ts b/client/src/features/book/components/BookCoverCard.test.ts index 780902184..e215d180e 100644 --- a/client/src/features/book/components/BookCoverCard.test.ts +++ b/client/src/features/book/components/BookCoverCard.test.ts @@ -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', () => { diff --git a/client/src/features/book/components/BookCoverCard.vue b/client/src/features/book/components/BookCoverCard.vue index bf519d8cb..ea1ffa503 100644 --- a/client/src/features/book/components/BookCoverCard.vue +++ b/client/src/features/book/components/BookCoverCard.vue @@ -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(() => 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
diff --git a/client/src/features/book/components/BookListRow.vue b/client/src/features/book/components/BookListRow.vue index d3445fbc8..09b8f891e 100644 --- a/client/src/features/book/components/BookListRow.vue +++ b/client/src/features/book/components/BookListRow.vue @@ -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') +}