diff --git a/README.md b/README.md index fcf40cfd3..81f387033 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,6 @@ A self-hosted library management and reading platform for ebooks, PDFs, audioboo [![Release](https://github.com/bookorbit/bookorbit/actions/workflows/release.yml/badge.svg)](https://github.com/bookorbit/bookorbit/actions/workflows/release.yml) [![Server Coverage](https://codecov.io/gh/bookorbit/bookorbit/graph/badge.svg?token=F6TADEFCUV&flag=server)](https://codecov.io/gh/bookorbit/bookorbit) [![Latest release](https://img.shields.io/github/v/release/bookorbit/bookorbit?label=latest)](https://github.com/bookorbit/bookorbit/releases) -[![Commits/month](https://img.shields.io/github/commit-activity/m/bookorbit/bookorbit?label=commits%2Fmonth)](https://github.com/bookorbit/bookorbit/commits/main) [![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](LICENSE)
@@ -373,6 +375,7 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe :is-audio="isAudiobook" :seed="book.title ?? String(book.id)" :alt="book.title ?? ''" + :frame-aspect-ratio="effectiveCoverAspectRatio" :image-class="isMissing ? 'brightness-50' : ''" :spine="!isAudiobook" @load="handleArtworkLoad" diff --git a/client/src/features/book/components/VirtualBookGrid.spec.ts b/client/src/features/book/components/VirtualBookGrid.spec.ts index 233ecf47e..52e9679e1 100644 --- a/client/src/features/book/components/VirtualBookGrid.spec.ts +++ b/client/src/features/book/components/VirtualBookGrid.spec.ts @@ -15,10 +15,15 @@ vi.mock('vue-virtual-scroller', () => ({ vi.mock('./BookCoverCard.vue', () => ({ default: { name: 'BookCoverCard', - props: ['book', 'selectionMode', 'selected', 'showLabel'], + props: ['book', 'selectionMode', 'selected', 'showLabel', 'coverAspectRatio'], emits: ['action', 'select', 'update:book'], template: - '', + '
' + + '' + + '' + + '' + + '
', }, })) @@ -116,6 +121,79 @@ describe('VirtualBookGrid', () => { expect(wrapper.emitted('action')).toEqual([[books[0], 'quick-view']]) }) + it('forwards select and update events in direct render mode', async () => { + const books = [makeBook(1)] + const wrapper = mount(VirtualBookGrid, { + props: { + books, + coverSize: 120, + gridGap: 12, + virtualized: false, + }, + }) + + await wrapper.get('[data-testid="book-card-select"]').trigger('click') + await wrapper.get('[data-testid="book-card-update"]').trigger('click') + + expect(wrapper.emitted('select')?.[0]?.[0]).toBe(1) + const updateEvent = wrapper.emitted('update:book') + expect(updateEvent).toBeDefined() + const updatedBook = updateEvent?.[0]?.[0] as BookCard | undefined + expect(updatedBook).toBeDefined() + expect(updatedBook?.title).toBe('Updated') + }) + + it('forwards select and update events in virtualized mode', async () => { + const books = [makeBook(2)] + const wrapper = mount(VirtualBookGrid, { + props: { + books, + coverSize: 120, + gridGap: 12, + }, + }) + + await wrapper.get('[data-testid="book-card-select"]').trigger('click') + await wrapper.get('[data-testid="book-card-update"]').trigger('click') + + expect(wrapper.emitted('select')?.[0]?.[0]).toBe(2) + const updateEvent = wrapper.emitted('update:book') + expect(updateEvent).toBeDefined() + const updatedBook = updateEvent?.[0]?.[0] as BookCard | undefined + expect(updatedBook).toBeDefined() + expect(updatedBook?.title).toBe('Updated') + }) + + it('renders audiobook cards 1.25x wider in static mode when audioCoverScale is set', () => { + const books = [ + makeBook(1, { files: [{ id: 11, format: 'epub', role: 'primary', sizeBytes: null }] }), + makeBook(2, { files: [{ id: 22, format: 'MP3', role: 'primary', sizeBytes: null }] }), + ] + + const wrapper = mount(VirtualBookGrid, { + props: { + books, + coverSize: 120, + gridGap: 12, + virtualized: false, + audioCoverScale: 1.25, + }, + }) + + const itemWrappers = wrapper.findAll('.min-w-0.shrink-0') + expect(itemWrappers).toHaveLength(2) + expect(wrapper.get('[data-testid="book-grid-static"]').classes()).toContain('content-start') + const cards = wrapper.findAll('[data-testid="book-card"]') + expect(cards[0]!.attributes('data-cover-ratio')).toBe('2/3') + expect(cards[1]!.attributes('data-cover-ratio')).toBe('1/1') + + const ebookWrapperStyle = itemWrappers[0]!.attributes('style') + const audioWrapperStyle = itemWrappers[1]!.attributes('style') + + expect(ebookWrapperStyle).toContain('width: 120px;') + expect(audioWrapperStyle).toContain('width: 150px;') + }) + describe('show-label prop forwarding', () => { afterEach(() => { displaySettingsState.cardInfoMode.value = 'hover-overlay' diff --git a/client/src/features/book/components/VirtualBookGrid.vue b/client/src/features/book/components/VirtualBookGrid.vue index e70da2aef..46aae25f2 100644 --- a/client/src/features/book/components/VirtualBookGrid.vue +++ b/client/src/features/book/components/VirtualBookGrid.vue @@ -2,7 +2,7 @@ import { computed, inject, ref } from 'vue' import { useElementSize, useWindowSize } from '@vueuse/core' import { RecycleScroller } from 'vue-virtual-scroller' -import type { BookCard } from '@bookorbit/types' +import { isAudioFormat, type BookCard, type CoverAspectRatio } from '@bookorbit/types' import BookCoverCard from './BookCoverCard.vue' import CollapsedSeriesCard from './CollapsedSeriesCard.vue' import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio' @@ -19,12 +19,14 @@ const props = withDefaults( isSelected?: (bookId: number) => boolean newBookIds?: Set virtualized?: boolean + audioCoverScale?: number }>(), { selectionMode: false, isSelected: undefined, newBookIds: () => new Set(), virtualized: true, + audioCoverScale: 1, }, ) @@ -44,8 +46,15 @@ function asPositiveInt(value: unknown, fallback: number) { return Math.round(n) } +function normalizeScale(value: unknown): number { + const n = Number(value) + if (!Number.isFinite(n) || n <= 1) return 1 + return n +} + const coverPx = computed(() => asPositiveInt(props.coverSize, 140)) const gapPx = computed(() => asPositiveInt(props.gridGap, 20)) +const audioCoverScale = computed(() => normalizeScale(props.audioCoverScale)) const availableWidth = computed(() => { const observed = Number(containerWidth.value) @@ -98,11 +107,60 @@ const staticGridStyle = computed(() => ({ gap: `${gapPx.value}px`, gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${coverPx.value}px), 1fr))`, })) + +const staticVariableWrapStyle = computed(() => ({ + gap: `${gapPx.value}px`, +})) + +const useVariableStaticWidths = computed(() => !props.virtualized && audioCoverScale.value > 1) + +function isAudiobook(book: BookCard): boolean { + return book.files.some((file) => (file.format ? isAudioFormat(file.format) : false)) +} + +function staticItemStyle(book: BookCard): { width: string; maxWidth: string } { + const scale = isAudiobook(book) ? audioCoverScale.value : 1 + const width = Math.max(1, Math.round(coverPx.value * scale)) + return { width: `${width}px`, maxWidth: '100%' } +} + +function staticCoverAspectRatio(book: BookCard): CoverAspectRatio { + if (isAudiobook(book)) return '1/1' + return coverAspectRatio.value +}