mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(client): prevent audiobook cover cropping in series view
Closes #153
This commit is contained in:
@@ -9,7 +9,6 @@ A self-hosted library management and reading platform for ebooks, PDFs, audioboo
|
||||
[](https://github.com/bookorbit/bookorbit/actions/workflows/release.yml)
|
||||
[](https://codecov.io/gh/bookorbit/bookorbit)
|
||||
[](https://github.com/bookorbit/bookorbit/releases)
|
||||
[](https://github.com/bookorbit/bookorbit/commits/main)
|
||||
[](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"
|
||||
|
||||
Reference in New Issue
Block a user