fix(books): fit series card covers to the grid instead of cropping them

First volume, Latest volume and First unread series cards drew their cover
with a cropping img, so a square audiobook cover in a 2:3 grid lost a third
of its width. They now render through BookCoverArtwork like single tiles and
follow the cover display setting, while the grid keeps one tile shape.

Fixes #1863
This commit is contained in:
neon
2026-10-20 03:45:08 +00:00
parent e0ce6d3348
commit 5878c96254
2 changed files with 133 additions and 67 deletions
@@ -6,7 +6,7 @@ import { Library } from '@lucide/vue'
import BookCoverArtwork from './BookCoverArtwork.vue' import BookCoverArtwork from './BookCoverArtwork.vue'
import BookCoverPlaceholder from './BookCoverPlaceholder.vue' import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
import BookCoverSurface from './BookCoverSurface.vue' import BookCoverSurface from './BookCoverSurface.vue'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio' import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO, coverAspectRatioValue, fittedCoverFrameStyle } from '../lib/cover-aspect-ratio'
import { useCoverVersions } from '../composables/useCoverVersions' import { useCoverVersions } from '../composables/useCoverVersions'
import { useDisplaySettings, type GridCardLabelField } from '@/composables/useDisplaySettings' import { useDisplaySettings, type GridCardLabelField } from '@/composables/useDisplaySettings'
import { fetchAuthors } from '@/features/author/api/author' import { fetchAuthors } from '@/features/author/api/author'
@@ -27,7 +27,7 @@ const router = useRouter()
const injectedCoverAspectRatio = 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(() => props.coverAspectRatio ?? props.book.coverAspectRatio ?? injectedCoverAspectRatio.value) const effectiveCoverAspectRatio = computed(() => props.coverAspectRatio ?? props.book.coverAspectRatio ?? injectedCoverAspectRatio.value)
const { coverUrl } = useCoverVersions() const { coverUrl } = useCoverVersions()
const { seriesCardCoverMode, gridCardPrimaryLabel, gridCardSecondaryLabel, cardInfoMode, cardOverlays } = useDisplaySettings() const { seriesCardCoverMode, bookCoverDisplayMode, gridCardPrimaryLabel, gridCardSecondaryLabel, cardInfoMode, cardOverlays } = useDisplaySettings()
const collapsed = computed(() => props.book.collapsedSeries!) const collapsed = computed(() => props.book.collapsedSeries!)
const seriesName = computed(() => props.book.seriesName ?? '') const seriesName = computed(() => props.book.seriesName ?? '')
@@ -69,12 +69,17 @@ const resolvedCoverId = computed<number | null>(() => {
} }
}) })
const singleCoverFailed = ref(false) const showSingleCover = computed(() => !isStack.value && !isMosaic.value && resolvedCoverId.value != null)
const singleCoverLoaded = ref(false) const singleCoverRatio = ref<number | null>(null)
const coverSlotRatio = computed(() => coverAspectRatioValue(String(effectiveCoverAspectRatio.value)))
// In natural-bottom the single cover shrinks to its own shape, so the badges and hover overlay follow it.
const coverOverlayFrameStyle = computed(() => {
if (bookCoverDisplayMode.value !== 'natural-bottom' || !showSingleCover.value || singleCoverRatio.value === null) return { inset: '0' }
return fittedCoverFrameStyle(singleCoverRatio.value, coverSlotRatio.value, 'bottom')
})
watch(resolvedCoverId, () => { watch(resolvedCoverId, () => {
singleCoverFailed.value = false singleCoverRatio.value = null
singleCoverLoaded.value = false
}) })
watch( watch(
allCoverIds, allCoverIds,
@@ -98,14 +103,12 @@ function handleCoverError(bookId: number) {
failedCovers.value = new Set([...failedCovers.value, bookId]) failedCovers.value = new Set([...failedCovers.value, bookId])
} }
function handleSingleCoverLoad() { function handleSingleCoverLoad(ratio: number | null) {
singleCoverLoaded.value = true singleCoverRatio.value = ratio
singleCoverFailed.value = false
} }
function handleSingleCoverError() { function handleSingleCoverError() {
singleCoverLoaded.value = false singleCoverRatio.value = null
singleCoverFailed.value = true
} }
function coverVersionFor(bookId: number): string | null | undefined { function coverVersionFor(bookId: number): string | null | undefined {
@@ -209,7 +212,7 @@ const secondaryLabelText = computed(() => resolveSeriesLabel(gridCardSecondaryLa
<div class="group"> <div class="group">
<BookCoverSurface <BookCoverSurface
class="relative w-full rounded-sm overflow-hidden transition-[box-shadow,transform] duration-150 will-change-transform group-hover:[transform:var(--cover-hover-transform)] motion-reduce:group-hover:[transform:none]" class="relative w-full rounded-sm overflow-hidden transition-[box-shadow,transform] duration-150 will-change-transform group-hover:[transform:var(--cover-hover-transform)] motion-reduce:group-hover:[transform:none]"
:class="isStack ? 'book-cover-surface--spine-fitted' : ''" :class="isStack || showSingleCover ? 'book-cover-surface--spine-fitted' : ''"
interactive interactive
:disable-spine="isAudiobook" :disable-spine="isAudiobook"
:is-comic="isComic" :is-comic="isComic"
@@ -230,6 +233,7 @@ const secondaryLabelText = computed(() => resolveSeriesLabel(gridCardSecondaryLa
:title="seriesName" :title="seriesName"
:author-line="authorLine" :author-line="authorLine"
:seed="`series-${bookId}`" :seed="`series-${bookId}`"
:frame-aspect-ratio="effectiveCoverAspectRatio"
alt="" alt=""
loading="lazy" loading="lazy"
decoding="async" decoding="async"
@@ -258,24 +262,21 @@ const secondaryLabelText = computed(() => resolveSeriesLabel(gridCardSecondaryLa
<!-- Single cover mode --> <!-- Single cover mode -->
<div v-else-if="!isMosaic && resolvedCoverId != null" class="absolute inset-0" data-testid="series-single-cover"> <div v-else-if="!isMosaic && resolvedCoverId != null" class="absolute inset-0" data-testid="series-single-cover">
<div <BookCoverArtwork
class="absolute inset-0 transition-opacity duration-200 ease-out"
:class="singleCoverLoaded && !singleCoverFailed ? 'opacity-0 pointer-events-none' : 'opacity-100'"
aria-hidden="true"
>
<BookCoverPlaceholder title="" author-line="" :is-audio="false" :seed="`series-${resolvedCoverId}`" />
</div>
<img
v-if="!singleCoverFailed"
:src="coverUrl(resolvedCoverId, 'thumbnail', coverVersionFor(resolvedCoverId))" :src="coverUrl(resolvedCoverId, 'thumbnail', coverVersionFor(resolvedCoverId))"
class="absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-out" :has-cover="true"
:class="singleCoverLoaded ? 'opacity-100' : 'opacity-0'" :title="seriesName"
loading="lazy" :author-line="authorLine"
:seed="`series-${resolvedCoverId}`"
:frame-aspect-ratio="effectiveCoverAspectRatio"
alt="" alt=""
loading="lazy"
decoding="async"
:spine="!isAudiobook"
:is-comic="isComic"
@load="handleSingleCoverLoad" @load="handleSingleCoverLoad"
@error="handleSingleCoverError" @error="handleSingleCoverError"
/> />
<span v-if="!singleCoverLoaded && !singleCoverFailed" class="absolute inset-0 z-[1] animate-pulse bg-foreground/5" />
</div> </div>
<!-- Adaptive cover mosaic --> <!-- Adaptive cover mosaic -->
@@ -307,52 +308,56 @@ const secondaryLabelText = computed(() => resolveSeriesLabel(gridCardSecondaryLa
</div> </div>
</div> </div>
<!-- Count badge -->
<div <div
v-if="!isStack" v-if="!isStack"
class="absolute right-1.5 top-1.5 z-10 rounded-sm bg-black/70 px-1.5 py-0.5 text-xs font-semibold tabular-nums text-white group-hover:opacity-0 transition-opacity duration-150" data-testid="series-overlay-frame"
data-testid="series-count-badge" class="absolute z-10 overflow-hidden rounded-[inherit]"
:style="coverOverlayFrameStyle"
> >
{{ collapsed.bookCount }} <!-- Count badge -->
</div> <div
class="absolute right-1.5 top-1.5 z-10 rounded-sm bg-black/70 px-1.5 py-0.5 text-xs font-semibold tabular-nums text-white group-hover:opacity-0 transition-opacity duration-150"
<!-- Series type badge --> data-testid="series-count-badge"
<div >
v-if="!isStack" {{ collapsed.bookCount }}
class="absolute bottom-1.5 left-1.5 z-10 rounded-sm bg-black/70 p-1 text-white group-hover:opacity-0 transition-opacity duration-150"
data-testid="series-type-badge"
>
<Library :size="10" />
</div>
<!-- Hover overlay -->
<div
v-if="!isStack"
class="absolute inset-0 flex flex-col p-2 opacity-0 group-hover:opacity-100 group-active:opacity-100 transition-opacity duration-150 pointer-events-none group-hover:pointer-events-auto group-active:pointer-events-auto"
:class="overlayBackground"
>
<div class="flex-1 flex items-center justify-center">
<button
class="size-[30cqi] flex items-center justify-center rounded-full bg-primary text-white shadow-2xl transition-all duration-300 scale-75 group-hover:scale-100"
data-testid="series-hover-action"
@click.stop="handleClick"
>
<Library class="size-[16cqi]" />
</button>
</div> </div>
<div v-if="showHoverText" class="shrink-0 flex flex-col">
<p class="text-xs font-semibold text-white leading-tight" :class="hoverTitleClampClass">
{{ seriesName }}
</p>
<p v-if="authorLine" class="text-[10px] text-white/70 truncate mt-0.5">
{{ authorLine }}
</p>
</div>
</div>
<!-- Read progress bar --> <!-- Series type badge -->
<div v-if="!isStack && showProgressBar" class="absolute bottom-0 left-0 right-0 h-[3px] z-20 bg-white/20" data-testid="series-progress-bar"> <div
<div class="h-full bg-primary" :style="{ width: `${progressPercent}%` }" data-testid="series-progress-fill" /> class="absolute bottom-1.5 left-1.5 z-10 rounded-sm bg-black/70 p-1 text-white group-hover:opacity-0 transition-opacity duration-150"
data-testid="series-type-badge"
>
<Library :size="10" />
</div>
<!-- Hover overlay -->
<div
class="absolute inset-0 flex flex-col p-2 opacity-0 group-hover:opacity-100 group-active:opacity-100 transition-opacity duration-150 pointer-events-none group-hover:pointer-events-auto group-active:pointer-events-auto"
:class="overlayBackground"
>
<div class="flex-1 flex items-center justify-center">
<button
class="size-[30cqi] flex items-center justify-center rounded-full bg-primary text-white shadow-2xl transition-all duration-300 scale-75 group-hover:scale-100"
data-testid="series-hover-action"
@click.stop="handleClick"
>
<Library class="size-[16cqi]" />
</button>
</div>
<div v-if="showHoverText" class="shrink-0 flex flex-col">
<p class="text-xs font-semibold text-white leading-tight" :class="hoverTitleClampClass">
{{ seriesName }}
</p>
<p v-if="authorLine" class="text-[10px] text-white/70 truncate mt-0.5">
{{ authorLine }}
</p>
</div>
</div>
<!-- Read progress bar -->
<div v-if="showProgressBar" class="absolute bottom-0 left-0 right-0 h-[3px] z-20 bg-white/20" data-testid="series-progress-bar">
<div class="h-full bg-primary" :style="{ width: `${progressPercent}%` }" data-testid="series-progress-fill" />
</div>
</div> </div>
</BookCoverSurface> </BookCoverSurface>
</div> </div>
@@ -90,7 +90,8 @@ function makeBook(overrides?: Partial<BookCard>): BookCard {
const expectedCoverUrl = (bookId: number, version = '2024-01-01T00:00:00.000Z') => const expectedCoverUrl = (bookId: number, version = '2024-01-01T00:00:00.000Z') =>
`/api/v1/books/${bookId}/thumbnail?t=${new Date(version).getTime()}` `/api/v1/books/${bookId}/thumbnail?t=${new Date(version).getTime()}`
const { bookSpineOverlay, seriesCardCoverMode, gridCardPrimaryLabel, gridCardSecondaryLabel, cardInfoMode, cardOverlays } = useDisplaySettings() const { bookSpineOverlay, bookCoverDisplayMode, seriesCardCoverMode, gridCardPrimaryLabel, gridCardSecondaryLabel, cardInfoMode, cardOverlays } =
useDisplaySettings()
describe('CollapsedSeriesCard', () => { describe('CollapsedSeriesCard', () => {
beforeEach(() => { beforeEach(() => {
@@ -101,6 +102,7 @@ describe('CollapsedSeriesCard', () => {
afterEach(() => { afterEach(() => {
bookSpineOverlay.value = 'off' bookSpineOverlay.value = 'off'
bookCoverDisplayMode.value = 'blurred-fit'
seriesCardCoverMode.value = 'stack' seriesCardCoverMode.value = 'stack'
gridCardPrimaryLabel.value = 'hidden' gridCardPrimaryLabel.value = 'hidden'
gridCardSecondaryLabel.value = 'hidden' gridCardSecondaryLabel.value = 'hidden'
@@ -379,9 +381,68 @@ describe('CollapsedSeriesCard', () => {
expect(wrapper.find('[data-testid="series-hover-action"]').exists()).toBe(false) expect(wrapper.find('[data-testid="series-hover-action"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="series-progress-bar"]').exists()).toBe(false) expect(wrapper.find('[data-testid="series-progress-bar"]').exists()).toBe(false)
}) })
it('fits stacked covers to the tile shape the grid passes', async () => {
bookCoverDisplayMode.value = 'natural-bottom'
const wrapper = mount(CollapsedSeriesCard, {
props: {
coverAspectRatio: '1/1',
book: makeBook({ coverAspectRatio: '1/1', collapsedSeries: { bookCount: 1, readCount: 0, coverBookIds: [73], seriesLatestAddedAt: null } }),
},
})
const image = wrapper.find('[data-testid="series-stack-cover"] .book-cover-artwork-frame img')
Object.defineProperty(image.element, 'naturalWidth', { configurable: true, value: 400 })
Object.defineProperty(image.element, 'naturalHeight', { configurable: true, value: 400 })
await image.trigger('load')
expect(wrapper.find('[data-testid="series-stack-cover"] .book-cover-artwork-frame').attributes('style')).toContain('inset: 0')
})
}) })
describe('single cover mode (non-mosaic)', () => { describe('single cover mode (non-mosaic)', () => {
function mountSquareSeriesInPortraitGrid(firstVolumeBookId: number) {
seriesCardCoverMode.value = 'first-volume'
return mount(CollapsedSeriesCard, {
props: {
coverAspectRatio: '2/3',
book: makeBook({
coverAspectRatio: '1/1',
collapsedSeries: { bookCount: 2, readCount: 0, coverBookIds: [firstVolumeBookId], seriesLatestAddedAt: null, firstVolumeBookId },
}),
},
})
}
async function loadSquareCover(wrapper: ReturnType<typeof mountSquareSeriesInPortraitGrid>) {
const image = wrapper.find('[data-testid="series-single-cover"] .book-cover-artwork-frame img')
Object.defineProperty(image.element, 'naturalWidth', { configurable: true, value: 400 })
Object.defineProperty(image.element, 'naturalHeight', { configurable: true, value: 400 })
await image.trigger('load')
}
it('keeps the grid tile shape and fits square art inside it instead of cropping', async () => {
bookCoverDisplayMode.value = 'blurred-fit'
const wrapper = mountSquareSeriesInPortraitGrid(71)
await loadSquareCover(wrapper)
expect(wrapper.find('.book-cover-surface').attributes('style')).toContain('aspect-ratio: 2/3')
expect(wrapper.find('[data-testid="series-single-cover"] .book-cover-artwork-frame img').classes()).toContain('object-contain')
})
it('keeps the badges on a natural-bottom cover that is shorter than the tile', async () => {
bookCoverDisplayMode.value = 'natural-bottom'
const wrapper = mountSquareSeriesInPortraitGrid(72)
await loadSquareCover(wrapper)
const overlayFrame = wrapper.find('[data-testid="series-overlay-frame"]')
expect(overlayFrame.attributes('style')).toContain('bottom: 0')
expect(overlayFrame.attributes('style')).toMatch(/height: 66\.6/)
expect(overlayFrame.find('[data-testid="series-count-badge"]').exists()).toBe(true)
})
it('renders single cover for first-volume mode', () => { it('renders single cover for first-volume mode', () => {
seriesCardCoverMode.value = 'first-volume' seriesCardCoverMode.value = 'first-volume'
const wrapper = mount(CollapsedSeriesCard, { const wrapper = mount(CollapsedSeriesCard, {