mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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, {
|
||||||
|
|||||||
Reference in New Issue
Block a user