fix(books): add support for collapsed series in list view

Add collapsed series rendering logic to BookListRow to support collapsing series when viewing books in list mode. This shows cover thumbnails, counts, and progress indicators for collapsed series rather than individual volumes.

Closes #586
This commit is contained in:
neon
2026-07-08 16:16:20 +00:00
parent 9f19fa8126
commit e0d4bae9cf
2 changed files with 242 additions and 3 deletions
@@ -9,9 +9,11 @@ import { useRouter } from 'vue-router'
import {
BookOpen,
Check,
ChevronRight,
Eye,
ExternalLink,
FolderPlus,
LibraryBig,
Loader2,
MoreHorizontal,
PanelRight,
@@ -32,6 +34,8 @@ import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
import { RATING_STARS, getRatingStarClass } from '@/features/book/lib/rating-stars'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
const COLLAPSED_SERIES_COVER_LIMIT = 3
const router = useRouter()
const props = defineProps<{
@@ -51,6 +55,28 @@ const { hasPermission } = usePermissions()
const { thumbnailClickAction } = useDisplaySettings()
const showSendDialog = ref(false)
const collapsedSeries = computed(() => props.book.collapsedSeries ?? null)
const isCollapsedSeries = computed(() => collapsedSeries.value !== null)
const canOpenSeries = computed(() => isCollapsedSeries.value && props.book.seriesId != null)
const collapsedSeriesName = computed(() => props.book.seriesName?.trim() || props.book.title?.trim() || 'Series')
const collapsedBookCount = computed(() => collapsedSeries.value?.bookCount ?? 0)
const collapsedReadCount = computed(() => collapsedSeries.value?.readCount ?? 0)
const collapsedCoverIds = computed(
() => collapsedSeries.value?.coverBookIds.filter((bookId) => bookId > 0).slice(0, COLLAPSED_SERIES_COVER_LIMIT) ?? [],
)
const collapsedCoverIsStacked = computed(() => collapsedCoverIds.value.length > 1)
const collapsedCoverContainerClass = computed(() =>
collapsedCoverIsStacked.value ? 'flex h-20 w-20 shrink-0 items-center' : 'flex h-20 w-16 shrink-0 items-center',
)
const collapsedCoverSurfaceClass = computed(() => [
'book-cover-surface--spine-fitted relative shrink-0 overflow-hidden rounded-sm shadow-sm',
collapsedCoverIsStacked.value ? '-ml-8 first:ml-0 w-12 ring-1 ring-background/80' : 'w-16',
])
const collapsedCountLabel = computed(() => `${collapsedBookCount.value} ${collapsedBookCount.value === 1 ? 'book' : 'books'}`)
const collapsedProgressPercent = computed(() => {
if (collapsedBookCount.value <= 0) return 0
return Math.min(100, Math.max(0, (collapsedReadCount.value / collapsedBookCount.value) * 100))
})
const authorLine = computed(() => props.book.authors.join(', ') || null)
const authorQuery = computed(() => props.book.authors[0] ?? null)
const seriesLine = computed(() => {
@@ -137,12 +163,31 @@ function openBookDetails() {
void router.push({ name: 'book-detail', params: { bookId: props.book.id } })
}
function openSeriesDetails() {
if (props.book.seriesId == null) return
void router.push({ name: 'series-detail', params: { seriesId: props.book.seriesId } })
}
function collapsedCoverVersion(bookId: number): string | null | undefined {
if (bookId === props.book.id) return props.book.updatedAt ?? props.book.addedAt
return collapsedSeries.value?.coverUpdatedAtByBookId?.[bookId]
}
function collapsedCoverSrc(bookId: number): string {
return coverUrl(bookId, 'thumbnail', collapsedCoverVersion(bookId))
}
function handleRowClick(event: MouseEvent) {
if (props.selectionMode) {
emit('select', event)
return
}
if (isCollapsedSeries.value) {
openSeriesDetails()
return
}
if (thumbnailClickAction.value === 'details') {
openBookDetails()
return
@@ -154,6 +199,89 @@ function handleRowClick(event: MouseEvent) {
<template>
<div
v-if="isCollapsedSeries"
data-testid="collapsed-series-list-row"
class="flex items-center gap-3 py-3 px-2 rounded-md transition-colors"
:class="[
selectionMode ? 'cursor-pointer select-none' : canOpenSeries ? 'cursor-pointer hover:bg-muted/50' : 'cursor-default',
selected ? 'bg-primary/8 ring-1 ring-primary/30' : '',
]"
@click="handleRowClick"
>
<div
v-if="selectionMode"
class="h-5 w-5 rounded shrink-0 flex items-center justify-center transition-colors"
:class="selected ? 'bg-primary' : 'border border-border bg-background'"
>
<Check v-if="selected" class="text-primary-foreground" :size="12" />
</div>
<div :class="collapsedCoverContainerClass">
<template v-if="collapsedCoverIds.length > 0">
<BookCoverSurface
v-for="bookId in collapsedCoverIds"
:key="bookId"
data-testid="collapsed-series-cover"
size="mini"
:class="collapsedCoverSurfaceClass"
:disable-spine="isAudiobook"
:is-comic="isComic"
:style="{ aspectRatio: coverAspectRatio }"
>
<img :src="collapsedCoverSrc(bookId)" class="absolute inset-0 h-full w-full object-cover" loading="lazy" decoding="async" alt="" />
</BookCoverSurface>
</template>
<BookCoverSurface
v-else
data-testid="collapsed-series-cover-fallback"
size="mini"
:class="collapsedCoverSurfaceClass"
:disable-spine="isAudiobook"
:is-comic="isComic"
:style="{ aspectRatio: coverAspectRatio }"
>
<BookCoverArtwork
:src="null"
:has-cover="false"
:title="collapsedSeriesName"
:author-line="authorLine"
:is-audio="isAudiobook"
:seed="`series-${book.seriesId ?? book.id}`"
alt=""
:spine="!isAudiobook"
:is-comic="isComic"
/>
</BookCoverSurface>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex min-w-0 items-center gap-2">
<span class="truncate text-sm font-semibold leading-snug text-foreground">{{ collapsedSeriesName }}</span>
<span class="hidden shrink-0 rounded-sm bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary sm:inline-flex"> Series </span>
</div>
<button v-if="authorLine" class="w-fit max-w-full truncate text-xs text-muted-foreground hover:underline" @click.stop="openAuthorBrowse">
{{ authorLine }}
</button>
<div class="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<LibraryBig class="size-3.5 shrink-0 text-muted-foreground/70" />
<span class="truncate">{{ collapsedCountLabel }}</span>
<span v-if="collapsedReadCount > 0" class="shrink-0">&middot; {{ collapsedReadCount }} read</span>
</div>
<div
v-if="collapsedReadCount > 0 && collapsedBookCount > 0"
data-testid="collapsed-series-progress"
class="mt-1 h-1 w-32 max-w-full overflow-hidden rounded-full bg-muted"
>
<div class="h-full rounded-full bg-primary/60 transition-all" :style="{ width: `${collapsedProgressPercent}%` }" />
</div>
</div>
<div v-if="!selectionMode" class="flex shrink-0 items-center gap-2">
<ChevronRight class="size-4 text-muted-foreground/60 transition-colors" />
</div>
</div>
<div
v-else
class="flex items-center gap-3 py-3 px-2 rounded-md transition-colors cursor-pointer"
:class="[
selectionMode ? 'cursor-pointer select-none' : '',
@@ -11,7 +11,7 @@ vi.mock('vue-router', async (importOriginal) => {
return { ...actual, useRouter: () => ({ push: routerPushMock }) }
})
vi.mock('@/features/book/composables/useCoverVersions', () => ({
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn<(...args: unknown[]) => void>() }),
useCoverVersions: () => ({ coverUrl: (bookId: number) => `/cover-${bookId}.jpg`, bumpVersion: vi.fn<(...args: unknown[]) => void>() }),
}))
vi.mock('@/features/book/lib/book-cover', () => ({
bookCoverStyle: () => ({ background: 'oklch(0.22 0.07 200)', color: 'oklch(0.92 0.03 200)' }),
@@ -107,7 +107,33 @@ const presentBook: BookCard = {
customMetadata: [],
}
describe('BookListRow — missing state', () => {
const collapsedSeriesBook: BookCard = {
...presentBook,
id: 20,
title: 'Representative Volume',
authors: ['Series Author'],
seriesId: 99,
seriesName: 'The Trilogy',
seriesIndex: 1,
readingProgress: 33,
collapsedSeries: {
bookCount: 4,
readCount: 1,
coverBookIds: [20, 21, 22, 23],
coverUpdatedAtByBookId: {
20: '2026-01-01T00:00:00.000Z',
21: '2026-01-02T00:00:00.000Z',
22: '2026-01-03T00:00:00.000Z',
23: '2026-01-04T00:00:00.000Z',
},
seriesLatestAddedAt: '2026-01-04T00:00:00.000Z',
firstVolumeBookId: 20,
latestVolumeBookId: 23,
firstUnreadBookId: 21,
},
}
describe('BookListRow - missing state', () => {
it('applies grayscale and opacity-60 to the root row', () => {
const wrapper = mount(BookListRow, { props: { book: missingBook }, global: globalStubs })
const root = wrapper.find('.flex.items-center')
@@ -138,7 +164,7 @@ describe('BookListRow — missing state', () => {
})
})
describe('BookListRow — present state', () => {
describe('BookListRow - present state', () => {
it('emits quick-view on row click by default', async () => {
const wrapper = mount(BookListRow, { props: { book: presentBook }, global: globalStubs })
@@ -228,3 +254,88 @@ describe('BookListRow — present state', () => {
expect(cover.attributes('data-cover-spine')).toBe('off')
})
})
describe('BookListRow collapsed series', () => {
it('renders the series summary instead of the representative book title', () => {
const wrapper = mount(BookListRow, { props: { book: collapsedSeriesBook }, global: globalStubs })
const row = wrapper.get('[data-testid="collapsed-series-list-row"]')
expect(row.text()).toContain('The Trilogy')
expect(row.text()).toContain('4 books')
expect(row.text()).toContain('1 read')
expect(row.text()).toContain('Series Author')
expect(row.text()).not.toContain('Representative Volume')
})
it('caps collapsed series cover thumbnails to a bounded set', () => {
const wrapper = mount(BookListRow, { props: { book: collapsedSeriesBook }, global: globalStubs })
const covers = wrapper.findAll('[data-testid="collapsed-series-cover"]')
expect(covers).toHaveLength(3)
expect(wrapper.findAll('img').map((img) => img.attributes('src'))).toEqual(['/cover-20.jpg', '/cover-21.jpg', '/cover-22.jpg'])
})
it('opens the series detail route on row click', async () => {
thumbnailClickAction.value = 'details'
const wrapper = mount(BookListRow, { props: { book: collapsedSeriesBook }, global: globalStubs })
await wrapper.get('[data-testid="collapsed-series-list-row"]').trigger('click')
expect(routerPushMock).toHaveBeenCalledWith({ name: 'series-detail', params: { seriesId: 99 } })
expect(routerPushMock).not.toHaveBeenCalledWith({ name: 'book-detail', params: { bookId: 20 } })
expect(wrapper.emitted('action')).toBeUndefined()
})
it('selects the collapsed row instead of navigating in selection mode', async () => {
const wrapper = mount(BookListRow, {
props: { book: collapsedSeriesBook, selectionMode: true },
global: globalStubs,
})
await wrapper.get('[data-testid="collapsed-series-list-row"]').trigger('click')
expect(wrapper.emitted('select')).toHaveLength(1)
expect(routerPushMock).not.toHaveBeenCalled()
})
it('renders a fallback cover when collapsed cover ids are unavailable', () => {
const wrapper = mount(BookListRow, {
props: {
book: {
...collapsedSeriesBook,
collapsedSeries: {
...collapsedSeriesBook.collapsedSeries!,
coverBookIds: [],
},
},
},
global: globalStubs,
})
expect(wrapper.find('[data-testid="collapsed-series-cover-fallback"]').exists()).toBe(true)
expect(wrapper.findAll('[data-testid="collapsed-series-cover"]')).toHaveLength(0)
})
it('uses the normal list cover width and no empty progress bar for a one-book series', () => {
const wrapper = mount(BookListRow, {
props: {
book: {
...collapsedSeriesBook,
collapsedSeries: {
...collapsedSeriesBook.collapsedSeries!,
bookCount: 1,
readCount: 0,
coverBookIds: [20],
},
},
},
global: globalStubs,
})
const cover = wrapper.get('[data-testid="collapsed-series-cover"]')
expect(cover.classes()).toContain('w-16')
expect(cover.classes()).not.toContain('w-12')
expect(wrapper.get('[data-testid="collapsed-series-list-row"]').text()).toContain('1 book')
expect(wrapper.find('[data-testid="collapsed-series-progress"]').exists()).toBe(false)
})
})