diff --git a/client/src/features/book/components/CollapsedSeriesCard.vue b/client/src/features/book/components/CollapsedSeriesCard.vue index 2b288be97..e7c6fb44e 100644 --- a/client/src/features/book/components/CollapsedSeriesCard.vue +++ b/client/src/features/book/components/CollapsedSeriesCard.vue @@ -6,7 +6,7 @@ import { Library } from '@lucide/vue' import BookCoverArtwork from './BookCoverArtwork.vue' import BookCoverPlaceholder from './BookCoverPlaceholder.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 { useDisplaySettings, type GridCardLabelField } from '@/composables/useDisplaySettings' 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 effectiveCoverAspectRatio = computed(() => props.coverAspectRatio ?? props.book.coverAspectRatio ?? injectedCoverAspectRatio.value) 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 seriesName = computed(() => props.book.seriesName ?? '') @@ -69,12 +69,17 @@ const resolvedCoverId = computed(() => { } }) -const singleCoverFailed = ref(false) -const singleCoverLoaded = ref(false) +const showSingleCover = computed(() => !isStack.value && !isMosaic.value && resolvedCoverId.value != null) +const singleCoverRatio = ref(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, () => { - singleCoverFailed.value = false - singleCoverLoaded.value = false + singleCoverRatio.value = null }) watch( allCoverIds, @@ -98,14 +103,12 @@ function handleCoverError(bookId: number) { failedCovers.value = new Set([...failedCovers.value, bookId]) } -function handleSingleCoverLoad() { - singleCoverLoaded.value = true - singleCoverFailed.value = false +function handleSingleCoverLoad(ratio: number | null) { + singleCoverRatio.value = ratio } function handleSingleCoverError() { - singleCoverLoaded.value = false - singleCoverFailed.value = true + singleCoverRatio.value = null } function coverVersionFor(bookId: number): string | null | undefined { @@ -209,7 +212,7 @@ const secondaryLabelText = computed(() => resolveSeriesLabel(gridCardSecondaryLa
resolveSeriesLabel(gridCardSecondaryLa :title="seriesName" :author-line="authorLine" :seed="`series-${bookId}`" + :frame-aspect-ratio="effectiveCoverAspectRatio" alt="" loading="lazy" decoding="async" @@ -258,24 +262,21 @@ const secondaryLabelText = computed(() => resolveSeriesLabel(gridCardSecondaryLa
- - -
@@ -307,52 +308,56 @@ const secondaryLabelText = computed(() => resolveSeriesLabel(gridCardSecondaryLa
-
- {{ collapsed.bookCount }} -
- - -
- -
- - -
-
- + +
+ {{ collapsed.bookCount }}
-
-

- {{ seriesName }} -

-

- {{ authorLine }} -

-
-
- -
-
+ +
+ +
+ + +
+
+ +
+
+

+ {{ seriesName }} +

+

+ {{ authorLine }} +

+
+
+ + +
+
+
diff --git a/client/src/features/book/components/__tests__/CollapsedSeriesCard.spec.ts b/client/src/features/book/components/__tests__/CollapsedSeriesCard.spec.ts index 4a7ada892..b1b51d89d 100644 --- a/client/src/features/book/components/__tests__/CollapsedSeriesCard.spec.ts +++ b/client/src/features/book/components/__tests__/CollapsedSeriesCard.spec.ts @@ -90,7 +90,8 @@ function makeBook(overrides?: Partial): BookCard { const expectedCoverUrl = (bookId: number, version = '2024-01-01T00:00:00.000Z') => `/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', () => { beforeEach(() => { @@ -101,6 +102,7 @@ describe('CollapsedSeriesCard', () => { afterEach(() => { bookSpineOverlay.value = 'off' + bookCoverDisplayMode.value = 'blurred-fit' seriesCardCoverMode.value = 'stack' gridCardPrimaryLabel.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-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)', () => { + 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) { + 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', () => { seriesCardCoverMode.value = 'first-volume' const wrapper = mount(CollapsedSeriesCard, {