feat: overhaul series detail view and improve card interactions

This commit is contained in:
neon
2026-04-16 17:06:53 -06:00
parent 97207cd66f
commit 2ac7bf822d
22 changed files with 1056 additions and 168 deletions
+15 -8
View File
@@ -24,6 +24,8 @@ withDefaults(
gridGap: number
viewMode: 'grid' | 'list'
selectionMode?: boolean
showSelection?: boolean
showViewModeToggle?: boolean
coverShape?: 'square' | 'circle'
coverSizeMin?: number
coverSizeMax?: number
@@ -41,6 +43,8 @@ withDefaults(
gridGapMin: 4,
gridGapMax: 40,
gridGapStep: 4,
showSelection: true,
showViewModeToggle: true,
},
)
@@ -144,6 +148,7 @@ function handleSearchInput(event: Event) {
<!-- Select mode toggle -->
<Button
v-if="showSelection"
variant="ghost"
size="sm"
class="hidden md:flex h-8 gap-1.5 text-[11px] font-bold uppercase tracking-tight px-2.5 rounded-lg transition-all"
@@ -159,10 +164,10 @@ function handleSearchInput(event: Event) {
Select
</Button>
<div class="hidden md:block w-px h-3.5 bg-border/40 mx-1.5" />
<div v-if="showSelection" class="hidden md:block w-px h-3.5 bg-border/40 mx-1.5" />
<!-- Desktop: view mode toggle -->
<div class="hidden md:flex items-center gap-0.5">
<div v-if="showViewModeToggle" class="hidden md:flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
@@ -286,12 +291,14 @@ function handleSearchInput(event: Event) {
Search
</DropdownMenuItem>
</template>
<DropdownMenuSeparator />
<DropdownMenuItem @click="emit('toggle-selection')">
<CheckSquare v-if="selectionMode" :size="14" class="mr-2" />
<Square v-else :size="14" class="mr-2" />
{{ selectionMode ? 'Exit Select' : 'Select' }}
</DropdownMenuItem>
<template v-if="showSelection">
<DropdownMenuSeparator />
<DropdownMenuItem @click="emit('toggle-selection')">
<CheckSquare v-if="selectionMode" :size="14" class="mr-2" />
<Square v-else :size="14" class="mr-2" />
{{ selectionMode ? 'Exit Select' : 'Select' }}
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import type { BookCard } from '@projectx/types'
import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
@@ -9,6 +9,7 @@ const props = defineProps<{
book: BookCard
}>()
const route = useRoute()
const router = useRouter()
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
@@ -27,7 +28,7 @@ function handleCoverError(bookId: number) {
}
function handleClick() {
router.push(`/series/${encodeURIComponent(seriesName.value)}`)
router.push({ name: 'series-detail', params: { seriesName: seriesName.value }, query: { from: route.fullPath } })
}
function thumbnailUrl(bookId: number): string {
@@ -7,7 +7,11 @@ const mockRouterPush = vi.fn<(...args: unknown[]) => unknown>()
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-router')>()
return { ...actual, useRouter: () => ({ push: mockRouterPush }) }
return {
...actual,
useRouter: () => ({ push: mockRouterPush }),
useRoute: () => ({ fullPath: '/test-path' }),
}
})
vi.mock('../BookCoverPlaceholder.vue', () => ({
@@ -129,7 +133,11 @@ describe('CollapsedSeriesCard', () => {
await wrapper.trigger('click')
expect(mockRouterPush).toHaveBeenCalledWith(`/series/${encodeURIComponent('The Arc')}`)
expect(mockRouterPush).toHaveBeenCalledWith({
name: 'series-detail',
params: { seriesName: 'The Arc' },
query: { from: '/test-path' },
})
})
it('encodes series name with special characters in URL', async () => {
@@ -139,7 +147,11 @@ describe('CollapsedSeriesCard', () => {
await wrapper.trigger('click')
expect(mockRouterPush).toHaveBeenCalledWith(`/series/${encodeURIComponent('The Wheel & Time')}`)
expect(mockRouterPush).toHaveBeenCalledWith({
name: 'series-detail',
params: { seriesName: 'The Wheel & Time' },
query: { from: '/test-path' },
})
})
it('shows placeholder instead of img after @error fires', async () => {
@@ -15,23 +15,42 @@ export function useSeriesCollapsePreference() {
return resolveCollapsePreference(prefs.value, ctx)
}
async function setPreference(ctx: { libraryId?: number; collectionId?: number } | 'global', value: boolean): Promise<void> {
let pendingUpdate: Promise<void> = Promise.resolve()
async function setPreference(ctx: { libraryId?: number; collectionId?: number } | 'global', value: boolean | null): Promise<void> {
const op = () => doSetPreference(ctx, value)
pendingUpdate = pendingUpdate.then(op, op)
return pendingUpdate
}
async function doSetPreference(ctx: { libraryId?: number; collectionId?: number } | 'global', value: boolean | null): Promise<void> {
let body: Record<string, unknown>
if (ctx === 'global') {
body = { global: value }
body = { global: value === null ? false : value }
} else if (ctx.collectionId !== undefined) {
body = { collections: { [String(ctx.collectionId)]: value } }
} else if (ctx.libraryId !== undefined) {
body = { libraries: { [String(ctx.libraryId)]: value } }
} else {
body = { global: value }
body = { global: value === null ? false : value }
}
const current = prefs.value ?? { global: false, libraries: {}, collections: {} }
const nextLibraries = { ...current.libraries, ...(body.libraries as Record<string, boolean | null>) }
const nextCollections = { ...current.collections, ...(body.collections as Record<string, boolean | null>) }
// Remove null entries (deletion)
for (const [k, v] of Object.entries(nextLibraries)) {
if (v === null) delete nextLibraries[k]
}
for (const [k, v] of Object.entries(nextCollections)) {
if (v === null) delete nextCollections[k]
}
const updated: SeriesCollapsePreferences = {
global: body.global !== undefined ? (body.global as boolean) : (current.global ?? false),
libraries: { ...current.libraries, ...(body.libraries as Record<string, boolean>) },
collections: { ...current.collections, ...(body.collections as Record<string, boolean>) },
libraries: nextLibraries as Record<string, boolean>,
collections: nextCollections as Record<string, boolean>,
}
const previous = user.value?.settings ? { ...user.value.settings } : undefined
@@ -4,21 +4,10 @@ import type { SeriesSummary } from '@projectx/types'
import { BookCopy } from 'lucide-vue-next'
import { bookCoverStyle } from '@/features/book/lib/book-cover'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import { useCoverStack } from '../composables/useCoverStack'
import SeriesCompletionBar from './SeriesCompletionBar.vue'
const MAX_VISIBLE = 7
const COVER_WIDTH_PCT = 43
const COVER_VERTICAL_MARGIN_PCT = 5.5
const COVER_GROUP_WIDTH_BY_COUNT = {
1: 43,
2: 62,
3: 72,
4: 80,
5: 86,
6: 90,
7: 92,
} satisfies Record<number, number>
const DEFAULT_COVER_GROUP_WIDTH = COVER_GROUP_WIDTH_BY_COUNT[7]
const HOVERED_COVER_Z_INDEX = 120
const props = defineProps<{
series: SeriesSummary
@@ -33,7 +22,9 @@ const fallbackStyle = computed(() => bookCoverStyle(props.series.name))
const initial = computed(() => props.series.name.trim().charAt(0).toUpperCase() || '?')
const failedCovers = ref(new Set<number>())
const visibleCovers = computed(() => props.series.coverBookIds.filter((id) => !failedCovers.value.has(id)).slice(0, MAX_VISIBLE))
const hoveredCoverId = ref<number | null>(null)
const activeCoverIds = computed(() => props.series.coverBookIds.filter((id) => !failedCovers.value.has(id)))
const { visibleCovers, baseStyles } = useCoverStack(activeCoverIds)
const authorsLine = computed(() => {
const a = props.series.authors
@@ -50,26 +41,47 @@ function handleClick() {
emit('open', props.series.name)
}
function handleCoverHover(bookId: number) {
hoveredCoverId.value = bookId
}
function clearHoveredCover() {
hoveredCoverId.value = null
}
const coverStyles = computed(() => {
const total = visibleCovers.value.length
if (total === 0) return []
const groupWidth = COVER_GROUP_WIDTH_BY_COUNT[total as keyof typeof COVER_GROUP_WIDTH_BY_COUNT] ?? DEFAULT_COVER_GROUP_WIDTH
const step = total > 1 ? (groupWidth - COVER_WIDTH_PCT) / (total - 1) : 0
const startLeft = (100 - groupWidth) / 2
const center = (total - 1) / 2
const hoveredId = hoveredCoverId.value
const hoveredIndex = hoveredId == null ? -1 : visibleCovers.value.findIndex((id) => id === hoveredId)
const hasActiveHover = hoveredIndex >= 0
return visibleCovers.value.map((_, index) => ({
left: `${startLeft + step * index}%`,
bottom: `${COVER_VERTICAL_MARGIN_PCT}%`,
width: `${COVER_WIDTH_PCT}%`,
aspectRatio: '2 / 3',
zIndex: Math.round(total - Math.abs(index - center)) + 1,
boxShadow:
index === Math.round(center)
? '0 18px 34px -20px rgba(15, 23, 42, 0.72), 0 8px 14px -12px rgba(15, 23, 42, 0.28)'
: '0 14px 26px -20px rgba(15, 23, 42, 0.58), 0 6px 12px -12px rgba(15, 23, 42, 0.22)',
}))
return baseStyles.value.map((base, index) => {
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)
const loweredOffset = Math.min(4, distanceFromHovered * 1.4)
return {
...base,
zIndex: hasActiveHover ? (isHovered ? HOVERED_COVER_Z_INDEX : base.zIndex) : base.zIndex,
transform: hasActiveHover
? isHovered
? 'translateY(-8px) scale(1.05)'
: `translateY(${loweredOffset}px) scale(${loweredScale})`
: 'translateY(0) scale(1)',
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
? isHovered
? '0 0 0 2px hsl(var(--primary) / 0.45), 0 24px 36px -20px rgba(15, 23, 42, 0.72), 0 10px 18px -14px rgba(15, 23, 42, 0.3)'
: '0 10px 18px -16px rgba(15, 23, 42, 0.4)'
: base.boxShadow,
transition: 'transform 190ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 180ms ease, filter 180ms ease, box-shadow 220ms ease',
willChange: 'transform, opacity, filter',
}
})
})
</script>
@@ -81,6 +93,7 @@ const coverStyles = computed(() => {
<div
class="relative isolate overflow-hidden border-b border-border/60 bg-linear-to-b from-white/[0.035] via-background/5 to-black/[0.07]"
style="aspect-ratio: 11 / 8"
@mouseleave="clearHoveredCover"
>
<div class="absolute inset-x-[21%] bottom-[5%] h-4 rounded-full bg-black/10 blur-2xl opacity-38" />
@@ -93,7 +106,13 @@ const coverStyles = computed(() => {
</span>
</div>
<div v-for="(bookId, i) in visibleCovers" :key="bookId" class="absolute overflow-hidden rounded-lg" :style="coverStyles[i] ?? {}">
<div
v-for="(bookId, i) in visibleCovers"
:key="bookId"
class="absolute overflow-hidden rounded-lg"
: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)" />
</div>
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest'
import { ref } from 'vue'
import { computeBasePositions, useCoverStack } from './useCoverStack'
describe('computeBasePositions', () => {
it('returns empty array for zero covers', () => {
expect(computeBasePositions(0)).toEqual([])
})
it('returns single centered cover for count 1', () => {
const result = computeBasePositions(1)
expect(result).toHaveLength(1)
expect(result[0]!.width).toBe('43%')
expect(result[0]!.aspectRatio).toBe('2 / 3')
expect(result[0]!.left).toBe('28.5%')
})
it('returns correct number of positions', () => {
for (const count of [2, 3, 4, 5, 6, 7]) {
const result = computeBasePositions(count)
expect(result).toHaveLength(count)
}
})
it('assigns higher zIndex to center covers', () => {
const result = computeBasePositions(5)
const centerIndex = 2
expect(result[centerIndex]!.zIndex).toBeGreaterThan(result[0]!.zIndex)
expect(result[centerIndex]!.zIndex).toBeGreaterThan(result[4]!.zIndex)
})
it('uses default group width for count > 7', () => {
const result = computeBasePositions(8)
expect(result).toHaveLength(8)
// Should not throw, uses fallback
})
it('produces consistent output for same input', () => {
const a = computeBasePositions(4)
const b = computeBasePositions(4)
expect(a).toEqual(b)
})
})
describe('useCoverStack', () => {
it('limits visible covers to MAX_VISIBLE (7)', () => {
const ids = ref([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
const { visibleCovers } = useCoverStack(ids)
expect(visibleCovers.value).toHaveLength(7)
expect(visibleCovers.value).toEqual([1, 2, 3, 4, 5, 6, 7])
})
it('returns all covers when fewer than MAX_VISIBLE', () => {
const ids = ref([10, 20, 30])
const { visibleCovers } = useCoverStack(ids)
expect(visibleCovers.value).toEqual([10, 20, 30])
})
it('returns empty for empty input', () => {
const ids = ref<number[]>([])
const { visibleCovers, baseStyles } = useCoverStack(ids)
expect(visibleCovers.value).toEqual([])
expect(baseStyles.value).toEqual([])
})
it('base styles count matches visible covers count', () => {
const ids = ref([1, 2, 3, 4, 5])
const { visibleCovers, baseStyles } = useCoverStack(ids)
expect(baseStyles.value).toHaveLength(visibleCovers.value.length)
})
it('reacts to ref changes', () => {
const ids = ref([1, 2])
const { visibleCovers, baseStyles } = useCoverStack(ids)
expect(visibleCovers.value).toHaveLength(2)
expect(baseStyles.value).toHaveLength(2)
ids.value = [1, 2, 3, 4]
expect(visibleCovers.value).toHaveLength(4)
expect(baseStyles.value).toHaveLength(4)
})
})
@@ -0,0 +1,53 @@
import { computed, type Ref } from 'vue'
export const MAX_VISIBLE = 7
const COVER_WIDTH_PCT = 43
const VERTICAL_MARGIN_PCT = 5.5
const GROUP_WIDTH_BY_COUNT: Record<number, number> = {
1: 43,
2: 62,
3: 72,
4: 80,
5: 86,
6: 90,
7: 92,
}
const DEFAULT_GROUP_WIDTH = 92
export type CoverStackBaseStyle = {
left: string
bottom: string
width: string
aspectRatio: string
zIndex: number
boxShadow: string
}
export function computeBasePositions(count: number): CoverStackBaseStyle[] {
if (count === 0) return []
const groupWidth = GROUP_WIDTH_BY_COUNT[count] ?? DEFAULT_GROUP_WIDTH
const step = count > 1 ? (groupWidth - COVER_WIDTH_PCT) / (count - 1) : 0
const startLeft = (100 - groupWidth) / 2
const center = (count - 1) / 2
return Array.from({ length: count }, (_, index) => ({
left: `${startLeft + step * index}%`,
bottom: `${VERTICAL_MARGIN_PCT}%`,
width: `${COVER_WIDTH_PCT}%`,
aspectRatio: '2 / 3',
zIndex: Math.round(count - Math.abs(index - center)) + 1,
boxShadow:
index === Math.round(center)
? '0 18px 34px -20px rgba(15, 23, 42, 0.72), 0 8px 14px -12px rgba(15, 23, 42, 0.28)'
: '0 14px 26px -20px rgba(15, 23, 42, 0.58), 0 6px 12px -12px rgba(15, 23, 42, 0.22)',
}))
}
export function useCoverStack(coverIds: Ref<number[]>) {
const visibleCovers = computed(() => coverIds.value.slice(0, MAX_VISIBLE))
const baseStyles = computed(() => computeBasePositions(visibleCovers.value.length))
return { visibleCovers, baseStyles, MAX_VISIBLE }
}
@@ -161,6 +161,43 @@ describe('useSeriesDetail', () => {
await pendingLoad
})
it('keeps previous seriesInfo and total during preserve reset', async () => {
const name = ref('Test')
mockFetchSeriesBooks.mockResolvedValue({
items: [{ id: 1 } as BookCard],
total: 5,
page: 0,
size: 50,
seriesInfo: { name: 'Test', bookCount: 5, readCount: 2, authors: ['Author'], possibleGaps: [] },
})
const { seriesInfo, total, load } = useSeriesDetail(name)
await load(true)
expect(seriesInfo.value?.name).toBe('Test')
expect(total.value).toBe(5)
let resolveLoad!: (v: SeriesBooksPage) => void
mockFetchSeriesBooks.mockImplementation(
() =>
new Promise<SeriesBooksPage>((resolve) => {
resolveLoad = resolve
}),
)
const pendingLoad = load({ reset: true, keepPreviousData: true })
expect(seriesInfo.value?.name).toBe('Test')
expect(total.value).toBe(5)
resolveLoad({
items: [],
total: 0,
page: 0,
size: 50,
seriesInfo: { name: 'New', bookCount: 0, readCount: 0, authors: [], possibleGaps: [] },
})
await pendingLoad
})
it('passes sort/order/libraryId to fetch', async () => {
const name = ref('Test')
mockFetchSeriesBooks.mockResolvedValue({
@@ -186,4 +223,86 @@ describe('useSeriesDetail', () => {
libraryId: 3,
})
})
it('discards stale response when a reset supersedes an in-flight load', async () => {
const name = ref('Test')
let resolveFirst!: (v: SeriesBooksPage) => void
let resolveSecond!: (v: SeriesBooksPage) => void
mockFetchSeriesBooks
.mockImplementationOnce(
() =>
new Promise<SeriesBooksPage>((resolve) => {
resolveFirst = resolve
}),
)
.mockImplementationOnce(
() =>
new Promise<SeriesBooksPage>((resolve) => {
resolveSecond = resolve
}),
)
const { items, seriesInfo, load } = useSeriesDetail(name)
const firstLoad = load(true)
const resetLoad = load(true)
resolveSecond!({
items: [{ id: 2 } as BookCard],
total: 1,
page: 0,
size: 50,
seriesInfo: { name: 'Fresh', bookCount: 1, readCount: 0, authors: [], possibleGaps: [] },
})
await resetLoad
resolveFirst!({
items: [{ id: 1 } as BookCard],
total: 1,
page: 0,
size: 50,
seriesInfo: { name: 'Stale', bookCount: 999, readCount: 0, authors: [], possibleGaps: [] },
})
await firstLoad
expect(items.value).toHaveLength(1)
expect(items.value[0]!.id).toBe(2)
expect(seriesInfo.value?.name).toBe('Fresh')
})
it('discards stale error when superseded by newer request', async () => {
const name = ref('Test')
let resolveSecond!: (v: SeriesBooksPage) => void
mockFetchSeriesBooks
.mockImplementationOnce(
() =>
new Promise<SeriesBooksPage>((_, reject) => {
setTimeout(() => reject(new Error('Stale error')), 10)
}),
)
.mockImplementationOnce(
() =>
new Promise<SeriesBooksPage>((resolve) => {
resolveSecond = resolve
}),
)
const { error, items, load } = useSeriesDetail(name)
const firstLoad = load(true)
const resetLoad = load(true)
resolveSecond!({
items: [{ id: 1 } as BookCard],
total: 1,
page: 0,
size: 50,
seriesInfo: { name: 'OK', bookCount: 1, readCount: 0, authors: [], possibleGaps: [] },
})
await resetLoad
await firstLoad
expect(error.value).toBeNull()
expect(items.value[0]!.id).toBe(1)
})
})
@@ -5,6 +5,10 @@ import { fetchSeriesBooks } from '../api/series'
import type { SeriesBookSort, SortDirection } from '../types/series'
const PAGE_SIZE = 50
type LoadOptions = {
reset?: boolean
keepPreviousData?: boolean
}
export function useSeriesDetail(seriesName: Ref<string>) {
const seriesInfo = ref<SeriesDetail | null>(null)
@@ -21,43 +25,54 @@ export function useSeriesDetail(seriesName: Ref<string>) {
const page = ref(0)
const hasMore = computed(() => items.value.length < total.value)
async function load(reset = false): Promise<void> {
if (!seriesName.value) return
if (loading.value) return
if (!reset && !hasMore.value) return
let requestToken = 0
async function load(input: boolean | LoadOptions = false): Promise<void> {
const reset = typeof input === 'boolean' ? input : Boolean(input.reset)
const keepPreviousData = typeof input === 'boolean' ? false : Boolean(input.keepPreviousData)
if (!seriesName.value) return
if (!reset && loading.value) return
if (!reset && !hasMore.value) return
const requestPage = reset ? 0 : page.value
const token = ++requestToken
loading.value = true
error.value = null
if (reset) {
page.value = 0
items.value = []
seriesInfo.value = null
total.value = 0
notFound.value = false
page.value = 0
if (!keepPreviousData) {
items.value = []
seriesInfo.value = null
total.value = 0
}
}
try {
const data = await fetchSeriesBooks(seriesName.value, {
page: page.value,
page: requestPage,
size: PAGE_SIZE,
sort: sort.value,
order: order.value,
libraryId: libraryId.value,
})
if (token !== requestToken) return
items.value = reset ? data.items : [...items.value, ...data.items]
total.value = data.total
seriesInfo.value = data.seriesInfo
page.value += 1
page.value = requestPage + 1
} catch (err) {
if (token !== requestToken) return
if (err instanceof Error && err.message.includes('404')) {
notFound.value = true
} else {
error.value = err instanceof Error ? err.message : 'Failed to load series'
}
} finally {
loading.value = false
if (token === requestToken) loading.value = false
}
}
@@ -112,7 +112,18 @@ describe('useSeriesList', () => {
expect(error.value).toBe('Failed to load series')
})
it('does not load when already loading', async () => {
it('does not load when already loading (non-reset)', async () => {
// First load some data so hasMore becomes true
mockFetchSeries.mockResolvedValueOnce({
items: [{ name: 'Series A', bookCount: 1, readCount: 0, authors: [], coverBookIds: [], lastAddedAt: null }],
total: 3,
page: 0,
size: 50,
})
const { loading, load } = useSeriesList()
await load(true)
let resolveFn!: (v: SeriesPage) => void
mockFetchSeries.mockImplementation(
() =>
@@ -121,18 +132,96 @@ describe('useSeriesList', () => {
}),
)
const { loading, load } = useSeriesList()
const firstLoad = load(true)
const firstLoad = load()
expect(loading.value).toBe(true)
const secondLoad = load(true)
const secondLoad = load()
resolveFn!({ items: [], total: 0, page: 0, size: 50 })
resolveFn!({ items: [], total: 3, page: 1, size: 50 })
await firstLoad
await secondLoad
expect(mockFetchSeries).toHaveBeenCalledTimes(1)
// Only the initial load + one additional load = 2 total calls
expect(mockFetchSeries).toHaveBeenCalledTimes(2)
})
it('allows reset load to supersede an in-flight non-reset load', async () => {
let resolveFirst!: (v: SeriesPage) => void
let resolveSecond!: (v: SeriesPage) => void
mockFetchSeries
.mockImplementationOnce(
() =>
new Promise<SeriesPage>((resolve) => {
resolveFirst = resolve
}),
)
.mockImplementationOnce(
() =>
new Promise<SeriesPage>((resolve) => {
resolveSecond = resolve
}),
)
const { items, load } = useSeriesList()
const firstLoad = load(true)
const resetLoad = load(true)
// Resolve second (reset) first
resolveSecond!({
items: [{ name: 'Fresh', bookCount: 1, readCount: 0, authors: [], coverBookIds: [], lastAddedAt: null }],
total: 1,
page: 0,
size: 50,
})
await resetLoad
// Resolve first (stale)
resolveFirst!({
items: [{ name: 'Stale', bookCount: 1, readCount: 0, authors: [], coverBookIds: [], lastAddedAt: null }],
total: 1,
page: 0,
size: 50,
})
await firstLoad
// Stale response should be discarded
expect(items.value).toHaveLength(1)
expect(items.value[0]!.name).toBe('Fresh')
})
it('discards stale error when superseded by newer request', async () => {
let resolveSecond!: (v: SeriesPage) => void
mockFetchSeries
.mockImplementationOnce(
() =>
new Promise<SeriesPage>((_, reject) => {
setTimeout(() => reject(new Error('Stale error')), 10)
}),
)
.mockImplementationOnce(
() =>
new Promise<SeriesPage>((resolve) => {
resolveSecond = resolve
}),
)
const { error, items, load } = useSeriesList()
const firstLoad = load(true)
const resetLoad = load(true)
resolveSecond!({
items: [{ name: 'OK', bookCount: 1, readCount: 0, authors: [], coverBookIds: [], lastAddedAt: null }],
total: 1,
page: 0,
size: 50,
})
await resetLoad
await firstLoad
expect(error.value).toBeNull()
expect(items.value[0]!.name).toBe('OK')
})
it('does not load more when no more items', async () => {
@@ -22,13 +22,17 @@ export function useSeriesList() {
const page = ref(0)
const hasMore = computed(() => items.value.length < total.value)
let requestToken = 0
async function load(reset = false): Promise<void> {
if (loading.value) return
if (!reset && loading.value) return
if (!reset && !hasMore.value) return
const token = ++requestToken
loading.value = true
error.value = null
const requestPage = reset ? 0 : page.value
if (reset) {
page.value = 0
items.value = []
@@ -37,7 +41,7 @@ export function useSeriesList() {
try {
const data = await fetchSeries({
q: q.value.trim() || undefined,
page: page.value,
page: requestPage,
size: PAGE_SIZE,
sort: sort.value,
order: order.value,
@@ -46,13 +50,16 @@ export function useSeriesList() {
author: author.value?.trim() || undefined,
})
if (token !== requestToken) return
items.value = reset ? data.items : [...items.value, ...data.items]
total.value = data.total
page.value += 1
page.value = requestPage + 1
} catch (err) {
if (token !== requestToken) return
error.value = err instanceof Error ? err.message : 'Failed to load series'
} finally {
loading.value = false
if (token === requestToken) loading.value = false
}
}
@@ -4,23 +4,30 @@ import { useRoute, useRouter } from 'vue-router'
import { ChevronLeft, Pencil } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import type { BookCard } from '@projectx/types'
import type { BookCard, BookDetail } from '@projectx/types'
import VirtualBookGrid from '@/features/book/components/VirtualBookGrid.vue'
import BookListRow from '@/features/book/components/BookListRow.vue'
import { bookCoverStyle } from '@/features/book/lib/book-cover'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useBookNavigation } from '@/features/book/composables/useBookNavigation'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { usePageTitle } from '@/composables/usePageTitle'
import { useSafeHtml } from '@/features/book/composables/useSafeHtml'
import { api } from '@/lib/api'
import EntityNotFound from '@/components/EntityNotFound.vue'
import SeriesCompletionBar from '../components/SeriesCompletionBar.vue'
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'
const route = useRoute()
const router = useRouter()
const { hasPermission } = usePermissions()
const { setBookContext } = useBookNavigation()
const { coverUrl } = useCoverVersions()
const { portraitCoverSize, gridGap, viewMode } = useDisplaySettings()
const { libraries, fetchLibraries } = useLibraries()
@@ -52,9 +59,54 @@ usePageTitle(pageTitle)
const sentinel = ref<HTMLElement | null>(null)
const openingSeriesEditor = ref(false)
const loadingLeadBook = ref(false)
const leadBookError = ref<string | null>(null)
const leadDescriptionExpanded = ref(false)
const leadGenresExpanded = ref(false)
const leadCoverBookIds = ref<number[]>([])
const failedLeadCovers = ref(new Set<number>())
const leadBook = ref<{
id: number
title: string | null
seriesIndex: number | null
publishedYear: number | null
language: string | null
rating: number | null
genres: string[]
description: string | null
} | null>(null)
let observer: IntersectionObserver | null = null
let leadBookRequestToken = 0
const canEditMetadata = computed(() => hasPermission('library_edit_metadata'))
const safeLeadDescription = useSafeHtml(() => leadBook.value?.description)
const visibleSeriesAuthors = computed(() => (seriesInfo.value?.authors ?? []).slice(0, 5))
const hiddenSeriesAuthorsCount = computed(() => Math.max(0, (seriesInfo.value?.authors.length ?? 0) - visibleSeriesAuthors.value.length))
const leadFallbackStyle = computed(() => {
const name = seriesInfo.value?.name ?? seriesName.value
return bookCoverStyle(name || 'Series')
})
const leadInitial = computed(() => (seriesInfo.value?.name ?? seriesName.value).trim().charAt(0).toUpperCase() || '?')
const activeCoverIds = computed(() => leadCoverBookIds.value.filter((id) => !failedLeadCovers.value.has(id)))
const { visibleCovers: visibleLeadCoverBookIds, baseStyles: leadCoverStyles } = useCoverStack(activeCoverIds)
const displayedLeadGenres = computed(() => {
const genres = leadBook.value?.genres ?? []
if (leadGenresExpanded.value || genres.length <= 8) return genres
return genres.slice(0, 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 leadMetaItems = computed(() => {
if (!leadBook.value) return []
const items: string[] = []
if (leadBook.value.publishedYear != null) items.push(String(leadBook.value.publishedYear))
if (leadBook.value.language) items.push(leadBook.value.language.toUpperCase())
if (leadBook.value.rating != null) items.push(`${leadBook.value.rating.toFixed(1)}★`)
return items
})
function handleBookAction(book: BookCard, action: string) {
if (action === 'quick-view') {
@@ -75,6 +127,94 @@ function onLibraryFilterChange(event: Event) {
libraryId.value = value ? Number(value) : null
}
function handleLeadCoverError(bookId: number) {
failedLeadCovers.value = new Set([...failedLeadCovers.value, bookId])
}
async function loadLeadBookPreview(preserveCurrent = false) {
if (!seriesName.value) {
leadBook.value = null
leadBookError.value = null
leadCoverBookIds.value = []
return
}
const token = ++leadBookRequestToken
loadingLeadBook.value = true
leadBookError.value = null
if (!preserveCurrent) {
leadBook.value = null
leadCoverBookIds.value = []
failedLeadCovers.value = new Set<number>()
}
leadDescriptionExpanded.value = false
leadGenresExpanded.value = false
try {
const firstPage = await fetchSeriesBooks(seriesName.value, {
page: 0,
size: MAX_STACK_VISIBLE,
sort: 'seriesIndex',
order: 'asc',
libraryId: libraryId.value,
})
if (token !== leadBookRequestToken) return
leadCoverBookIds.value = firstPage.items
.filter((book) => book.hasCover)
.map((book) => book.id)
.slice(0, MAX_STACK_VISIBLE)
const firstBook = firstPage.items[0]
if (!firstBook) {
leadBook.value = null
return
}
const baseLeadBook = {
id: firstBook.id,
title: firstBook.title,
seriesIndex: firstBook.seriesIndex,
publishedYear: firstBook.publishedYear,
language: firstBook.language,
rating: firstBook.rating,
genres: firstBook.genres,
description: null,
}
leadBook.value = baseLeadBook
const detailResponse = await api(`/api/v1/books/${firstBook.id}`)
if (token !== leadBookRequestToken) return
if (!detailResponse.ok) return
const detail = (await detailResponse.json()) as BookDetail
leadBook.value = {
id: firstBook.id,
title: detail.title ?? firstBook.title,
seriesIndex: firstBook.seriesIndex,
publishedYear: detail.publishedYear ?? firstBook.publishedYear,
language: detail.language ?? firstBook.language,
rating: detail.rating ?? firstBook.rating,
genres: detail.genres.length > 0 ? detail.genres : firstBook.genres,
description: detail.description,
}
} catch (err) {
if (token !== leadBookRequestToken) return
leadBookError.value = err instanceof Error ? err.message : 'Failed to load lead book details'
} finally {
if (token === leadBookRequestToken) loadingLeadBook.value = false
}
}
function toggleLeadGenresExpanded() {
leadGenresExpanded.value = !leadGenresExpanded.value
}
function toggleLeadDescriptionExpanded() {
leadDescriptionExpanded.value = !leadDescriptionExpanded.value
}
async function editSeriesMetadata() {
if (!canEditMetadata.value || openingSeriesEditor.value || loadingBooks.value) return
@@ -111,7 +251,7 @@ async function editSeriesMetadata() {
onMounted(async () => {
await fetchLibraries()
await loadBooks(true)
await loadBooks({ reset: true })
observer = new IntersectionObserver(
(entries) => {
@@ -142,13 +282,21 @@ watch(
)
watch([sort, order, libraryId], () => {
void loadBooks(true)
void loadBooks({ reset: true, keepPreviousData: true })
})
watch(seriesName, () => {
void loadBooks(true)
void loadBooks({ reset: true })
})
watch(
[seriesName, libraryId],
([nextSeriesName], [prevSeriesName]) => {
void loadLeadBookPreview(nextSeriesName === prevSeriesName)
},
{ immediate: true },
)
watch(
loadingBooks,
(isLoading) => {
@@ -182,27 +330,128 @@ watch(
<!-- Series header -->
<div v-if="seriesInfo" class="mb-4 rounded-xl border border-border/70 bg-card/60 p-4">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="min-w-0">
<h1 class="text-xl font-bold text-foreground">{{ seriesInfo.name }}</h1>
<div class="mt-2 flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span>{{ seriesInfo.bookCount }} {{ seriesInfo.bookCount === 1 ? 'book' : 'books' }}</span>
<span v-if="seriesInfo.authors.length > 0">by {{ seriesInfo.authors.join(', ') }}</span>
<div class="flex flex-col gap-4 md:flex-row md:items-start">
<div class="mx-auto w-full max-w-[360px] md:mx-0 md:w-[340px] md:shrink-0 lg:w-[360px]">
<div
class="relative isolate overflow-hidden rounded-xl border border-border/60 bg-linear-to-b from-white/[0.035] via-background/5 to-black/[0.07]"
style="aspect-ratio: 11 / 8"
>
<div class="pointer-events-none absolute -right-8 -top-12 h-36 w-36 rounded-full bg-primary/10 blur-3xl" />
<div class="pointer-events-none absolute -bottom-12 -left-10 h-32 w-32 rounded-full bg-primary/8 blur-3xl" />
<div class="absolute inset-x-[21%] bottom-[5%] h-4 rounded-full bg-black/10 blur-2xl opacity-38" />
<div
v-for="(bookId, i) in visibleLeadCoverBookIds"
:key="bookId"
class="absolute overflow-hidden rounded-lg"
:style="leadCoverStyles[i] ?? {}"
>
<img
:src="coverUrl(bookId)"
alt=""
class="h-full w-full object-cover"
loading="lazy"
decoding="async"
@error="handleLeadCoverError(bookId)"
/>
</div>
<div
v-if="visibleLeadCoverBookIds.length === 0"
class="absolute inset-x-[28.5%] bottom-[5.5%] top-[5.5%] flex select-none items-center justify-center rounded-[14px] text-4xl font-bold shadow-[0_12px_28px_-18px_rgba(15,23,42,0.7)]"
:style="{ background: leadFallbackStyle.background, color: leadFallbackStyle.color }"
>
{{ leadInitial }}
</div>
</div>
</div>
<button
v-if="canEditMetadata && books.length > 0"
class="flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-input bg-background px-2.5 text-sm transition-colors hover:bg-muted disabled:opacity-40 sm:px-3 md:w-auto"
:disabled="openingSeriesEditor || loadingBooks"
@click="editSeriesMetadata"
>
<Pencil :size="14" />
{{ openingSeriesEditor ? 'Preparing editor...' : 'Edit Series Metadata' }}
</button>
<div class="min-w-0 flex-1 md:border-l md:border-border/60 md:pl-6">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="min-w-0">
<h1 class="text-xl font-bold text-foreground">{{ seriesInfo.name }}</h1>
<div class="mt-2 flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span>{{ seriesInfo.bookCount }} {{ seriesInfo.bookCount === 1 ? 'book' : 'books' }}</span>
<span v-if="visibleSeriesAuthors.length > 0">
by {{ visibleSeriesAuthors.join(', ') }}
<span v-if="hiddenSeriesAuthorsCount > 0"> +{{ hiddenSeriesAuthorsCount }} more</span>
</span>
</div>
</div>
<button
v-if="canEditMetadata && books.length > 0"
class="flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-input bg-background px-2.5 text-sm transition-colors hover:bg-muted disabled:opacity-40 sm:px-3 md:w-auto"
:disabled="openingSeriesEditor || loadingBooks"
@click="editSeriesMetadata"
>
<Pencil :size="14" />
{{ openingSeriesEditor ? 'Preparing editor...' : 'Edit Series Metadata' }}
</button>
</div>
<SeriesCompletionBar :read-count="seriesInfo.readCount" :total-count="seriesInfo.bookCount" class="mt-3 max-w-xs" />
<SeriesGapBanner v-if="seriesInfo.possibleGaps.length > 0" :gaps="seriesInfo.possibleGaps" class="mt-3" />
<div class="mt-4 border-t border-border/60 pt-4">
<div class="mb-2">
<p class="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground/90">First in Series</p>
<p v-if="leadBook" class="mt-1 text-base font-semibold leading-tight text-foreground">
{{ leadBook.title ?? 'Untitled' }}
<span v-if="leadBook.seriesIndex != null" class="text-muted-foreground">#{{ leadBook.seriesIndex }}</span>
</p>
<p v-if="leadMetaItems.length > 0" class="mt-1 text-[11px] text-muted-foreground">
{{ leadMetaItems.join(' • ') }}
</p>
</div>
<div v-if="loadingLeadBook && !leadBook" class="text-sm text-muted-foreground">Loading first book preview...</div>
<div v-else-if="leadBookError" class="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{{ leadBookError }}
</div>
<div v-else-if="leadBook" class="space-y-3">
<div v-if="leadBook.genres.length" class="flex flex-wrap items-center gap-1.5">
<span
v-for="(genre, index) in displayedLeadGenres"
:key="`${genre}-${index}`"
class="rounded-full border border-primary/40 px-2.5 py-0.5 text-xs text-primary/85"
>
{{ genre }}
</span>
<button
v-if="hiddenLeadGenres > 0"
type="button"
class="whitespace-nowrap text-xs font-medium text-foreground/75 transition-colors hover:text-foreground"
@click="toggleLeadGenresExpanded"
>
{{ leadGenresExpanded ? 'Show less' : `+${hiddenLeadGenres} more` }}
</button>
</div>
<div>
<p class="mb-2 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">Synopsis</p>
<div v-if="leadBook.description">
<div
class="text-sm leading-relaxed text-foreground/85"
:class="leadDescriptionExpanded ? '' : 'line-clamp-3'"
v-html="safeLeadDescription"
/>
<button
type="button"
class="mt-2 text-xs text-muted-foreground transition-colors hover:text-foreground"
@click="toggleLeadDescriptionExpanded"
>
{{ leadDescriptionExpanded ? 'Show less' : 'Show more' }}
</button>
</div>
<p v-else class="text-sm italic text-muted-foreground">No description available.</p>
</div>
<p v-if="loadingLeadBook" class="text-xs text-muted-foreground">Updating preview...</p>
</div>
<div v-else class="text-sm text-muted-foreground">No books available to preview.</div>
</div>
</div>
</div>
<SeriesCompletionBar :read-count="seriesInfo.readCount" :total-count="seriesInfo.bookCount" class="mt-3 max-w-xs" />
<SeriesGapBanner v-if="seriesInfo.possibleGaps.length > 0" :gaps="seriesInfo.possibleGaps" class="mt-3" />
</div>
<!-- Loading state -->
@@ -212,34 +461,38 @@ watch(
<!-- Books section -->
<section v-if="seriesInfo" class="rounded-xl border border-border/70 bg-card/60 p-3">
<div class="mb-3 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<h2 class="text-sm font-semibold text-foreground">Books</h2>
<div class="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<select
v-model="sort"
class="h-8 w-full min-w-0 rounded-md border border-input bg-background px-2.5 text-sm outline-none transition-colors focus:border-primary/60 sm:w-auto"
>
<option value="seriesIndex">Series Order</option>
<option value="title">Title</option>
<option value="addedAt">Recently Added</option>
</select>
<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"
>
<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>
<div class="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<select
v-model="sort"
class="h-8 w-full min-w-0 rounded-md border border-input bg-background px-2.5 text-sm outline-none transition-colors focus:border-primary/60 sm:w-auto"
>
<option value="seriesIndex">Series Order</option>
<option value="title">Title</option>
<option value="addedAt">Recently Added</option>
</select>
<select
v-model="order"
class="h-8 w-full min-w-0 rounded-md border border-input bg-background px-2.5 text-sm outline-none transition-colors focus:border-primary/60 sm:w-auto"
>
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>
<select
v-model="order"
class="h-8 w-full min-w-0 rounded-md border border-input bg-background px-2.5 text-sm outline-none transition-colors focus:border-primary/60 sm:w-auto"
>
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>
<select
:value="libraryId ?? ''"
class="h-8 w-full min-w-0 rounded-md border border-input bg-background px-2.5 text-sm outline-none transition-colors focus:border-primary/60 sm:w-auto"
@change="onLibraryFilterChange"
>
<option value="">All Libraries</option>
<option v-for="library in libraries" :key="library.id" :value="library.id">{{ library.name }}</option>
</select>
<select
:value="libraryId ?? ''"
class="h-8 w-full min-w-0 rounded-md border border-input bg-background px-2.5 text-sm outline-none transition-colors focus:border-primary/60 sm:w-auto"
@change="onLibraryFilterChange"
>
<option value="">All Libraries</option>
<option v-for="library in libraries" :key="library.id" :value="library.id">{{ library.name }}</option>
</select>
</div>
</div>
</div>
@@ -251,7 +504,7 @@ watch(
<VirtualBookGrid
v-if="viewMode === 'grid' && books.length > 0"
:books="books"
:cover-size="portraitCoverSize"
:cover-size="seriesBooksCoverSize"
:grid-gap="gridGap"
@action="handleBookAction"
/>
+19 -11
View File
@@ -101,7 +101,7 @@ function syncRouteQuery() {
}
function openSeries(seriesName: string) {
void router.push({ name: 'series-detail', params: { seriesName } })
void router.push({ name: 'series-detail', params: { seriesName }, query: { from: route.fullPath } })
}
function setSortField(field: SeriesListSort) {
@@ -188,10 +188,16 @@ async function clearFilters() {
suppressAutoReload.value = false
}
function handleFilterUpdate(field: 'libraryId' | 'completionStatus' | 'author', value: unknown) {
if (field === 'libraryId') libraryId.value = value as number | null
else if (field === 'completionStatus') completionStatus.value = value as CompletionStatus | null
else if (field === 'author') author.value = value as string | null
function handleLibraryIdUpdate(value: number | null) {
libraryId.value = value
}
function handleCompletionStatusUpdate(value: CompletionStatus | null) {
completionStatus.value = value
}
function handleAuthorUpdate(value: string | null) {
author.value = value
}
function loadIfSentinelVisible() {
@@ -283,6 +289,8 @@ watch(seriesGridGap, (value) => {
icon="Library"
:total="total"
:view-mode="viewMode"
:show-selection="false"
:show-view-mode-toggle="false"
v-model:coverSize="seriesCardWidth"
v-model:gridGap="seriesGridGap"
:cover-size-min="SERIES_CARD_WIDTH_MIN"
@@ -437,9 +445,9 @@ watch(seriesGridGap, (value) => {
:completion-status="completionStatus"
:author="author"
:active-count="activeFilterCount"
@update:library-id="handleFilterUpdate('libraryId', $event)"
@update:completion-status="handleFilterUpdate('completionStatus', $event)"
@update:author="handleFilterUpdate('author', $event)"
@update:library-id="handleLibraryIdUpdate"
@update:completion-status="handleCompletionStatusUpdate"
@update:author="handleAuthorUpdate"
@clear="clearFilters"
/>
</section>
@@ -453,9 +461,9 @@ watch(seriesGridGap, (value) => {
:author="author"
:active-count="activeFilterCount"
closable
@update:library-id="handleFilterUpdate('libraryId', $event)"
@update:completion-status="handleFilterUpdate('completionStatus', $event)"
@update:author="handleFilterUpdate('author', $event)"
@update:library-id="handleLibraryIdUpdate"
@update:completion-status="handleCompletionStatusUpdate"
@update:author="handleAuthorUpdate"
@clear="clearFilters"
@close="closeFiltersPanel"
/>
@@ -215,6 +215,7 @@ export class CollectionService {
const qWhere = q?.trim() ? this.queryBuilder.buildQuickSearch(q.trim()) : undefined;
if (collapseSeries) {
const MAX_COLLAPSE_BOOKS = 5000;
const allBookIds = await this.collectionRepo.findAllBookIds(id, libraryIds, qWhere);
if (allBookIds.length === 0) {
this.logger.log(
@@ -223,6 +224,25 @@ export class CollectionService {
return { items: [], total: 0, page, size };
}
if (allBookIds.length > MAX_COLLAPSE_BOOKS) {
this.logger.warn(
`[${event}] [end] collectionId=${id} durationMs=${Date.now() - startedAt} bookCount=${allBookIds.length} cap=${MAX_COLLAPSE_BOOKS} - falling back to non-collapsed pagination`,
);
const bookPage = await this.collectionRepo.findBookIdsPage(id, libraryIds, page, size, qWhere);
if (bookPage.bookIds.length === 0) {
return { items: [], total: bookPage.total, page, size };
}
const { rows, authorRows, fileRows, genreRows, progressRows, statusRows } = await this.bookReadService.findCardsByBookIds(
bookPage.bookIds,
user.id,
);
const orderMap = new Map(bookPage.bookIds.map((bookId, index) => [bookId, index]));
const items = assembleBookCards(rows, authorRows, fileRows, genreRows, progressRows, statusRows).sort(
(left, right) => (orderMap.get(left.id) ?? 0) - (orderMap.get(right.id) ?? 0),
);
return { items, total: bookPage.total, page, size };
}
const { rows, authorRows, fileRows, genreRows, progressRows, statusRows } = await this.bookReadService.findCardsByBookIds(
allBookIds,
user.id,
@@ -1,5 +1,7 @@
import 'reflect-metadata';
import { BadRequestException } from '@nestjs/common';
import type { RequestUser } from '../../common/types/request-user';
import { SeriesController } from './series.controller';
@@ -45,7 +47,7 @@ describe('SeriesController', () => {
expect(result).toBe(expected);
});
it('findBooks delegates to service with raw param', async () => {
it('findBooks delegates to service with decoded param', async () => {
const { controller, seriesService } = makeController();
const user = makeUser();
const dto = { page: 0, size: 50 };
@@ -64,14 +66,62 @@ describe('SeriesController', () => {
expect(result).toBe(expected);
});
it('findBooks passes param as-is without decoding', async () => {
const { controller, seriesService } = makeController();
const user = makeUser();
const dto = { page: 0, size: 50 };
seriesService.findBooks.mockResolvedValue({ items: [] });
describe('seriesName validation', () => {
it('trims whitespace from seriesName', async () => {
const { controller, seriesService } = makeController();
seriesService.findBooks.mockResolvedValue({ items: [] });
await controller.findBooks(user, 'Simple Series', dto as any);
await controller.findBooks(makeUser(), ' Harry Potter ', {} as any);
expect(seriesService.findBooks).toHaveBeenCalledWith(expect.anything(), 'Harry Potter', expect.anything());
});
expect(seriesService.findBooks).toHaveBeenCalledWith(user, 'Simple Series', dto);
it('passes already-decoded param as-is', async () => {
const { controller, seriesService } = makeController();
seriesService.findBooks.mockResolvedValue({ items: [] });
await controller.findBooks(makeUser(), 'Harry Potter', {} as any);
expect(seriesService.findBooks).toHaveBeenCalledWith(expect.anything(), 'Harry Potter', expect.anything());
});
it('handles names with special characters', async () => {
const { controller, seriesService } = makeController();
seriesService.findBooks.mockResolvedValue({ items: [] });
await controller.findBooks(makeUser(), 'A & B / C', {} as any);
expect(seriesService.findBooks).toHaveBeenCalledWith(expect.anything(), 'A & B / C', expect.anything());
});
it('handles names with percent sign', async () => {
const { controller, seriesService } = makeController();
seriesService.findBooks.mockResolvedValue({ items: [] });
await controller.findBooks(makeUser(), '100% Complete', {} as any);
expect(seriesService.findBooks).toHaveBeenCalledWith(expect.anything(), '100% Complete', expect.anything());
});
it('throws BadRequestException for empty seriesName', () => {
const { controller } = makeController();
expect(() => controller.findBooks(makeUser(), '', {} as any)).toThrow(BadRequestException);
});
it('throws BadRequestException for whitespace-only seriesName', () => {
const { controller } = makeController();
expect(() => controller.findBooks(makeUser(), ' ', {} as any)).toThrow(BadRequestException);
});
it('throws BadRequestException for seriesName exceeding max length', () => {
const { controller } = makeController();
const longName = 'A'.repeat(501);
expect(() => controller.findBooks(makeUser(), longName, {} as any)).toThrow(BadRequestException);
});
it('accepts seriesName at max length boundary', async () => {
const { controller, seriesService } = makeController();
seriesService.findBooks.mockResolvedValue({ items: [] });
const name = 'A'.repeat(500);
await controller.findBooks(makeUser(), name, {} as any);
expect(seriesService.findBooks).toHaveBeenCalledWith(expect.anything(), name, expect.anything());
});
});
});
+11 -2
View File
@@ -1,4 +1,4 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { BadRequestException, Controller, Get, Param, Query } from '@nestjs/common';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { RequestUser } from '../../common/types/request-user';
@@ -6,6 +6,8 @@ import { ListSeriesBooksDto } from './dto/list-series-books.dto';
import { ListSeriesDto } from './dto/list-series.dto';
import { SeriesService } from './series.service';
const MAX_SERIES_NAME_LENGTH = 500;
@Controller('series')
export class SeriesController {
constructor(private readonly seriesService: SeriesService) {}
@@ -16,7 +18,14 @@ export class SeriesController {
}
@Get(':seriesName/books')
findBooks(@CurrentUser() user: RequestUser, @Param('seriesName') seriesName: string, @Query() dto: ListSeriesBooksDto) {
findBooks(@CurrentUser() user: RequestUser, @Param('seriesName') rawSeriesName: string, @Query() dto: ListSeriesBooksDto) {
const seriesName = rawSeriesName.trim();
if (!seriesName) {
throw new BadRequestException('seriesName must not be empty');
}
if (seriesName.length > MAX_SERIES_NAME_LENGTH) {
throw new BadRequestException(`seriesName must not exceed ${MAX_SERIES_NAME_LENGTH} characters`);
}
return this.seriesService.findBooks(user, seriesName, dto);
}
}
@@ -39,6 +39,10 @@ export class SeriesRepository {
return inArray(books.libraryId, libraryIds);
}
private escapeLikePattern(s: string): string {
return s.replace(/[\\%_]/g, '\\$&');
}
async findPage(params: {
q?: string;
page: number;
@@ -56,15 +60,16 @@ export class SeriesRepository {
const conditions: SQL[] = [isNotNull(bookMetadata.seriesName), sql`btrim(${bookMetadata.seriesName}) != ''`, libraryFilter];
if (params.q) {
conditions.push(ilike(bookMetadata.seriesName, `%${params.q}%`));
conditions.push(ilike(bookMetadata.seriesName, `%${this.escapeLikePattern(params.q)}%`));
}
if (params.author) {
const authorPattern = `%${this.escapeLikePattern(params.author)}%`;
conditions.push(
sql`${books.id} IN (
SELECT ${bookAuthors.bookId} FROM ${bookAuthors}
INNER JOIN ${authors} ON ${authors.id} = ${bookAuthors.authorId}
WHERE ${ilike(authors.name, `%${params.author}%`)}
WHERE ${ilike(authors.name, authorPattern)}
)`,
);
}
@@ -240,39 +240,71 @@ describe('SeriesService', () => {
});
});
describe('computeGaps (via findBooks)', () => {
describe('findBooks - library filter empty state', () => {
it('returns empty state when series exists in another library', async () => {
seriesRepo.findDetail
.mockResolvedValueOnce(null) // first call with scoped library [2]
.mockResolvedValueOnce({ name: 'Dune', bookCount: 5, readCount: 2, authors: ['Frank Herbert'], indices: [1, 2, 3, 4, 5] }); // second call with all libraries [1, 2]
seriesRepo.findBookIds.mockResolvedValue({ bookIds: [], total: 0 });
const result = await service.findBooks(reqUser(), 'Dune', { libraryId: 2 });
expect(result.items).toEqual([]);
expect(result.total).toBe(0);
expect(result.seriesInfo.name).toBe('Dune');
expect(result.seriesInfo.authors).toEqual(['Frank Herbert']);
expect(result.seriesInfo.possibleGaps).toEqual([]);
expect(result.seriesInfo.bookCount).toBe(0);
});
it('throws 404 when series does not exist in any library', async () => {
seriesRepo.findDetail.mockResolvedValue(null);
seriesRepo.findBookIds.mockResolvedValue({ bookIds: [], total: 0 });
await expect(service.findBooks(reqUser(), 'Nonexistent', { libraryId: 1 })).rejects.toBeInstanceOf(NotFoundException);
});
it('throws 404 when no library filter and series not found', async () => {
seriesRepo.findDetail.mockResolvedValue(null);
seriesRepo.findBookIds.mockResolvedValue({ bookIds: [], total: 0 });
await expect(service.findBooks(reqUser(), 'Nonexistent', {})).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('computeGaps edge cases', () => {
beforeEach(() => {
seriesRepo.findBookIds.mockResolvedValue({ bookIds: [], total: 0 });
});
it('detects integer gaps', async () => {
seriesRepo.findDetail.mockResolvedValue({ name: 'S', bookCount: 3, readCount: 0, authors: [], indices: [1, 2, 5] });
const result = await service.findBooks(reqUser(), 'S', {});
expect(result.seriesInfo.possibleGaps).toEqual([3, 4]);
});
it('ignores decimal indices for gap detection', async () => {
seriesRepo.findDetail.mockResolvedValue({ name: 'S', bookCount: 3, readCount: 0, authors: [], indices: [1, 1.5, 3] });
const result = await service.findBooks(reqUser(), 'S', {});
expect(result.seriesInfo.possibleGaps).toEqual([2]);
});
it('returns empty gaps for single-book series', async () => {
seriesRepo.findDetail.mockResolvedValue({ name: 'S', bookCount: 1, readCount: 0, authors: [], indices: [1] });
it('returns empty gaps when all indices are non-integer', async () => {
seriesRepo.findDetail.mockResolvedValue({ name: 'S', bookCount: 3, readCount: 0, authors: [], indices: [0.5, 1.5, 2.5] });
const result = await service.findBooks(reqUser(), 'S', {});
expect(result.seriesInfo.possibleGaps).toEqual([]);
});
it('returns empty gaps for continuous series', async () => {
seriesRepo.findDetail.mockResolvedValue({ name: 'S', bookCount: 3, readCount: 0, authors: [], indices: [1, 2, 3] });
it('returns empty gaps when min index < 1', async () => {
seriesRepo.findDetail.mockResolvedValue({ name: 'S', bookCount: 2, readCount: 0, authors: [], indices: [0, 5] });
const result = await service.findBooks(reqUser(), 'S', {});
expect(result.seriesInfo.possibleGaps).toEqual([]);
});
it('handles near-integer floating point values', async () => {
seriesRepo.findDetail.mockResolvedValue({ name: 'S', bookCount: 2, readCount: 0, authors: [], indices: [1.001, 3.002] });
it('returns empty gaps when max index > 10000', async () => {
seriesRepo.findDetail.mockResolvedValue({ name: 'S', bookCount: 2, readCount: 0, authors: [], indices: [1, 10001] });
const result = await service.findBooks(reqUser(), 'S', {});
expect(result.seriesInfo.possibleGaps).toEqual([]);
});
it('handles duplicate indices', async () => {
seriesRepo.findDetail.mockResolvedValue({ name: 'S', bookCount: 3, readCount: 0, authors: [], indices: [1, 1, 3] });
const result = await service.findBooks(reqUser(), 'S', {});
expect(result.seriesInfo.possibleGaps).toEqual([2]);
});
it('handles empty indices array', async () => {
seriesRepo.findDetail.mockResolvedValue({ name: 'S', bookCount: 0, readCount: 0, authors: [], indices: [] });
const result = await service.findBooks(reqUser(), 'S', {});
expect(result.seriesInfo.possibleGaps).toEqual([]);
});
});
});
@@ -83,6 +83,20 @@ export class SeriesService {
]);
if (!detail) {
if (dto.libraryId) {
const allLibraryIds = await this.resolveLibraryIds(user);
const existsInAnyLibrary = await this.seriesRepo.findDetail({ seriesName, userId: user.id, libraryIds: allLibraryIds });
if (existsInAnyLibrary) {
const emptyInfo: SeriesDetail = {
name: existsInAnyLibrary.name,
bookCount: 0,
readCount: 0,
authors: existsInAnyLibrary.authors,
possibleGaps: [],
};
return { items: [], total: 0, page, size, seriesInfo: emptyInfo };
}
}
throw new NotFoundException('Series not found');
}
@@ -0,0 +1,65 @@
import 'reflect-metadata';
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { UpdateSeriesCollapsePreferencesDto } from './update-series-collapse-preferences.dto';
function toDto(plain: Record<string, unknown>): UpdateSeriesCollapsePreferencesDto {
return plainToInstance(UpdateSeriesCollapsePreferencesDto, plain);
}
describe('UpdateSeriesCollapsePreferencesDto', () => {
it('accepts valid boolean global', async () => {
const dto = toDto({ global: true });
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('accepts valid boolean libraries', async () => {
const dto = toDto({ libraries: { '1': true, '2': false } });
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('accepts null values in libraries for deletion', async () => {
const dto = toDto({ libraries: { '1': null, '2': true } });
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('accepts null values in collections for deletion', async () => {
const dto = toDto({ collections: { '5': null } });
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('rejects non-boolean/non-null values in libraries', async () => {
const dto = toDto({ libraries: { '1': 'yes' } });
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
});
it('rejects number values in collections', async () => {
const dto = toDto({ collections: { '1': 42 } });
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
});
it('rejects array for libraries', async () => {
const dto = toDto({ libraries: [true, false] });
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
});
it('accepts empty dto (all optional)', async () => {
const dto = toDto({});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('rejects non-boolean global', async () => {
const dto = toDto({ global: 'true' });
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
});
});
@@ -1,19 +1,19 @@
import { IsBoolean, IsOptional, registerDecorator, ValidationOptions } from 'class-validator';
function IsBooleanRecord(validationOptions?: ValidationOptions) {
function IsBooleanOrNullRecord(validationOptions?: ValidationOptions) {
return (object: object, propertyName: string) => {
registerDecorator({
name: 'isBooleanRecord',
name: 'isBooleanOrNullRecord',
target: object.constructor,
propertyName,
options: validationOptions,
validator: {
validate(value: unknown) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
return Object.values(value as Record<string, unknown>).every((v) => typeof v === 'boolean');
return Object.values(value as Record<string, unknown>).every((v) => typeof v === 'boolean' || v === null);
},
defaultMessage() {
return `${propertyName} must be a record with string keys and boolean values`;
return `${propertyName} must be a record with string keys and boolean or null values`;
},
},
});
@@ -26,10 +26,10 @@ export class UpdateSeriesCollapsePreferencesDto {
global?: boolean;
@IsOptional()
@IsBooleanRecord()
libraries?: Record<string, boolean>;
@IsBooleanOrNullRecord()
libraries?: Record<string, boolean | null>;
@IsOptional()
@IsBooleanRecord()
collections?: Record<string, boolean>;
@IsBooleanOrNullRecord()
collections?: Record<string, boolean | null>;
}
+9
View File
@@ -265,6 +265,15 @@ export class UserService {
libraries: { ...currentPrefs.libraries, ...(dto.libraries ?? {}) },
collections: { ...currentPrefs.collections, ...(dto.collections ?? {}) },
};
// Remove entries set to null (deletion of overrides)
for (const [k, v] of Object.entries(merged.libraries)) {
if (v === null) delete merged.libraries[k];
}
for (const [k, v] of Object.entries(merged.collections)) {
if (v === null) delete merged.collections[k];
}
await this.userRepo.update(userId, { settings: { seriesCollapsePreferences: merged } });
}
}