diff --git a/client/src/composables/__tests__/useDisplaySettings.spec.ts b/client/src/composables/__tests__/useDisplaySettings.spec.ts index 1988a9577..db93d9794 100644 --- a/client/src/composables/__tests__/useDisplaySettings.spec.ts +++ b/client/src/composables/__tests__/useDisplaySettings.spec.ts @@ -21,6 +21,8 @@ function resetDisplaySettings() { settings.bookShadowStrength.value = 'default' settings.bookCoverDisplayMode.value = 'blurred-fit' settings.seriesCardCoverMode.value = 'mosaic' + settings.gridCardPrimaryLabel.value = 'hidden' + settings.gridCardSecondaryLabel.value = 'hidden' } afterEach(() => { @@ -115,4 +117,45 @@ describe('useDisplaySettings preferences helpers', () => { resetDisplaySettings() expect(settings.seriesCardCoverMode.value).toBe('mosaic') }) + + it('includes gridCardPrimaryLabel and gridCardSecondaryLabel in snapshot', () => { + settings.gridCardPrimaryLabel.value = 'book-title' + settings.gridCardSecondaryLabel.value = 'author' + const snap = getDisplayPreferencesSnapshot() + expect(snap.gridCardPrimaryLabel).toBe('book-title') + expect(snap.gridCardSecondaryLabel).toBe('author') + }) + + it('defaults both label fields to hidden', () => { + resetDisplaySettings() + expect(settings.gridCardPrimaryLabel.value).toBe('hidden') + expect(settings.gridCardSecondaryLabel.value).toBe('hidden') + }) + + it('sanitizes valid gridCardPrimaryLabel values', () => { + for (const value of ['hidden', 'book-title', 'series-title', 'series-title-position', 'author'] as const) { + expect(sanitizeDisplayPreferences({ gridCardPrimaryLabel: value })).toEqual({ gridCardPrimaryLabel: value }) + } + }) + + it('sanitizes valid gridCardSecondaryLabel values', () => { + for (const value of ['hidden', 'book-title', 'series-title', 'series-title-position', 'author'] as const) { + expect(sanitizeDisplayPreferences({ gridCardSecondaryLabel: value })).toEqual({ gridCardSecondaryLabel: value }) + } + }) + + it('drops invalid gridCardPrimaryLabel values', () => { + expect(sanitizeDisplayPreferences({ gridCardPrimaryLabel: 'unknown-field' })).toEqual({}) + expect(sanitizeDisplayPreferences({ gridCardPrimaryLabel: 42 })).toEqual({}) + }) + + it('drops invalid gridCardSecondaryLabel values', () => { + expect(sanitizeDisplayPreferences({ gridCardSecondaryLabel: 'bad-value' })).toEqual({}) + }) + + it('applies gridCardPrimaryLabel and gridCardSecondaryLabel from preferences', () => { + applyDisplayPreferences({ gridCardPrimaryLabel: 'series-title', gridCardSecondaryLabel: 'author' }) + expect(settings.gridCardPrimaryLabel.value).toBe('series-title') + expect(settings.gridCardSecondaryLabel.value).toBe('author') + }) }) diff --git a/client/src/composables/__tests__/useDisplaySettingsSync.spec.ts b/client/src/composables/__tests__/useDisplaySettingsSync.spec.ts index 12c1b9f51..944034f60 100644 --- a/client/src/composables/__tests__/useDisplaySettingsSync.spec.ts +++ b/client/src/composables/__tests__/useDisplaySettingsSync.spec.ts @@ -20,7 +20,10 @@ function validDisplayPreferences(overrides: Partial = {}): D bookSpineOverlay: 'subtle', bookShadowStrength: 'strong', bookCoverDisplayMode: 'blurred-fit', + cardInfoMode: 'hover-overlay', seriesCardCoverMode: 'mosaic', + gridCardPrimaryLabel: 'hidden', + gridCardSecondaryLabel: 'hidden', ...overrides, } } @@ -252,4 +255,56 @@ describe('useDisplaySettingsSync', () => { expect(displaySettings.useDisplaySettings().seriesCardCoverMode.value).toBe('latest-volume') }) + + it('syncs gridCardPrimaryLabel changes to the server', async () => { + vi.useFakeTimers() + const { apiMock, displaySettings, sync } = await loadModules() + apiMock.mockResolvedValue({ ok: true }) + + sync.initDisplaySettingsSync() + displaySettings.useDisplaySettings().gridCardPrimaryLabel.value = 'book-title' + await vi.advanceTimersByTimeAsync(1500) + + expect(apiMock).toHaveBeenCalledWith( + '/api/v1/user-preferences/display', + expect.objectContaining({ + method: 'PUT', + body: expect.stringContaining('"gridCardPrimaryLabel":"book-title"'), + }), + ) + }) + + it('syncs gridCardSecondaryLabel changes to the server', async () => { + vi.useFakeTimers() + const { apiMock, displaySettings, sync } = await loadModules() + apiMock.mockResolvedValue({ ok: true }) + + sync.initDisplaySettingsSync() + displaySettings.useDisplaySettings().gridCardSecondaryLabel.value = 'author' + await vi.advanceTimersByTimeAsync(1500) + + expect(apiMock).toHaveBeenCalledWith( + '/api/v1/user-preferences/display', + expect.objectContaining({ + method: 'PUT', + body: expect.stringContaining('"gridCardSecondaryLabel":"author"'), + }), + ) + }) + + it('loads gridCardPrimaryLabel and gridCardSecondaryLabel from the server', async () => { + const { apiMock, displaySettings, sync } = await loadModules() + apiMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + settings: validDisplayPreferences({ gridCardPrimaryLabel: 'series-title', gridCardSecondaryLabel: 'author' }), + }), + }) + + await sync.loadDisplaySettingsFromServer() + + const s = displaySettings.useDisplaySettings() + expect(s.gridCardPrimaryLabel.value).toBe('series-title') + expect(s.gridCardSecondaryLabel.value).toBe('author') + }) }) diff --git a/client/src/composables/useDisplaySettings.ts b/client/src/composables/useDisplaySettings.ts index d3bd5377b..1208ee6d8 100644 --- a/client/src/composables/useDisplaySettings.ts +++ b/client/src/composables/useDisplaySettings.ts @@ -5,8 +5,10 @@ import { BOOK_SHADOW_STRENGTHS, BOOK_SPINE_OVERLAYS, BOOK_VIEW_MODES, + CARD_INFO_MODES, CARD_OVERLAY_KEYS, COVER_SIZE_SCOPES, + GRID_CARD_LABEL_FIELDS, SERIES_CARD_COVER_MODES, TABLE_DENSITIES, type AuthorCoverShape, @@ -14,9 +16,11 @@ import { type BookShadowStrength, type BookSpineOverlay, type BookViewMode, + type CardInfoMode, type CardOverlayKey, type CoverSizeScope, type DisplayPreferences, + type GridCardLabelField, type SeriesCardCoverMode, type TableDensity, } from '@bookorbit/types' @@ -28,9 +32,11 @@ export type { BookShadowStrength, BookSpineOverlay, BookViewMode, + CardInfoMode, CardOverlayKey, CoverSizeScope, DisplayPreferences, + GridCardLabelField, SeriesCardCoverMode, TableDensity, } from '@bookorbit/types' @@ -68,6 +74,14 @@ function normalizeSeriesCardCoverMode(value: unknown): SeriesCardCoverMode { : DEFAULT_SERIES_CARD_COVER_MODE } +function normalizeCardInfoMode(value: unknown): CardInfoMode { + return typeof value === 'string' && CARD_INFO_MODES.includes(value as CardInfoMode) ? (value as CardInfoMode) : 'hover-overlay' +} + +function normalizeGridCardLabelField(value: unknown): GridCardLabelField { + return typeof value === 'string' && GRID_CARD_LABEL_FIELDS.includes(value as GridCardLabelField) ? (value as GridCardLabelField) : 'hidden' +} + function normalizeCoverSizeScope(value: unknown): CoverSizeScope { return typeof value === 'string' && COVER_SIZE_SCOPES.includes(value as CoverSizeScope) ? (value as CoverSizeScope) : 'per-view' } @@ -127,6 +141,9 @@ const bookCoverDisplayMode = ref( normalizeBookCoverDisplayMode(storage.get('bookCoverDisplayMode', DEFAULT_BOOK_COVER_DISPLAY_MODE)), ) const seriesCardCoverMode = ref(normalizeSeriesCardCoverMode(storage.get('seriesCardCoverMode', DEFAULT_SERIES_CARD_COVER_MODE))) +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'))) watch(portraitCoverSize, (v) => storage.set('portraitCoverSize', v)) watch(squareCoverSize, (v) => storage.set('squareCoverSize', v)) @@ -145,6 +162,9 @@ watch(bookSpineOverlay, (value) => storage.set('bookSpineOverlay', normalizeBook watch(bookShadowStrength, (value) => storage.set('bookShadowStrength', normalizeBookShadowStrength(value))) watch(bookCoverDisplayMode, (value) => storage.set('bookCoverDisplayMode', normalizeBookCoverDisplayMode(value))) watch(seriesCardCoverMode, (value) => storage.set('seriesCardCoverMode', normalizeSeriesCardCoverMode(value))) +watch(gridCardPrimaryLabel, (value) => storage.set('gridCardPrimaryLabel', normalizeGridCardLabelField(value))) +watch(gridCardSecondaryLabel, (value) => storage.set('gridCardSecondaryLabel', normalizeGridCardLabelField(value))) +watch(cardInfoMode, (value) => storage.set('cardInfoMode', normalizeCardInfoMode(value))) export function getDisplayPreferencesSnapshot(): DisplayPreferences { return { @@ -165,6 +185,9 @@ export function getDisplayPreferencesSnapshot(): DisplayPreferences { bookShadowStrength: normalizeBookShadowStrength(bookShadowStrength.value), bookCoverDisplayMode: normalizeBookCoverDisplayMode(bookCoverDisplayMode.value), seriesCardCoverMode: normalizeSeriesCardCoverMode(seriesCardCoverMode.value), + gridCardPrimaryLabel: normalizeGridCardLabelField(gridCardPrimaryLabel.value), + gridCardSecondaryLabel: normalizeGridCardLabelField(gridCardSecondaryLabel.value), + cardInfoMode: normalizeCardInfoMode(cardInfoMode.value), } } @@ -197,6 +220,15 @@ 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 ab77547ae..69b2761d1 100644 --- a/client/src/features/book/components/BookCoverCard.test.ts +++ b/client/src/features/book/components/BookCoverCard.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { mount } from '@vue/test-utils' +import { flushPromises, mount } from '@vue/test-utils' import { defineComponent, h, ref } from 'vue' import type { BookCard } from '@bookorbit/types' import BookCoverCard from './BookCoverCard.vue' @@ -8,6 +8,7 @@ import BookCoverCard from './BookCoverCard.vue' const mockRouterPush = vi.fn<() => void>() vi.mock('vue-router', () => ({ + useRoute: () => ({ fullPath: '/' }), useRouter: () => ({ push: mockRouterPush }), })) @@ -37,14 +38,34 @@ vi.mock('@/features/auth/composables/usePermissions', () => ({ usePermissions: () => ({ hasPermission: mockHasPermission }), })) +const mockFetchAuthors = vi.hoisted(() => + vi.fn< + (params: { q?: string; page: number; size: number; sort: string; order: string }) => Promise<{ + items: Array<{ id: number; name: string; sortName: string | null; bookCount: number; lastAddedAt: string | null }> + total: number + page: number + size: number + }> + >(), +) +vi.mock('@/features/author/api/author', () => ({ + fetchAuthors: mockFetchAuthors, +})) + const mockCardOverlays = ref([]) const mockBookCoverDisplayMode = ref('blurred-fit') +const mockGridCardPrimaryLabel = ref('hidden') +const mockGridCardSecondaryLabel = ref('hidden') +const mockCardInfoMode = ref('hover-overlay') vi.mock('@/composables/useDisplaySettings', () => ({ useDisplaySettings: () => ({ cardOverlays: mockCardOverlays, bookSpineOverlay: ref('off'), bookShadowStrength: ref('default'), bookCoverDisplayMode: mockBookCoverDisplayMode, + gridCardPrimaryLabel: mockGridCardPrimaryLabel, + gridCardSecondaryLabel: mockGridCardSecondaryLabel, + cardInfoMode: mockCardInfoMode, }), })) @@ -137,7 +158,9 @@ function makeBook(overrides: Partial = {}): BookCard { } } -function mountCard(props: Partial<{ book: BookCard; selectionMode: boolean; selected: boolean; onSelect: (e: MouseEvent) => void }> = {}) { +function mountCard( + props: Partial<{ book: BookCard; selectionMode: boolean; selected: boolean; showLabel: boolean; onSelect: (e: MouseEvent) => void }> = {}, +) { return mount(BookCoverCard, { props: { book: makeBook(), selectionMode: false, selected: false, ...props }, global: { @@ -188,6 +211,15 @@ describe('BookCoverCard', () => { mockRefreshing.value = false mockCardOverlays.value = [] mockBookCoverDisplayMode.value = 'blurred-fit' + mockGridCardPrimaryLabel.value = 'hidden' + mockGridCardSecondaryLabel.value = 'hidden' + mockCardInfoMode.value = 'hover-overlay' + mockFetchAuthors.mockResolvedValue({ + items: [{ id: 7, name: 'Frank Herbert', sortName: null, bookCount: 1, lastAddedAt: '2024-01-01T00:00:00Z' }], + total: 1, + page: 0, + size: 5, + }) setTouchMode(false) }) @@ -314,6 +346,46 @@ describe('BookCoverCard', () => { }) }) + describe('below-cover label click behavior', () => { + it('opens book details when the primary label text is clicked', async () => { + mockCardInfoMode.value = 'below-cover' + mockGridCardPrimaryLabel.value = 'book-title' + const book = makeBook({ id: 42, files: [makeFile({ id: 10, format: 'epub', role: 'primary' })] }) + const wrapper = mountCard({ book, showLabel: true }) + + await wrapper.get('[data-testid="grid-card-label-primary"]').trigger('click') + + expect(mockRouterPush).toHaveBeenCalledWith({ name: 'book-detail', params: { bookId: 42 } }) + expect(mockRouterPush).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'reader' })) + }) + + it('opens author details when the author label text is clicked', async () => { + mockCardInfoMode.value = 'below-cover' + mockGridCardSecondaryLabel.value = 'author' + const book = makeBook({ id: 43, files: [makeFile({ id: 11, format: 'epub', role: 'primary' })] }) + const wrapper = mountCard({ book, showLabel: true }) + + await wrapper.get('[data-testid="grid-card-label-secondary"]').trigger('click') + await flushPromises() + + expect(mockFetchAuthors).toHaveBeenCalledWith({ q: 'Frank Herbert', page: 0, size: 5, sort: 'name', order: 'asc' }) + expect(mockRouterPush).toHaveBeenCalledWith({ name: 'author-detail', params: { id: 7 }, query: { from: '/' } }) + expect(mockRouterPush).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'reader' })) + }) + + it('opens series details when the series label text is clicked', async () => { + mockCardInfoMode.value = 'below-cover' + mockGridCardPrimaryLabel.value = 'series-title-position' + const book = makeBook({ id: 44, seriesName: 'Dune Chronicles', seriesIndex: 1, files: [makeFile({ id: 12, format: 'epub', role: 'primary' })] }) + const wrapper = mountCard({ book, showLabel: true }) + + await wrapper.get('[data-testid="grid-card-label-primary"]').trigger('click') + + expect(mockRouterPush).toHaveBeenCalledWith({ name: 'series-detail', params: { seriesName: 'Dune Chronicles' }, query: { from: '/' } }) + expect(mockRouterPush).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'reader' })) + }) + }) + // ── cover rendering ─────────────────────────────────────────────────────── describe('cover rendering', () => { diff --git a/client/src/features/book/components/BookCoverCard.vue b/client/src/features/book/components/BookCoverCard.vue index 6403aedf7..39d30046f 100644 --- a/client/src/features/book/components/BookCoverCard.vue +++ b/client/src/features/book/components/BookCoverCard.vue @@ -3,7 +3,7 @@ import type { BookCard, BookFileRef } from '@bookorbit/types' import { FORMAT_TO_GROUP, READER_OPENABLE_FORMATS } from '@bookorbit/types' import { getFormatColor } from '../lib/format-colors' import { computed, inject, ref, watch, onMounted, onUnmounted } from 'vue' -import { useRouter } from 'vue-router' +import { useRoute, useRouter } from 'vue-router' import { BookOpen, Check, @@ -41,20 +41,23 @@ import { useRefreshMetadata } from '../composables/useRefreshMetadata' import { useRefreshingBooks } from '../composables/useRefreshingBooks' import { mergeBookCardWithDetail } from '../lib/book-card-mapper' import { usePermissions } from '@/features/auth/composables/usePermissions' -import { useDisplaySettings } from '@/composables/useDisplaySettings' +import { useDisplaySettings, type GridCardLabelField } from '@/composables/useDisplaySettings' import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO, coverAspectRatioValue, fittedCoverFrameStyle } from '../lib/cover-aspect-ratio' import { useBookDownload } from '@/features/book/composables/useBookDownload' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import SendBookDialog from '@/features/email/components/SendBookDialog.vue' import BookCoverArtwork from './BookCoverArtwork.vue' import BookCoverSurface from './BookCoverSurface.vue' +import { fetchAuthors } from '@/features/author/api/author' +const route = useRoute() const router = useRouter() const props = defineProps<{ book: BookCard selectionMode?: boolean selected?: boolean + showLabel?: boolean }>() type BookActionType = 'quick-view' | 'add-to-collection' | 'delete' @@ -110,7 +113,7 @@ async function handleRefreshMetadata() { if (updated) emit('update:book', mergeBookCardWithDetail(props.book, updated)) } const { hasPermission } = usePermissions() -const { cardOverlays, bookCoverDisplayMode } = useDisplaySettings() +const { cardOverlays, bookCoverDisplayMode, gridCardPrimaryLabel, gridCardSecondaryLabel, cardInfoMode } = useDisplaySettings() const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO)) const showSendDialog = ref(false) @@ -184,6 +187,64 @@ function openFile(file: BookFileRef) { }) } +function openBookDetails() { + void router.push({ name: 'book-detail', params: { bookId: props.book.id } }) +} + +function openSeriesDetails() { + const seriesName = props.book.seriesName?.trim() + if (!seriesName) return + void router.push({ name: 'series-detail', params: { seriesName }, query: { from: route.fullPath } }) +} + +async function openAuthorDetails() { + const authorName = authorQuery.value?.trim() + if (!authorName) return + + try { + const page = await fetchAuthors({ q: authorName, page: 0, size: 5, sort: 'name', order: 'asc' }) + const author = page.items.find((item) => item.name.trim().toLocaleLowerCase() === authorName.toLocaleLowerCase()) + if (author) { + void router.push({ name: 'author-detail', params: { id: author.id }, query: { from: route.fullPath } }) + return + } + } catch { + // Fall back to the filtered author list below. + } + + void router.push({ name: 'authors', query: { q: authorName } }) +} + +function openLabelTarget(field: GridCardLabelField) { + if (field === 'book-title') { + openBookDetails() + return + } + if (field === 'author') { + void openAuthorDetails() + return + } + if (field === 'series-title' || field === 'series-title-position') { + openSeriesDetails() + } +} + +function handleLabelClick(field: GridCardLabelField, event: MouseEvent) { + if (props.selectionMode) { + emit('select', event) + return + } + openLabelTarget(field) +} + +function handlePrimaryLabelClick(event: MouseEvent) { + handleLabelClick(gridCardPrimaryLabel.value, event) +} + +function handleSecondaryLabelClick(event: MouseEvent) { + handleLabelClick(gridCardSecondaryLabel.value, event) +} + function handleCardClick(event: MouseEvent) { const target = event.target if (target instanceof Element && target.closest('button, [data-card-click-blocker]')) return @@ -224,10 +285,16 @@ function openAuthorBrowse() { void router.push({ name: 'authors', query: { q: authorQuery.value } }) } -const showHoverText = computed(() => coverLoaded.value || !props.book.hasCover || coverFailed.value) -const showAuthorOnHover = computed(() => showHoverText.value && !!authorLine.value) +const showHoverTitle = computed( + () => (cardInfoMode.value === 'hover-overlay' || !props.showLabel) && (coverLoaded.value || !props.book.hasCover || coverFailed.value), +) +const showAuthorOnHover = computed(() => showHoverTitle.value && !!authorLine.value) const hoverTitleClampClass = computed(() => (coverAspectRatio.value === '1/1' ? 'line-clamp-1' : 'line-clamp-2')) +const overlayBackground = computed(() => (cardInfoMode.value === 'hover-overlay' || !props.showLabel ? 'bg-black/70' : 'bg-black/20')) +const overlayFadeClass = computed(() => (cardInfoMode.value === 'hover-overlay' || !props.showLabel ? 'group-hover:opacity-0' : '')) +const showBelowCoverLabelArea = computed(() => props.showLabel && cardInfoMode.value === 'below-cover') + const { downloadFile, exportBooks } = useBookDownload() function handleDownloadFile(file: BookFileRef) { @@ -260,292 +327,450 @@ async function handleSetStatus(status: ReadStatus) { localReadStatus.value = prev } } + +function resolveBookLabel(field: GridCardLabelField): string | null { + if (field === 'hidden') return null + if (field === 'book-title') return props.book.title?.trim() || null + if (field === 'series-title') return props.book.seriesName?.trim() || null + if (field === 'series-title-position') { + const name = props.book.seriesName?.trim() + if (!name) return null + return props.book.seriesIndex != null ? `${name} #${props.book.seriesIndex}` : name + } + if (field === 'author') return props.book.authors.length > 0 ? props.book.authors.join(', ') : null + return null +} + +const primaryLabelText = computed(() => resolveBookLabel(gridCardPrimaryLabel.value)) +const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabel.value))