mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(cover): disable spine overlay for audiobook covers
Respect audiobook presentation by forcing spine effects off for audio items. Keep the selected spine mode unchanged for non-audiobook covers. Ref #147
This commit is contained in:
@@ -282,6 +282,7 @@ async function handleSetStatus(status: ReadStatus) {
|
||||
class="book-cover-surface--spine-fitted relative w-full rounded-sm overflow-hidden transition-[box-shadow,transform,ring] duration-150 will-change-transform"
|
||||
:class="isMissing || selectionMode ? '' : 'group-hover:scale-[1.02]'"
|
||||
:interactive="!isMissing && !selectionMode"
|
||||
:disable-spine="isAudiobook"
|
||||
:style="[{ aspectRatio: coverAspectRatio }, !book.hasCover || !coverLoaded || coverFailed ? coverStyle : {}]"
|
||||
>
|
||||
<!-- Missing border overlay: mirror selected overlay pattern so border is never clipped/hidden -->
|
||||
@@ -309,7 +310,11 @@ async function handleSetStatus(status: ReadStatus) {
|
||||
@error="coverFailed = true"
|
||||
/>
|
||||
|
||||
<div v-if="book.hasCover && coverLoaded && !coverFailed" class="book-cover-spine-layer absolute z-[3]" :style="fittedCoverSpineStyle" />
|
||||
<div
|
||||
v-if="!isAudiobook && book.hasCover && coverLoaded && !coverFailed"
|
||||
class="book-cover-spine-layer absolute z-[3]"
|
||||
:style="fittedCoverSpineStyle"
|
||||
/>
|
||||
|
||||
<!-- Skeleton shimmer while a known-cover loads -->
|
||||
<div v-if="book.hasCover && !coverLoaded && !coverFailed" class="absolute inset-0 animate-pulse bg-white/10" />
|
||||
|
||||
@@ -7,16 +7,25 @@ const props = withDefaults(
|
||||
size?: 'default' | 'mini'
|
||||
interactive?: boolean
|
||||
tag?: string
|
||||
disableSpine?: boolean
|
||||
}>(),
|
||||
{
|
||||
size: 'default',
|
||||
interactive: false,
|
||||
tag: 'div',
|
||||
disableSpine: false,
|
||||
},
|
||||
)
|
||||
|
||||
const { bookSpineOverlay, bookShadowStrength } = useDisplaySettings()
|
||||
|
||||
const spineOverlayMode = computed(() => {
|
||||
if (props.disableSpine) return 'off'
|
||||
return bookSpineOverlay?.value ?? 'off'
|
||||
})
|
||||
|
||||
const shadowStrengthMode = computed(() => bookShadowStrength?.value ?? 'default')
|
||||
|
||||
const classes = computed(() => [
|
||||
'book-cover-surface',
|
||||
props.size === 'mini' ? 'book-cover-surface--mini' : '',
|
||||
@@ -29,8 +38,8 @@ const classes = computed(() => [
|
||||
:is="tag"
|
||||
:class="classes"
|
||||
:data-cover-size="size"
|
||||
:data-cover-shadow="bookShadowStrength"
|
||||
:data-cover-spine="bookSpineOverlay"
|
||||
:data-cover-shadow="shadowStrengthMode"
|
||||
:data-cover-spine="spineOverlayMode"
|
||||
:data-cover-interactive="interactive ? 'true' : 'false'"
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -157,6 +157,7 @@ function openAuthorBrowse() {
|
||||
<BookCoverSurface
|
||||
size="mini"
|
||||
class="book-cover-surface--spine-fitted w-16 rounded shrink-0 overflow-hidden relative"
|
||||
:disable-spine="isAudiobook"
|
||||
:class="isMissing ? 'opacity-50 grayscale' : ''"
|
||||
:style="[{ aspectRatio: coverAspectRatio }, !book.hasCover || !coverLoaded || coverFailed ? coverStyle : {}]"
|
||||
>
|
||||
@@ -178,7 +179,11 @@ function openAuthorBrowse() {
|
||||
@load="handleCoverLoad"
|
||||
@error="coverFailed = true"
|
||||
/>
|
||||
<div v-if="book.hasCover && coverLoaded && !coverFailed" class="book-cover-spine-layer absolute z-[3]" :style="fittedCoverSpineStyle" />
|
||||
<div
|
||||
v-if="!isAudiobook && book.hasCover && coverLoaded && !coverFailed"
|
||||
class="book-cover-spine-layer absolute z-[3]"
|
||||
:style="fittedCoverSpineStyle"
|
||||
/>
|
||||
<BookCoverPlaceholder
|
||||
v-if="!book.hasCover || coverFailed"
|
||||
:title="book.title"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type { BookCard } from '@bookorbit/types'
|
||||
import { FORMAT_TO_GROUP, type BookCard } from '@bookorbit/types'
|
||||
import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
|
||||
import BookCoverSurface from './BookCoverSurface.vue'
|
||||
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
|
||||
@@ -25,6 +25,8 @@ const coverIds = computed(() => collapsed.value.coverBookIds.filter((bookId) =>
|
||||
const tileCount = computed(() => Math.max(coverIds.value.length, 1))
|
||||
|
||||
const failedCovers = ref(new Set<number>())
|
||||
const primaryFile = computed(() => props.book.files.find((file) => file.role === 'primary') ?? props.book.files[0] ?? null)
|
||||
const isAudiobook = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
|
||||
|
||||
function handleCoverError(bookId: number) {
|
||||
failedCovers.value = new Set([...failedCovers.value, bookId])
|
||||
@@ -48,6 +50,7 @@ function tileClass(index: number): string {
|
||||
<BookCoverSurface
|
||||
class="relative w-full rounded-sm overflow-hidden transition-[box-shadow,transform] duration-150 will-change-transform group-hover:scale-[1.02]"
|
||||
interactive
|
||||
:disable-spine="isAudiobook"
|
||||
:style="{ aspectRatio: coverAspectRatio }"
|
||||
>
|
||||
<!-- Adaptive cover mosaic -->
|
||||
|
||||
@@ -247,6 +247,18 @@ describe('BookCoverCard — cover style preferences', () => {
|
||||
const coverDiv = wrapper.find('[style*="aspect-ratio"]')
|
||||
expect(coverDiv.attributes('data-cover-shadow')).toBe('strong')
|
||||
})
|
||||
|
||||
it('forces spine overlay off for audiobooks even when global spine mode is enabled', async () => {
|
||||
const wrapper = mountCard({
|
||||
...presentBook,
|
||||
files: [{ id: 12, format: 'm4b', role: 'primary', sizeBytes: null }],
|
||||
})
|
||||
bookSpineOverlay.value = 'strong'
|
||||
await nextTick()
|
||||
|
||||
const coverDiv = wrapper.find('[style*="aspect-ratio"]')
|
||||
expect(coverDiv.attributes('data-cover-spine')).toBe('off')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BookCoverCard — placeholder state', () => {
|
||||
|
||||
@@ -44,4 +44,13 @@ describe('BookCoverSurface', () => {
|
||||
expect(wrapper.attributes('type')).toBe('button')
|
||||
expect(wrapper.attributes('aria-label')).toBe('cover')
|
||||
})
|
||||
|
||||
it('forces spine overlay off when disableSpine is true', () => {
|
||||
bookSpineOverlay.value = 'strong'
|
||||
const wrapper = mount(BookCoverSurface, {
|
||||
props: { disableSpine: true },
|
||||
})
|
||||
|
||||
expect(wrapper.attributes('data-cover-spine')).toBe('off')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest'
|
||||
import BookListRow from '../BookListRow.vue'
|
||||
import type { BookCard } from '@bookorbit/types'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-router')>()
|
||||
@@ -35,6 +36,12 @@ const globalStubs = {
|
||||
},
|
||||
}
|
||||
|
||||
const { bookSpineOverlay } = useDisplaySettings()
|
||||
|
||||
afterEach(() => {
|
||||
bookSpineOverlay.value = 'off'
|
||||
})
|
||||
|
||||
const missingBook: BookCard = {
|
||||
id: 1,
|
||||
status: 'missing',
|
||||
@@ -145,4 +152,20 @@ describe('BookListRow — present state', () => {
|
||||
|
||||
expect(wrapper.findAll('.fill-lime-400')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('forces spine overlay off for audiobook rows even when global spine mode is enabled', () => {
|
||||
bookSpineOverlay.value = 'strong'
|
||||
const wrapper = mount(BookListRow, {
|
||||
props: {
|
||||
book: {
|
||||
...presentBook,
|
||||
files: [{ id: 22, format: 'm4b', role: 'primary', sizeBytes: null }],
|
||||
},
|
||||
},
|
||||
global: globalStubs,
|
||||
})
|
||||
|
||||
const cover = wrapper.find('.book-cover-surface')
|
||||
expect(cover.attributes('data-cover-spine')).toBe('off')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type { BookCard } from '@bookorbit/types'
|
||||
import CollapsedSeriesCard from '../CollapsedSeriesCard.vue'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
|
||||
const mockRouterPush = vi.fn<(...args: unknown[]) => unknown>()
|
||||
|
||||
@@ -55,11 +56,17 @@ function makeBook(overrides?: Partial<BookCard>): BookCard {
|
||||
}
|
||||
}
|
||||
|
||||
const { bookSpineOverlay } = useDisplaySettings()
|
||||
|
||||
describe('CollapsedSeriesCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
bookSpineOverlay.value = 'off'
|
||||
})
|
||||
|
||||
it('renders four cover tiles when all books are represented by covers', () => {
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: {
|
||||
@@ -193,4 +200,18 @@ describe('CollapsedSeriesCard', () => {
|
||||
expect(wrapper.findAll('[data-testid="series-cover-tile"]')).toHaveLength(4)
|
||||
expect(wrapper.findAll('[data-testid="series-cover-fallback"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('forces spine overlay off for audiobook series cards', () => {
|
||||
bookSpineOverlay.value = 'strong'
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: {
|
||||
book: makeBook({
|
||||
files: [{ id: 5, format: 'm4b', role: 'primary', sizeBytes: null }],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const cover = wrapper.find('.book-cover-surface')
|
||||
expect(cover.attributes('data-cover-spine')).toBe('off')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Lock, LockOpen } from 'lucide-vue-next'
|
||||
import { FORMAT_TO_GROUP, type BookCard } from '@bookorbit/types'
|
||||
import BookTableCoverCell from './BookTableCoverCell.vue'
|
||||
import BookTableTextCell from './BookTableTextCell.vue'
|
||||
import BookTableNumberCell from './BookTableNumberCell.vue'
|
||||
@@ -14,7 +15,6 @@ import BookTableActionsCell from './BookTableActionsCell.vue'
|
||||
import BookTableProgressCell from './BookTableProgressCell.vue'
|
||||
import BookTableMetadataScoreCell from './BookTableMetadataScoreCell.vue'
|
||||
import BookTableLockableCell from './BookTableLockableCell.vue'
|
||||
import type { BookCard } from '@bookorbit/types'
|
||||
import type { CellType, ColumnId } from '@/features/book/composables/tableColumnSchema'
|
||||
|
||||
type NavigationDirection = 'next' | 'prev' | 'rowUp' | 'rowDown'
|
||||
@@ -71,6 +71,9 @@ const lockStateClass = computed(() => {
|
||||
if (props.lockedFieldCount > 0) return 'text-amber-600/90 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300'
|
||||
return 'text-muted-foreground/70 hover:text-foreground'
|
||||
})
|
||||
|
||||
const primaryFile = computed(() => props.book.files.find((file) => file.role === 'primary') ?? props.book.files[0] ?? null)
|
||||
const isAudiobook = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -91,6 +94,7 @@ const lockStateClass = computed(() => {
|
||||
:book-id="book.id"
|
||||
:title="book.title"
|
||||
:has-cover="book.hasCover"
|
||||
:is-audio="isAudiobook"
|
||||
@cover-click="emit('coverClick')"
|
||||
/>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
import type { BookCard } from '@bookorbit/types'
|
||||
import { FORMAT_TO_GROUP, type BookCard } from '@bookorbit/types'
|
||||
import BookCoverSurface from '../BookCoverSurface.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -18,6 +18,8 @@ const seriesName = computed(() => props.book.seriesName ?? '')
|
||||
const readCount = computed(() => collapsed.value.readCount)
|
||||
const bookCount = computed(() => collapsed.value.bookCount)
|
||||
const coverIds = computed(() => collapsed.value.coverBookIds.filter((id) => id > 0).slice(0, 4))
|
||||
const primaryFile = computed(() => props.book.files.find((file) => file.role === 'primary') ?? props.book.files[0] ?? null)
|
||||
const isAudiobook = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
|
||||
|
||||
function handleClick() {
|
||||
if (props.selectionMode) return
|
||||
@@ -34,10 +36,21 @@ function thumbnailUrl(bookId: number): string {
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<!-- Cover row: up to 4 thumbnails -->
|
||||
<div class="flex shrink-0 gap-0.5">
|
||||
<BookCoverSurface v-for="coverId in coverIds" :key="coverId" size="mini" class="h-8 w-6 rounded-sm overflow-hidden">
|
||||
<BookCoverSurface
|
||||
v-for="coverId in coverIds"
|
||||
:key="coverId"
|
||||
size="mini"
|
||||
:disable-spine="isAudiobook"
|
||||
class="h-8 w-6 rounded-sm overflow-hidden"
|
||||
>
|
||||
<img :src="thumbnailUrl(coverId)" class="h-full w-full object-cover" loading="lazy" alt="" />
|
||||
</BookCoverSurface>
|
||||
<BookCoverSurface v-if="coverIds.length === 0" size="mini" class="h-8 w-6 rounded-sm bg-muted flex items-center justify-center">
|
||||
<BookCoverSurface
|
||||
v-if="coverIds.length === 0"
|
||||
size="mini"
|
||||
:disable-spine="isAudiobook"
|
||||
class="h-8 w-6 rounded-sm bg-muted flex items-center justify-center"
|
||||
>
|
||||
<span class="text-[8px] text-muted-foreground">?</span>
|
||||
</BookCoverSurface>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ const props = defineProps<{
|
||||
bookId: number
|
||||
title: string | null
|
||||
hasCover: boolean
|
||||
isAudio: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'cover-click': [] }>()
|
||||
@@ -67,6 +68,7 @@ const adjustedTop = computed(() => {
|
||||
<div class="relative" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
|
||||
<BookCoverSurface
|
||||
size="mini"
|
||||
:disable-spine="isAudio"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label="View cover"
|
||||
|
||||
+23
-1
@@ -1,7 +1,8 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BookCard } from '@bookorbit/types'
|
||||
import BookTableCollapsedSeriesRow from '../BookTableCollapsedSeriesRow.vue'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
|
||||
const mockRouterPush = vi.fn<(...args: unknown[]) => unknown>()
|
||||
|
||||
@@ -46,11 +47,17 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
|
||||
}
|
||||
}
|
||||
|
||||
const { bookSpineOverlay } = useDisplaySettings()
|
||||
|
||||
describe('BookTableCollapsedSeriesRow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
bookSpineOverlay.value = 'off'
|
||||
})
|
||||
|
||||
it('renders the series name and book count', () => {
|
||||
const wrapper = mount(BookTableCollapsedSeriesRow, {
|
||||
props: { book: makeBook(), colspan: 6 },
|
||||
@@ -153,4 +160,19 @@ describe('BookTableCollapsedSeriesRow', () => {
|
||||
|
||||
expect(mockRouterPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forces spine overlay off for audiobook collapsed rows', () => {
|
||||
bookSpineOverlay.value = 'strong'
|
||||
const wrapper = mount(BookTableCollapsedSeriesRow, {
|
||||
props: {
|
||||
book: makeBook({
|
||||
files: [{ id: 9, format: 'm4b', role: 'primary', sizeBytes: null }],
|
||||
}),
|
||||
colspan: 6,
|
||||
},
|
||||
})
|
||||
|
||||
const cover = wrapper.find('.book-cover-surface')
|
||||
expect(cover.attributes('data-cover-spine')).toBe('off')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user