mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: overhaul series detail view and improve card interactions
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user