feat(client): support square audiobook covers and inline series cells

Refactors the virtual book table to render collapsed series as standard
inline cells (BookTableCollapsedSeriesCell) rather than full-width
rows, improving table alignment and layout consistency. Updates the
dashboard scroller and cover artwork components to support 1:1 aspect
ratios, ensuring audiobook covers are displayed correctly without
distortion.
This commit is contained in:
neon
2026-06-01 20:08:18 +00:00
parent 1baae51a32
commit 9fd61d327a
13 changed files with 239 additions and 367 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
--book-cover-spine-width: 6%;
position: relative;
isolation: isolate;
background-color: white;
background-color: color-mix(in oklch, var(--foreground) 12%, var(--background));
background-size: cover;
box-shadow: var(--book-cover-shadow);
}
@@ -115,6 +115,16 @@ function handleImageError() {
<template>
<template v-if="canRenderImage">
<span
:class="[
'absolute inset-0 z-0 transition-opacity duration-200 ease-out',
loaded ? 'opacity-0 pointer-events-none' : 'opacity-100',
effectiveMode === 'natural-bottom' ? 'book-cover-artwork-placeholder-natural overflow-hidden rounded-[inherit]' : '',
]"
aria-hidden="true"
>
<BookCoverPlaceholder :title="title" :author-line="authorLine" :is-audio="isAudio" :seed="seed" />
</span>
<img
v-if="showBlurredBackdrop"
:src="src ?? ''"
@@ -143,7 +153,7 @@ function handleImageError() {
/>
<span v-if="showSpineLayer" class="book-cover-spine-layer absolute inset-0 z-[3]" :style="spineStyle" />
</span>
<span v-if="!loaded" class="absolute inset-0 z-[2] animate-pulse bg-white/10" />
<span v-if="!loaded" class="absolute inset-0 z-[2] animate-pulse bg-foreground/5" />
</template>
<span
v-else
@@ -56,19 +56,42 @@ const resolvedCoverId = computed<number | null>(() => {
})
const failedCovers = ref(new Set<number>())
const loadedCovers = ref(new Set<number>())
const singleCoverFailed = ref(false)
const singleCoverLoaded = ref(false)
watch(resolvedCoverId, () => {
singleCoverFailed.value = false
singleCoverLoaded.value = false
})
watch(
coverIds,
(ids) => {
const activeIds = new Set(ids)
failedCovers.value = new Set([...failedCovers.value].filter((id) => activeIds.has(id)))
loadedCovers.value = new Set([...loadedCovers.value].filter((id) => activeIds.has(id)))
},
{ immediate: true },
)
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 handleCoverLoad(bookId: number) {
loadedCovers.value = new Set([...loadedCovers.value, bookId])
}
function handleCoverError(bookId: number) {
loadedCovers.value = new Set([...loadedCovers.value].filter((id) => id !== bookId))
failedCovers.value = new Set([...failedCovers.value, bookId])
}
function handleSingleCoverLoad() {
singleCoverLoaded.value = true
singleCoverFailed.value = false
}
function handleSingleCoverError() {
singleCoverLoaded.value = false
singleCoverFailed.value = true
}
@@ -145,30 +168,48 @@ const secondaryLabelText = computed(() => resolveSeriesLabel(gridCardSecondaryLa
>
<!-- Single cover mode -->
<div v-if="!isMosaic && resolvedCoverId != null" class="absolute inset-0" data-testid="series-single-cover">
<div
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)"
class="absolute inset-0 h-full w-full object-cover"
class="absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-out"
:class="singleCoverLoaded ? 'opacity-100' : 'opacity-0'"
loading="lazy"
alt=""
@load="handleSingleCoverLoad"
@error="handleSingleCoverError"
/>
<BookCoverPlaceholder v-else title="" author-line="" :is-audio="false" :seed="`series-${resolvedCoverId}`" />
<span v-if="!singleCoverLoaded && !singleCoverFailed" class="absolute inset-0 z-[1] animate-pulse bg-foreground/5" />
</div>
<!-- Adaptive cover mosaic (default mode) -->
<div v-else class="absolute inset-0 grid grid-cols-2 grid-rows-2">
<template v-for="(bookId, i) in coverIds" :key="bookId">
<div class="relative overflow-hidden" :class="tileClass(i)" data-testid="series-cover-tile">
<div
class="absolute inset-0 transition-opacity duration-200 ease-out"
:class="loadedCovers.has(bookId) && !failedCovers.has(bookId) ? 'opacity-0 pointer-events-none' : 'opacity-100'"
aria-hidden="true"
>
<BookCoverPlaceholder title="" author-line="" :is-audio="false" :seed="`series-${bookId}`" />
</div>
<img
v-if="!failedCovers.has(bookId)"
:src="coverUrl(bookId)"
class="absolute inset-0 h-full w-full object-cover"
class="absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-out"
:class="loadedCovers.has(bookId) ? 'opacity-100' : 'opacity-0'"
loading="lazy"
alt=""
@load="() => handleCoverLoad(bookId)"
@error="() => handleCoverError(bookId)"
/>
<BookCoverPlaceholder v-else title="" author-line="" :is-audio="false" :seed="`series-${bookId}`" />
<span v-if="!loadedCovers.has(bookId) && !failedCovers.has(bookId)" class="absolute inset-0 z-[1] animate-pulse bg-foreground/5" />
</div>
</template>
<div v-if="coverIds.length === 0" class="relative overflow-hidden" :class="tileClass(0)" data-testid="series-cover-fallback">
@@ -31,11 +31,12 @@ import { useRefreshMetadata } from '@/features/book/composables/useRefreshMetada
import { useRefreshingBooks } from '@/features/book/composables/useRefreshingBooks'
import { useBookRefreshFeedback } from '@/features/book/composables/useBookRefreshFeedback'
import type { InFlightOp } from '@/features/book/composables/useBookBulkActions'
import { useRouter } from 'vue-router'
import { detectChangedColumns, mergeBookCardWithDetail } from '@/features/book/lib/book-card-mapper'
import { buildLockStateBookUpdate, mergeBookPatchWithLatest, sameLockFields } from '@/features/book/lib/table-row-state-sync'
import BookCoverDialog from './table/BookCoverDialog.vue'
import BookTableCellDispatcher from './table/BookTableCellDispatcher.vue'
import BookTableCollapsedSeriesRow from './table/BookTableCollapsedSeriesRow.vue'
import BookTableCollapsedSeriesCell from './table/BookTableCollapsedSeriesCell.vue'
import BookTableContextMenu from './table/BookTableContextMenu.vue'
import BookTableHeader from './table/BookTableHeader.vue'
import BookTableHeaderContextMenu from './table/BookTableHeaderContextMenu.vue'
@@ -79,6 +80,7 @@ const emit = defineEmits<{
const { md } = useBreakpoints(breakpointsTailwind)
const isReadOnly = computed(() => !md.value)
const { tableDensity, tableZebraStriping } = useDisplaySettings()
const router = useRouter()
const { layout, visibleColumns, allColumns, toggleColumn, setColumnOrder, setColumnWidth, setLayout, pinColumn, unpinColumn, resetLayout } =
useTableColumns(props.viewType)
@@ -508,6 +510,10 @@ function handleNavigate(book: BookCard, colId: ColumnId, direction: 'next' | 'pr
function handleRowClick(book: BookCard, e: MouseEvent) {
if (props.selectionMode) {
emit('select', book.id, e)
return
}
if (book.collapsedSeries) {
router.push({ name: 'series-detail', params: { seriesName: book.seriesName ?? '' } })
}
}
@@ -705,92 +711,74 @@ defineExpose({
@click="handleRowClick(books[vItem.index]!, $event)"
@contextmenu.prevent="(event) => handleRowContextMenu(event, books[vItem.index]!)"
>
<!-- Collapsed series row: span all columns with dedicated component -->
<template v-if="books[vItem.index]!.collapsedSeries">
<td
v-if="selectionMode"
role="gridcell"
class="overflow-hidden px-2 bg-background"
:class="rowPaddingClass"
:style="{ width: '36px', minWidth: '36px', position: 'sticky', left: '0px', zIndex: 20 }"
>
<div class="flex items-center justify-center">
<input
type="checkbox"
class="accent-primary h-3.5 w-3.5 cursor-pointer"
:checked="isSelected?.(books[vItem.index]!.id) ?? false"
@click.stop="handleCheckboxClick(books[vItem.index]!.id, $event)"
/>
</div>
</td>
<BookTableCollapsedSeriesRow :book="books[vItem.index]!" :colspan="displayColumns.length" :selection-mode="selectionMode" />
</template>
<!-- Checkbox cell -->
<td
v-if="selectionMode"
role="gridcell"
class="overflow-hidden px-2 bg-background"
:class="rowPaddingClass"
:style="{ width: '36px', minWidth: '36px', position: 'sticky', left: '0px', zIndex: 20 }"
>
<div class="flex items-center justify-center">
<input
type="checkbox"
class="accent-primary h-3.5 w-3.5 cursor-pointer"
:checked="isSelected?.(books[vItem.index]!.id) ?? false"
@click.stop="handleCheckboxClick(books[vItem.index]!.id, $event)"
/>
</div>
</td>
<template v-else>
<!-- Checkbox cell -->
<td
v-if="selectionMode"
role="gridcell"
class="overflow-hidden px-2 bg-background"
:class="rowPaddingClass"
:style="{ width: '36px', minWidth: '36px', position: 'sticky', left: '0px', zIndex: 20 }"
>
<div class="flex items-center justify-center">
<input
type="checkbox"
class="accent-primary h-3.5 w-3.5 cursor-pointer"
:checked="isSelected?.(books[vItem.index]!.id) ?? false"
@click.stop="handleCheckboxClick(books[vItem.index]!.id, $event)"
/>
</div>
</td>
<td
v-for="(col, colIdx) in displayColumns"
:key="col.id"
:data-col-id="col.id"
:data-row-index="vItem.index"
role="gridcell"
class="relative overflow-hidden px-2 align-middle"
:class="[
rowPaddingClass,
col.pinned === 'right' ? 'border-l border-border/60' : '',
col.pinned === null ? (isMandatoryFieldEmpty(books[vItem.index]!, col.id) ? 'bg-amber-500/5' : '') : '',
isCellChanged(books[vItem.index]!.id, col.id) ? 'book-cell--changed' : '',
isFocusedCell(vItem.index, colIdx) && !editor.isActive(books[vItem.index]!.id, col.id)
? 'outline outline-2 outline-primary/50 -outline-offset-2 rounded-sm'
: '',
]"
:style="{
width: `${col.defaultWidth}px`,
minWidth: `${col.minWidth}px`,
...(col.pinned === 'left' || col.id === 'lockRow'
? {
position: 'sticky',
left: `${(selectionMode ? 36 : 0) + (pinnedLeftOffsets.get(col.id) ?? 0)}px`,
zIndex: col.id === 'lockRow' ? 20 : 2,
background: getPinnedCellBackground(
books[vItem.index]!,
col.id,
selectionMode && (isSelected?.(books[vItem.index]!.id) ?? false),
),
}
: {}),
...(col.pinned === 'right'
? {
position: 'sticky',
right: '0',
zIndex: 2,
background: getPinnedCellBackground(
books[vItem.index]!,
col.id,
selectionMode && (isSelected?.(books[vItem.index]!.id) ?? false),
),
}
: {}),
}"
@click="focusTableCell(vItem.index, colIdx, $event, books[vItem.index]!.id, col.id)"
>
<td
v-for="(col, colIdx) in displayColumns"
:key="col.id"
:data-col-id="col.id"
:data-row-index="vItem.index"
role="gridcell"
class="relative overflow-hidden px-2 align-middle"
:class="[
rowPaddingClass,
col.pinned === 'right' ? 'border-l border-border/60' : '',
col.pinned === null ? (isMandatoryFieldEmpty(books[vItem.index]!, col.id) ? 'bg-amber-500/5' : '') : '',
isCellChanged(books[vItem.index]!.id, col.id) ? 'book-cell--changed' : '',
isFocusedCell(vItem.index, colIdx) && !editor.isActive(books[vItem.index]!.id, col.id)
? 'outline outline-2 outline-primary/50 -outline-offset-2 rounded-sm'
: '',
]"
:style="{
width: `${col.defaultWidth}px`,
minWidth: `${col.minWidth}px`,
...(col.pinned === 'left' || col.id === 'lockRow'
? {
position: 'sticky',
left: `${(selectionMode ? 36 : 0) + (pinnedLeftOffsets.get(col.id) ?? 0)}px`,
zIndex: col.id === 'lockRow' ? 20 : 2,
background: getPinnedCellBackground(
books[vItem.index]!,
col.id,
selectionMode && (isSelected?.(books[vItem.index]!.id) ?? false),
),
}
: {}),
...(col.pinned === 'right'
? {
position: 'sticky',
right: '0',
zIndex: 2,
background: getPinnedCellBackground(
books[vItem.index]!,
col.id,
selectionMode && (isSelected?.(books[vItem.index]!.id) ?? false),
),
}
: {}),
}"
@click="focusTableCell(vItem.index, colIdx, $event, books[vItem.index]!.id, col.id)"
>
<template v-if="books[vItem.index]!.collapsedSeries">
<BookTableCollapsedSeriesCell :book="books[vItem.index]!" :col-id="col.id" />
</template>
<template v-else>
<BookTableCellDispatcher
:book="books[vItem.index]!"
:col-id="col.id"
@@ -855,8 +843,8 @@ defineExpose({
</button>
</span>
</div>
</td>
</template>
</template>
</td>
</tr>
<tr v-if="virtualItems.length > 0" role="row">
@@ -133,7 +133,7 @@ describe('BookCoverArtwork', () => {
await wrapper.setProps({ resetKey: 'retry-1' })
expect(wrapper.find('img[alt="Dune cover"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="placeholder"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="placeholder"]').exists()).toBe(true)
})
it('does not render the fitted spine layer when spine is disabled', async () => {
@@ -291,11 +291,6 @@ describe('BookCoverCard — placeholder state', () => {
expect(imgs).toHaveLength(0)
})
it('does not show BookCoverPlaceholder when hasCover is true', () => {
const wrapper = mountCard(presentBookWithCover)
expect(wrapper.findComponent({ name: 'BookCoverPlaceholder' }).exists()).toBe(false)
})
it('renders cover img when hasCover is true', () => {
const wrapper = mountCard(presentBookWithCover)
const imgs = wrapper.findAll('img')
@@ -116,7 +116,7 @@ describe('CollapsedSeriesCard', () => {
expect(wrapper.findAll('[data-testid="series-cover-tile"]')).toHaveLength(1)
expect(wrapper.findAll('[data-testid="series-cover-fallback"]')).toHaveLength(0)
expect(wrapper.findAll('.book-cover-placeholder')).toHaveLength(0)
expect(wrapper.findAll('.book-cover-placeholder')).toHaveLength(1)
})
it('shows a single fallback tile when coverBookIds is empty', () => {
@@ -199,7 +199,7 @@ describe('CollapsedSeriesCard', () => {
await firstImg.trigger('error')
await wrapper.vm.$nextTick()
expect(wrapper.findAll('.book-cover-placeholder')).toHaveLength(1)
expect(wrapper.findAll('.book-cover-placeholder')).toHaveLength(2)
})
it('uses at most 4 cover tiles even if more are provided', () => {
@@ -135,12 +135,6 @@ describe('BookCarousel', () => {
const book = makeBook({ hasCover: true })
const wrapper = mountCarousel([book])
expect(wrapper.find('img').exists()).toBe(true)
expect(wrapper.find('[data-testid="placeholder"]').exists()).toBe(false)
})
it('does not render placeholder for audiobook recommendations that have a cover', () => {
const wrapper = mountCarousel([makeBook({ hasCover: true, isAudiobook: true })])
expect(wrapper.find('[data-testid="placeholder"]').exists()).toBe(false)
})
it('renders BookCoverPlaceholder when book.hasCover is false', () => {
@@ -197,7 +191,6 @@ describe('BookCarousel', () => {
await wrapper.setProps({ books: [makeBook({ id: 42, hasCover: true, title: 'Retried' })] })
expect(wrapper.find('[data-book-id="42"] img').exists()).toBe(true)
expect(wrapper.find('[data-testid="placeholder"]').exists()).toBe(false)
})
it('renders centered cover with blurred backdrop after non-audiobook cover image loads', async () => {
@@ -212,7 +205,6 @@ describe('BookCarousel', () => {
expect(backdrop?.classes()).toContain('blur-md')
expect(backdrop?.classes()).toContain('object-cover')
expect(cover?.classes()).toContain('object-contain')
expect(wrapper.find('[data-testid="placeholder"]').exists()).toBe(false)
})
it('renders audiobook covers as square without blurred backdrop', async () => {
@@ -282,7 +274,7 @@ describe('BookCarousel', () => {
const wrapper = mountCarousel(books)
expect(wrapper.findAll('[data-book-id]').length).toBe(3)
expect(wrapper.findAll('img').length).toBe(2)
expect(wrapper.findAll('[data-testid="placeholder"]').length).toBe(1)
expect(wrapper.findAll('[data-testid="placeholder"]').length).toBe(3)
})
it('uses book id as seed when title is null', () => {
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ChevronRight, LibraryBig } from 'lucide-vue-next'
import { FORMAT_TO_GROUP, type BookCard } from '@bookorbit/types'
import BookCoverSurface from '../BookCoverSurface.vue'
import type { ColumnId } from '@/features/book/composables/useTableColumns'
const props = defineProps<{
book: BookCard
colId: ColumnId
}>()
const collapsed = computed(() => props.book.collapsedSeries!)
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 thumbnailUrl(bookId: number): string {
return `/api/v1/books/${bookId}/thumbnail`
}
</script>
<template>
<div v-if="colId === 'lockRow'" class="flex h-6 w-6 items-center justify-center">
<LibraryBig :size="13" class="text-muted-foreground/60" />
</div>
<div v-else-if="colId === 'readStatus'" class="flex w-full items-center px-1 py-0.5">
<div class="flex w-3 items-center justify-center">
<span class="text-[10px] text-muted-foreground/40">-</span>
</div>
</div>
<div v-else-if="colId === 'read'" class="flex w-full items-center justify-start">
<div class="mr-auto flex h-7 w-7 items-center justify-center">
<span class="text-[10px] text-muted-foreground/40">-</span>
</div>
</div>
<div v-else-if="colId === 'cover'" class="flex h-9 items-center">
<!-- Cover row: up to 4 thumbnails -->
<div class="flex shrink-0 gap-0.5 min-w-9" :class="coverIds.length <= 1 ? 'justify-center' : 'justify-start'">
<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"
: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>
</div>
<div v-else-if="colId === 'title'" class="flex items-center gap-2 min-w-0 h-full px-1">
<span class="font-medium text-sm truncate block">{{ seriesName }}</span>
<span class="text-xs text-muted-foreground shrink-0 mt-0.5">({{ bookCount }} {{ bookCount === 1 ? 'book' : 'books' }})</span>
<span v-if="readCount > 0" class="text-xs text-muted-foreground shrink-0 mt-0.5">&middot; {{ readCount }} of {{ bookCount }} read</span>
</div>
<div v-else-if="colId === 'actions'" class="flex h-full w-6 items-center justify-center">
<ChevronRight :size="16" class="text-muted-foreground/60 group-hover:text-foreground transition-colors" />
</div>
<!-- Dash for all other columns -->
<div v-else class="flex h-full items-center">
<span class="text-[10px] text-muted-foreground/20">-</span>
</div>
</template>
@@ -1,71 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { ChevronRight } from 'lucide-vue-next'
import { FORMAT_TO_GROUP, type BookCard } from '@bookorbit/types'
import BookCoverSurface from '../BookCoverSurface.vue'
const props = defineProps<{
book: BookCard
colspan: number
selectionMode?: boolean
}>()
const router = useRouter()
const collapsed = computed(() => props.book.collapsedSeries!)
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
router.push({ name: 'series-detail', params: { seriesName: seriesName.value } })
}
function thumbnailUrl(bookId: number): string {
return `/api/v1/books/${bookId}/thumbnail`
}
</script>
<template>
<td :colspan="colspan" role="gridcell" class="cursor-pointer px-3 py-1.5" @click="handleClick">
<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"
: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"
: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>
<!-- Series info -->
<div class="min-w-0 flex-1">
<span class="font-medium text-sm truncate block">{{ seriesName }}</span>
<div class="flex items-center gap-2 mt-0.5">
<span class="text-xs text-muted-foreground"> {{ bookCount }} {{ bookCount === 1 ? 'book' : 'books' }} </span>
<span v-if="readCount > 0" class="text-xs text-muted-foreground"> &middot; {{ readCount }} of {{ bookCount }} read </span>
</div>
</div>
<!-- Chevron -->
<ChevronRight :size="14" class="shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
</div>
</td>
</template>
@@ -1,178 +0,0 @@
import { mount } from '@vue/test-utils'
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>()
vi.mock('vue-router', () => ({
useRouter: () => ({ push: mockRouterPush }),
}))
function makeBook(overrides: Partial<BookCard> = {}): BookCard {
return {
id: 1,
status: 'present',
title: 'Book One',
authors: ['Author A'],
seriesName: 'The Arc',
seriesIndex: 1,
files: [],
publishedYear: null,
language: null,
genres: [],
tags: [],
rating: null,
readingProgress: null,
readStatus: null,
addedAt: '2026-01-01T00:00:00.000Z',
updatedAt: null,
metadataScore: null,
hasCover: false,
hasMetadataLocks: false,
lockedFields: [],
subtitle: null,
publisher: null,
pageCount: null,
isbn13: null,
narrators: [],
collapsedSeries: {
bookCount: 5,
readCount: 2,
coverBookIds: [10, 20, 30, 40],
seriesLatestAddedAt: null,
},
...overrides,
}
}
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 },
})
expect(wrapper.text()).toContain('The Arc')
expect(wrapper.text()).toContain('5 books')
})
it('renders the read count when some books are read', () => {
const wrapper = mount(BookTableCollapsedSeriesRow, {
props: { book: makeBook(), colspan: 6 },
})
expect(wrapper.text()).toContain('2 of 5 read')
})
it('does not render the read count when no books are read', () => {
const wrapper = mount(BookTableCollapsedSeriesRow, {
props: {
book: makeBook({
collapsedSeries: { bookCount: 5, readCount: 0, coverBookIds: [10, 20], seriesLatestAddedAt: null },
}),
colspan: 6,
},
})
expect(wrapper.text()).not.toContain('0 of 5 read')
})
it('renders up to four cover thumbnails', () => {
const wrapper = mount(BookTableCollapsedSeriesRow, {
props: { book: makeBook(), colspan: 6 },
})
const images = wrapper.findAll('img')
expect(images).toHaveLength(4)
expect(images.map((image) => image.attributes('src'))).toEqual([
'/api/v1/books/10/thumbnail',
'/api/v1/books/20/thumbnail',
'/api/v1/books/30/thumbnail',
'/api/v1/books/40/thumbnail',
])
})
it('renders a placeholder when no cover ids are available', () => {
const wrapper = mount(BookTableCollapsedSeriesRow, {
props: {
book: makeBook({
collapsedSeries: { bookCount: 5, readCount: 0, coverBookIds: [], seriesLatestAddedAt: null },
}),
colspan: 6,
},
})
expect(wrapper.findAll('img')).toHaveLength(0)
expect(wrapper.text()).toContain('?')
})
it('navigates to the series detail route on click', async () => {
const wrapper = mount(BookTableCollapsedSeriesRow, {
props: { book: makeBook(), colspan: 6 },
})
await wrapper.get('td').trigger('click')
expect(mockRouterPush).toHaveBeenCalledWith({
name: 'series-detail',
params: { seriesName: 'The Arc' },
})
})
it('renders no more than four thumbnails even when more cover ids are provided', () => {
const wrapper = mount(BookTableCollapsedSeriesRow, {
props: {
book: makeBook({
collapsedSeries: { bookCount: 6, readCount: 1, coverBookIds: [10, 20, 30, 40, 50, 60], seriesLatestAddedAt: null },
}),
colspan: 6,
},
})
expect(wrapper.findAll('img')).toHaveLength(4)
})
it('sets the expected colspan on the table cell', () => {
const wrapper = mount(BookTableCollapsedSeriesRow, {
props: { book: makeBook(), colspan: 9 },
})
expect(wrapper.get('td').attributes('colspan')).toBe('9')
})
it('does not navigate when selectionMode is true', async () => {
const wrapper = mount(BookTableCollapsedSeriesRow, {
props: { book: makeBook(), colspan: 6, selectionMode: true },
})
await wrapper.get('td').trigger('click')
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')
})
})
@@ -2,7 +2,7 @@
import { computed, ref, useAttrs } from 'vue'
import { Aperture, BookMarked, ChevronLeft, ChevronRight, ListOrdered, RefreshCw, Shuffle, Sparkles } from 'lucide-vue-next'
import type { BookCard, ScrollerType } from '@bookorbit/types'
import { isAudioFormat, type BookCard, type CoverAspectRatio, type ScrollerType } from '@bookorbit/types'
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
import BookQuickView from '@/features/book/components/BookQuickView.vue'
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
@@ -40,6 +40,8 @@ const typeIcon = computed(() => {
})
const SKELETONS = Array.from({ length: 8 })
const DEFAULT_COVER_WIDTH_CLASS = 'w-[120px]'
const SQUARE_AUDIOBOOK_COVER_WIDTH_CLASS = 'w-[150px]'
type BookActionType = 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'delete'
@@ -74,6 +76,18 @@ function handleBookAction(book: BookCard, action: BookActionType) {
promptDelete(book.id)
}
}
function isAudiobookCard(book: BookCard): boolean {
return book.files.some((file) => (file.format ? isAudioFormat(file.format) : false))
}
function coverWidthClass(book: BookCard): string {
return isAudiobookCard(book) ? SQUARE_AUDIOBOOK_COVER_WIDTH_CLASS : DEFAULT_COVER_WIDTH_CLASS
}
function coverAspectRatio(book: BookCard): CoverAspectRatio {
return isAudiobookCard(book) ? '1/1' : '2/3'
}
</script>
<template>
@@ -139,15 +153,16 @@ function handleBookAction(book: BookCard, action: BookActionType) {
</div>
<!-- Books row -->
<div v-else ref="scrollEl" class="flex gap-5 overflow-x-auto px-5 pb-5 pt-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div v-else ref="scrollEl" class="flex items-end gap-5 overflow-x-auto px-5 pb-5 pt-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div
v-for="(book, index) in books"
:key="book.id"
class="w-[120px] shrink-0"
class="shrink-0"
:class="coverWidthClass(book)"
style="animation: dashboardFadeUp 0.35s ease both"
:style="{ animationDelay: `${index * 35}ms` }"
>
<BookCoverCard :book="book" @action="handleBookAction(book, $event)" />
<BookCoverCard :book="book" :cover-aspect-ratio="coverAspectRatio(book)" @action="handleBookAction(book, $event)" />
</div>
</div>
</section>
@@ -382,7 +382,7 @@ watch(
</script>
<template>
<main class="flex-1 overflow-y-auto p-4 md:p-6">
<main class="flex-1 overflow-y-auto py-2">
<div class="mb-4">
<button class="flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground" @click="goBack">
<ChevronLeft :size="16" />
@@ -462,7 +462,7 @@ watch(
@click="editSeriesMetadata"
>
<Pencil :size="14" />
{{ openingSeriesEditor ? 'Preparing editor...' : 'Edit Series Metadata' }}
{{ openingSeriesEditor ? 'Preparing editor...' : 'Edit Metadata' }}
</button>
</div>
@@ -538,7 +538,7 @@ watch(
<!-- Books section -->
<section v-if="seriesInfo" class="rounded-lg border border-border/70 bg-card/60 p-3">
<div
class="sticky top-0 z-20 -mx-3 mb-3 border-b border-border/60 bg-card/92 px-3 pb-3 pt-2 backdrop-blur supports-[backdrop-filter]:bg-card/78"
class="sticky top-0 z-20 -mx-3 mb-3 border-b border-border/60 bg-card/92 px-3 pb-3 pt-1 backdrop-blur supports-backdrop-filter:bg-card/78"
>
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<h2 class="text-sm font-semibold text-foreground">Books</h2>