feat(client): prevent audiobook cover cropping in series view

Closes #153
This commit is contained in:
neon
2026-06-01 18:29:52 +00:00
parent 2bdc88e2cc
commit 1baae51a32
11 changed files with 444 additions and 21 deletions
-1
View File
@@ -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)
<!--
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { BookCard, BookFileRef } from '@bookorbit/types'
import type { BookCard, BookFileRef, CoverAspectRatio } 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'
@@ -58,6 +58,7 @@ const props = defineProps<{
selectionMode?: boolean
selected?: boolean
showLabel?: boolean
coverAspectRatio?: CoverAspectRatio
}>()
type BookActionType = 'quick-view' | 'add-to-collection' | 'delete'
@@ -114,7 +115,8 @@ async function handleRefreshMetadata() {
}
const { hasPermission } = usePermissions()
const { cardOverlays, bookCoverDisplayMode, gridCardPrimaryLabel, gridCardSecondaryLabel, cardInfoMode } = useDisplaySettings()
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const injectedCoverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const effectiveCoverAspectRatio = computed<CoverAspectRatio>(() => props.coverAspectRatio ?? injectedCoverAspectRatio.value)
const showSendDialog = ref(false)
const hasProgress = computed(() => props.book.readingProgress != null && props.book.readingProgress > 0)
@@ -153,7 +155,7 @@ const coverImageRatio = ref<number | null>(null)
const isMissing = computed(() => props.book.status === 'missing')
const showMobileOverlay = ref(false)
const root = ref<HTMLElement | null>(null)
const coverSlotRatio = computed(() => coverAspectRatioValue(String(coverAspectRatio.value)))
const coverSlotRatio = computed(() => coverAspectRatioValue(String(effectiveCoverAspectRatio.value)))
const coverOverlayFrameStyle = computed(() => {
if (bookCoverDisplayMode.value !== 'natural-bottom' || !props.book.hasCover || !coverLoaded.value || coverFailed.value) return { inset: '0' }
return fittedCoverFrameStyle(coverImageRatio.value, coverSlotRatio.value, 'bottom')
@@ -289,7 +291,7 @@ 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 hoverTitleClampClass = computed(() => (effectiveCoverAspectRatio.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' : ''))
@@ -360,7 +362,7 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
:class="isMissing || selectionMode ? '' : 'group-hover:scale-[1.02]'"
:interactive="!isMissing && !selectionMode"
:disable-spine="isAudiobook"
:style="{ aspectRatio: coverAspectRatio }"
:style="{ aspectRatio: effectiveCoverAspectRatio }"
>
<!-- Missing border overlay: mirror selected overlay pattern so border is never clipped/hidden -->
<div v-if="isMissing" class="absolute inset-0 z-30 pointer-events-none rounded-sm ring-2 ring-inset ring-amber-500" />
@@ -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"
@@ -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:
'<button data-testid="book-card" @click="$emit(\'action\', \'quick-view\')">{{ book.id }}<span v-if="showLabel" data-testid="book-card-label-slot" /></button>',
'<div>' +
'<button data-testid="book-card" :data-cover-ratio="coverAspectRatio || \'\'" @click="$emit(\'action\', \'quick-view\')">' +
'{{ book.id }}<span v-if="showLabel" data-testid="book-card-label-slot" /></button>' +
'<button data-testid="book-card-select" @click="$emit(\'select\', $event)">select</button>' +
'<button data-testid="book-card-update" @click="$emit(\'update:book\', { ...book, title: \'Updated\' })">update</button>' +
'</div>',
},
}))
@@ -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'
@@ -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<number>
virtualized?: boolean
audioCoverScale?: number
}>(),
{
selectionMode: false,
isSelected: undefined,
newBookIds: () => new Set<number>(),
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
}
</script>
<template>
<div ref="containerRef" class="w-full">
<div v-if="!virtualized" class="grid w-full max-w-full items-start" :style="staticGridStyle" data-testid="book-grid-static">
<div
v-if="!virtualized && useVariableStaticWidths"
class="flex w-full flex-wrap content-start items-start"
:style="staticVariableWrapStyle"
data-testid="book-grid-static"
>
<div
v-for="book in books"
:key="book.id"
class="min-w-0 shrink-0"
:class="{ 'book-grid-cell--new': props.newBookIds.has(book.id) }"
:style="staticItemStyle(book)"
>
<CollapsedSeriesCard v-if="book.collapsedSeries" :book="book" :show-label="showLabel" />
<BookCoverCard
v-else
:book="book"
:show-label="showLabel"
:cover-aspect-ratio="staticCoverAspectRatio(book)"
:selection-mode="selectionMode"
:selected="isSelected?.(book.id) ?? false"
@action="emit('action', book, $event)"
@select="emit('select', book.id, $event)"
@update:book="emit('update:book', $event)"
/>
</div>
</div>
<div v-else-if="!virtualized" class="grid w-full max-w-full items-start" :style="staticGridStyle" data-testid="book-grid-static">
<div v-for="book in books" :key="book.id" class="min-w-0" :class="{ 'book-grid-cell--new': props.newBookIds.has(book.id) }">
<CollapsedSeriesCard v-if="book.collapsedSeries" :book="book" :show-label="showLabel" />
<BookCoverCard
@@ -49,9 +49,12 @@ const globalStubs = {
},
}
function mountCard(book: BookCard, coverAspectRatio: '2/3' | '1/1' = '2/3') {
function mountCard(book: BookCard, coverAspectRatio: '2/3' | '1/1' = '2/3', coverAspectRatioOverride?: '2/3' | '1/1') {
return mount(BookCoverCard, {
props: { book },
props: {
book,
...(coverAspectRatioOverride ? { coverAspectRatio: coverAspectRatioOverride } : {}),
},
global: {
...globalStubs,
provide: {
@@ -164,6 +167,15 @@ describe('BookCoverCard — missing state', () => {
})
})
describe('BookCoverCard — cover aspect override', () => {
it('uses explicit coverAspectRatio prop over injected ratio', () => {
const wrapper = mountCard(presentBookWithCover, '2/3', '1/1')
const coverDiv = wrapper.find('[style*="aspect-ratio"]')
const style = coverDiv.attributes('style') ?? ''
expect(style.includes('aspect-ratio: 1 / 1') || style.includes('aspect-ratio: 1/1')).toBe(true)
})
})
describe('BookCoverCard — present state', () => {
it('does not apply grayscale to the cover container', () => {
const wrapper = mountCard(presentBook)
@@ -0,0 +1,113 @@
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import { mount, type VueWrapper } from '@vue/test-utils'
import type { SeriesSummary } from '@bookorbit/types'
import SeriesCard from './SeriesCard.vue'
vi.mock('@/features/book/composables/useCoverVersions', () => ({
useCoverVersions: () => ({
coverUrl: (bookId: number) => `/covers/${bookId}`,
}),
}))
const BookCoverArtworkStub = defineComponent({
name: 'BookCoverArtwork',
emits: ['load', 'error'],
template: '<div data-testid="series-cover-artwork" />',
})
function makeSeries(overrides: Partial<SeriesSummary> = {}): SeriesSummary {
return {
name: 'Harry Potter Audio',
bookCount: 7,
readCount: 2,
authors: ['J.K. Rowling'],
coverBookIds: [101],
lastAddedAt: null,
...overrides,
}
}
function mountCard(series: SeriesSummary): VueWrapper {
return mount(SeriesCard, {
props: { series },
global: {
stubs: {
BookCoverArtwork: BookCoverArtworkStub,
SeriesCompletionBar: true,
},
},
})
}
function getCoverStyle(wrapper: VueWrapper): string {
const artwork = wrapper.get('[data-testid="series-cover-artwork"]')
return artwork.element.parentElement?.getAttribute('style') ?? ''
}
function getCoverStyleAt(wrapper: VueWrapper, index: number): string {
const artworks = wrapper.findAll('[data-testid="series-cover-artwork"]')
return artworks[index]!.element.parentElement?.getAttribute('style') ?? ''
}
describe('SeriesCard', () => {
it('emits open on card click and renders compact author text', async () => {
const wrapper = mountCard(makeSeries({ authors: ['A1', 'A2', 'A3'] }))
await wrapper.get('.group.flex.h-full.cursor-pointer.flex-col').trigger('click')
expect(wrapper.emitted('open')).toEqual([['Harry Potter Audio']])
expect(wrapper.text()).toContain('A1, A2 +1')
})
it('keeps non-square covers at base scale', async () => {
const wrapper = mountCard(makeSeries())
const artwork = wrapper.getComponent(BookCoverArtworkStub)
artwork.vm.$emit('load', 0.66)
await nextTick()
const style = getCoverStyle(wrapper)
expect(style).toContain('scale(1)')
expect(style).toContain('transform-origin: center')
})
it('enlarges square covers and anchors them to the bottom', async () => {
const wrapper = mountCard(makeSeries())
const artwork = wrapper.getComponent(BookCoverArtworkStub)
artwork.vm.$emit('load', 1)
await nextTick()
const style = getCoverStyle(wrapper)
expect(style).toContain('scale(1.25)')
expect(style).toContain('transform-origin: center bottom')
})
it('applies and clears hover transforms across the stack', async () => {
const wrapper = mountCard(makeSeries({ coverBookIds: [101, 102] }))
const wrappers = wrapper.findAll('.absolute.overflow-hidden.rounded-lg')
await wrappers[0]!.trigger('mouseenter')
await nextTick()
expect(getCoverStyleAt(wrapper, 0)).toContain('translateY(-8px) scale(1.05)')
expect(getCoverStyleAt(wrapper, 1)).toContain('opacity: 0.58')
await wrapper.get('.relative.isolate.overflow-hidden.border-b.border-border\\/60').trigger('mouseleave')
await nextTick()
expect(getCoverStyleAt(wrapper, 0)).toContain('translateY(0) scale(1)')
})
it('falls back to initial when the visible cover fails', async () => {
const wrapper = mountCard(makeSeries({ name: 'Resident Evil', coverBookIds: [101] }))
const artwork = wrapper.getComponent(BookCoverArtworkStub)
artwork.vm.$emit('error')
await nextTick()
expect(wrapper.find('[data-testid="series-cover-artwork"]').exists()).toBe(false)
expect(wrapper.text()).toContain('R')
})
})
@@ -4,10 +4,13 @@ import type { SeriesSummary } from '@bookorbit/types'
import { BookCopy } from 'lucide-vue-next'
import { bookCoverStyle } from '@/features/book/lib/book-cover'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import BookCoverArtwork from '@/features/book/components/BookCoverArtwork.vue'
import { resolveSquareCoverScale, shouldPersistCoverRatio } from '../lib/cover-scale'
import { useCoverStack } from '../composables/useCoverStack'
import SeriesCompletionBar from './SeriesCompletionBar.vue'
const HOVERED_COVER_Z_INDEX = 120
const SQUARE_COVER_SCALE = 1.25
const props = defineProps<{
series: SeriesSummary
@@ -22,6 +25,7 @@ const fallbackStyle = computed(() => bookCoverStyle(props.series.name))
const initial = computed(() => props.series.name.trim().charAt(0).toUpperCase() || '?')
const failedCovers = ref(new Set<number>())
const coverRatios = ref(new Map<number, number>())
const hoveredCoverId = ref<number | null>(null)
const activeCoverIds = computed(() => props.series.coverBookIds.filter((id) => !failedCovers.value.has(id)))
const { visibleCovers, baseStyles } = useCoverStack(activeCoverIds)
@@ -35,6 +39,17 @@ const authorsLine = computed(() => {
function handleCoverError(bookId: number) {
failedCovers.value = new Set([...failedCovers.value, bookId])
if (coverRatios.value.has(bookId)) {
const nextRatios = new Map(coverRatios.value)
nextRatios.delete(bookId)
coverRatios.value = nextRatios
}
}
function handleCoverLoad(bookId: number, ratio: number | null) {
const prev = coverRatios.value.get(bookId)
if (!shouldPersistCoverRatio(prev, ratio)) return
coverRatios.value = new Map(coverRatios.value).set(bookId, ratio)
}
function handleClick() {
@@ -58,6 +73,9 @@ const coverStyles = computed(() => {
const hasActiveHover = hoveredIndex >= 0
return baseStyles.value.map((base, index) => {
const bookId = visibleCovers.value[index]
const ratio = bookId == null ? null : (coverRatios.value.get(bookId) ?? null)
const squareScale = resolveSquareCoverScale(ratio, SQUARE_COVER_SCALE)
const isHovered = hasActiveHover && hoveredId === visibleCovers.value[index]
const distanceFromHovered = hasActiveHover ? Math.abs(index - hoveredIndex) : 0
const loweredScale = Math.max(0.95, 1 - distanceFromHovered * 0.03)
@@ -66,11 +84,12 @@ const coverStyles = computed(() => {
return {
...base,
zIndex: hasActiveHover ? (isHovered ? HOVERED_COVER_Z_INDEX : base.zIndex) : base.zIndex,
transformOrigin: squareScale > 1 ? 'center bottom' : 'center',
transform: hasActiveHover
? isHovered
? 'translateY(-8px) scale(1.05)'
: `translateY(${loweredOffset}px) scale(${loweredScale})`
: 'translateY(0) scale(1)',
? `translateY(-8px) scale(${1.05 * squareScale})`
: `translateY(${loweredOffset}px) scale(${loweredScale * squareScale})`
: `translateY(0) scale(${squareScale})`,
opacity: hasActiveHover ? (isHovered ? 1 : 0.58) : 1,
filter: hasActiveHover ? (isHovered ? 'brightness(1.08) saturate(1.14)' : 'brightness(0.78) saturate(0.74)') : 'none',
boxShadow: hasActiveHover
@@ -113,7 +132,19 @@ const coverStyles = computed(() => {
:style="coverStyles[i] ?? {}"
@mouseenter="handleCoverHover(bookId)"
>
<img :src="coverUrl(bookId)" alt="" class="h-full w-full object-cover" loading="lazy" decoding="async" @error="handleCoverError(bookId)" />
<BookCoverArtwork
:src="coverUrl(bookId)"
:has-cover="true"
:title="series.name"
:seed="`${series.name}-${bookId}`"
alt=""
frame-aspect-ratio="2/3"
loading="lazy"
decoding="async"
:spine="false"
@load="handleCoverLoad(bookId, $event)"
@error="handleCoverError(bookId)"
/>
</div>
<div
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { centeredBottomScaleTransform, resolveSquareCoverScale, shouldPersistCoverRatio } from './cover-scale'
describe('cover-scale utilities', () => {
it('persists a ratio only when it is valid and meaningfully changed', () => {
expect(shouldPersistCoverRatio(undefined, null)).toBe(false)
expect(shouldPersistCoverRatio(undefined, 0)).toBe(false)
expect(shouldPersistCoverRatio(undefined, 1)).toBe(true)
expect(shouldPersistCoverRatio(1, 1.00001)).toBe(false)
expect(shouldPersistCoverRatio(1, 1.01)).toBe(true)
})
it('detects square covers using tolerance and returns the enlarged scale', () => {
expect(resolveSquareCoverScale(null, 1.25)).toBe(1)
expect(resolveSquareCoverScale(0.66, 1.25)).toBe(1)
expect(resolveSquareCoverScale(0.93, 1.25)).toBe(1.25)
expect(resolveSquareCoverScale(1.07, 1.25)).toBe(1.25)
})
it('returns centered bottom transform only when scale exceeds 1', () => {
expect(centeredBottomScaleTransform(1)).toBeNull()
expect(centeredBottomScaleTransform(Number.NaN)).toBeNull()
expect(centeredBottomScaleTransform(1.25)).toEqual({
transformOrigin: 'center bottom',
transform: 'translateY(-12.5%) scale(1.25)',
})
})
})
@@ -0,0 +1,27 @@
export const SQUARE_RATIO_TARGET = 1
export const SQUARE_RATIO_TOLERANCE = 0.08
const COVER_RATIO_EPSILON = 0.0001
function isFinitePositive(value: number | null | undefined): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0
}
export function shouldPersistCoverRatio(previous: number | undefined, next: number | null): next is number {
if (!isFinitePositive(next)) return false
if (previous == null) return true
return Math.abs(previous - next) >= COVER_RATIO_EPSILON
}
export function resolveSquareCoverScale(ratio: number | null | undefined, enlargedScale: number): number {
if (!isFinitePositive(ratio)) return 1
return Math.abs(ratio - SQUARE_RATIO_TARGET) <= SQUARE_RATIO_TOLERANCE ? enlargedScale : 1
}
export function centeredBottomScaleTransform(scale: number): { transformOrigin: string; transform: string } | null {
if (!Number.isFinite(scale) || scale <= 1) return null
const centerShiftPercent = (scale - 1) * 50
return {
transformOrigin: 'center bottom',
transform: `translateY(-${centerShiftPercent}%) scale(${scale})`,
}
}
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, reactive, ref, type PropType } from 'vue'
import { mount } from '@vue/test-utils'
import { flushPromises, mount } from '@vue/test-utils'
import type { BookCard, SeriesDetail } from '@bookorbit/types'
import SeriesDetailView from './SeriesDetailView.vue'
@@ -59,6 +59,7 @@ vi.mock('@/composables/useDisplaySettings', () => ({
useDisplaySettings: () => ({
portraitCoverSize: ref(160),
gridGap: ref(16),
bookCoverDisplayMode: ref('blurred-fit'),
gridCardPrimaryLabel: ref('hidden'),
gridCardSecondaryLabel: ref('hidden'),
}),
@@ -179,6 +180,12 @@ const VirtualBookTableStub = defineComponent({
template: `<button data-testid="table-add-action" @click="$emit('action', { id: 91 }, 'add-to-collection')">add</button>`,
})
const BookCoverArtworkStub = defineComponent({
name: 'BookCoverArtwork',
emits: ['load', 'error'],
template: '<div data-testid="lead-cover-artwork" />',
})
const AddToCollectionSheetStub = defineComponent({
name: 'AddToCollectionSheet',
props: {
@@ -209,6 +216,7 @@ function mountView(mode: ViewMode) {
BookListRow: BookListRowStub,
VirtualBookTable: VirtualBookTableStub,
AddToCollectionSheet: AddToCollectionSheetStub,
BookCoverArtwork: BookCoverArtworkStub,
EntityNotFound: true,
SeriesCompletionBar: true,
SeriesGapBanner: true,
@@ -307,4 +315,33 @@ describe('SeriesDetailView', () => {
expect(sheet.attributes('data-open')).toBe('false')
expect(sheet.attributes('data-book-ids')).toBe('')
})
it('scales and centers square lead covers in the series header stack', async () => {
const lead = makeBook({ id: 7, seriesIndex: 1, hasCover: true })
mocks.fetchSeriesBooks.mockResolvedValueOnce({
items: [lead],
total: 1,
page: 0,
size: 8,
seriesInfo: makeSeriesInfo(),
})
const wrapper = mountView('grid')
await flushPromises()
await nextTick()
const artwork = wrapper.getComponent(BookCoverArtworkStub)
artwork.vm.$emit('load', 1)
await nextTick()
const style = wrapper.get('[data-testid="lead-cover-artwork"]').element.parentElement?.getAttribute('style') ?? ''
expect(style).toContain('scale(1.25)')
expect(style).toContain('transform-origin: center bottom')
expect(style).toContain('translateY(-12.5%)')
artwork.vm.$emit('load', 1)
await nextTick()
const secondStyle = wrapper.get('[data-testid="lead-cover-artwork"]').element.parentElement?.getAttribute('style') ?? ''
expect(secondStyle).toBe(style)
})
})
@@ -8,6 +8,7 @@ import type { BookCard, BookDetail, SortSpec } from '@bookorbit/types'
import VirtualBookGrid from '@/features/book/components/VirtualBookGrid.vue'
import BookListRow from '@/features/book/components/BookListRow.vue'
import VirtualBookTable from '@/features/book/components/VirtualBookTable.vue'
import BookCoverArtwork from '@/features/book/components/BookCoverArtwork.vue'
import { bookCoverStyle } from '@/features/book/lib/book-cover'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
@@ -25,6 +26,7 @@ import SeriesGapBanner from '../components/SeriesGapBanner.vue'
import { fetchSeriesBooks } from '../api/series'
import { useSeriesDetail } from '../composables/useSeriesDetail'
import { useCoverStack, MAX_VISIBLE as MAX_STACK_VISIBLE } from '../composables/useCoverStack'
import { centeredBottomScaleTransform, resolveSquareCoverScale, shouldPersistCoverRatio } from '../lib/cover-scale'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import type { ColumnId } from '@/features/book/composables/useTableColumns'
@@ -71,6 +73,7 @@ const leadDescriptionExpanded = ref(false)
const leadGenresExpanded = ref(false)
const leadCoverBookIds = ref<number[]>([])
const failedLeadCovers = ref(new Set<number>())
const leadCoverRatios = ref(new Map<number, number>())
const leadBook = ref<{
id: number
title: string | null
@@ -106,7 +109,8 @@ const hiddenLeadGenres = computed(() => {
const totalGenres = leadBook.value?.genres.length ?? 0
return Math.max(0, totalGenres - displayedLeadGenres.value.length)
})
const seriesBooksCoverSize = computed(() => Math.max(100, portraitCoverSize.value - 20))
const SERIES_AUDIOBOOK_COVER_SCALE = 1.25
const seriesBooksCoverSize = computed(() => Math.max(125, portraitCoverSize.value - 20))
const leadMetaItems = computed(() => {
if (!leadBook.value) return []
const items: string[] = []
@@ -169,13 +173,39 @@ function onLibraryFilterChange(event: Event) {
function handleLeadCoverError(bookId: number) {
failedLeadCovers.value = new Set([...failedLeadCovers.value, bookId])
if (leadCoverRatios.value.has(bookId)) {
const nextRatios = new Map(leadCoverRatios.value)
nextRatios.delete(bookId)
leadCoverRatios.value = nextRatios
}
}
function handleLeadCoverLoad(bookId: number, ratio: number | null) {
const prev = leadCoverRatios.value.get(bookId)
if (!shouldPersistCoverRatio(prev, ratio)) return
leadCoverRatios.value = new Map(leadCoverRatios.value).set(bookId, ratio)
}
const scaledLeadCoverStyles = computed(() =>
leadCoverStyles.value.map((base, index) => {
const bookId = visibleLeadCoverBookIds.value[index]
const ratio = bookId == null ? null : (leadCoverRatios.value.get(bookId) ?? null)
const squareScale = resolveSquareCoverScale(ratio, SERIES_AUDIOBOOK_COVER_SCALE)
const squareTransform = centeredBottomScaleTransform(squareScale)
if (!squareTransform) return base
return {
...base,
...squareTransform,
}
}),
)
async function loadLeadBookPreview(preserveCurrent = false) {
if (!seriesName.value) {
leadBook.value = null
leadBookError.value = null
leadCoverBookIds.value = []
leadCoverRatios.value = new Map<number, number>()
return
}
@@ -186,6 +216,7 @@ async function loadLeadBookPreview(preserveCurrent = false) {
leadBook.value = null
leadCoverBookIds.value = []
failedLeadCovers.value = new Set<number>()
leadCoverRatios.value = new Map<number, number>()
}
leadDescriptionExpanded.value = false
leadGenresExpanded.value = false
@@ -384,14 +415,19 @@ watch(
v-for="(bookId, i) in visibleLeadCoverBookIds"
:key="bookId"
class="absolute overflow-hidden rounded-lg"
:style="leadCoverStyles[i] ?? {}"
:style="scaledLeadCoverStyles[i] ?? {}"
>
<img
<BookCoverArtwork
:src="coverUrl(bookId)"
:has-cover="true"
:title="seriesInfo.name"
:seed="`${seriesInfo.name}-${bookId}`"
alt=""
class="h-full w-full object-cover"
frame-aspect-ratio="2/3"
loading="lazy"
decoding="async"
:spine="false"
@load="handleLeadCoverLoad(bookId, $event)"
@error="handleLeadCoverError(bookId)"
/>
</div>
@@ -577,6 +613,7 @@ watch(
:books="books"
:cover-size="seriesBooksCoverSize"
:grid-gap="gridGap"
:audio-cover-scale="SERIES_AUDIOBOOK_COVER_SCALE"
:virtualized="false"
@action="handleBookAction"
@update:book="handleTableBookUpdate"