feat: implement series browser with card deck visual style

- Add series browser page featuring browse and detail views
- Implement card deck cover design with horizontal spread and square aspect ratio
- Fix series-related bugs in decoding, state management, and data types
- Optimize responsive grid layout for series items
This commit is contained in:
neon
2026-04-15 08:55:25 -06:00
parent 40d829a8a9
commit 485bea5e5b
28 changed files with 2700 additions and 24 deletions
+8
View File
@@ -101,6 +101,7 @@ const {
const isDashboardActive = computed(() => route.name === 'dashboard')
const isAuthorsActive = computed(() => route.name === 'authors' || route.name === 'author-detail')
const isSeriesActive = computed(() => route.name === 'series' || route.name === 'series-detail')
const activeLibraryId = computed(() => {
const id = route.params.id
@@ -210,6 +211,13 @@ onUnmounted(() => stopLibraryUploadListener())
label="Authors"
@click="navigateFromSidebar({ name: 'authors' })"
/>
<SidebarNavItem
:is-active="isSeriesActive"
tooltip="Series"
:icon="Icons.Library"
label="Series"
@click="navigateFromSidebar({ name: 'series' })"
/>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
+38 -22
View File
@@ -15,16 +15,32 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
defineProps<{
title: string
icon?: string
total: number
coverSize: number
gridGap: number
viewMode: 'grid' | 'list'
selectionMode?: boolean
coverShape?: 'square' | 'circle'
}>()
withDefaults(
defineProps<{
title: string
icon?: string
total: number
coverSize: number
gridGap: number
viewMode: 'grid' | 'list'
selectionMode?: boolean
coverShape?: 'square' | 'circle'
coverSizeMin?: number
coverSizeMax?: number
coverSizeStep?: number
gridGapMin?: number
gridGapMax?: number
gridGapStep?: number
}>(),
{
coverSizeMin: 100,
coverSizeMax: 280,
coverSizeStep: 10,
gridGapMin: 4,
gridGapMax: 40,
gridGapStep: 4,
},
)
const emit = defineEmits<{
'update:coverSize': [value: number]
@@ -119,9 +135,9 @@ function getIconComponent(name: string) {
:value="coverSize"
@input="emit('update:coverSize', Number(($event.target as HTMLInputElement).value))"
type="range"
min="100"
max="280"
step="10"
:min="coverSizeMin"
:max="coverSizeMax"
:step="coverSizeStep"
class="w-full accent-primary cursor-pointer"
/>
</div>
@@ -134,9 +150,9 @@ function getIconComponent(name: string) {
:value="gridGap"
@input="emit('update:gridGap', Number(($event.target as HTMLInputElement).value))"
type="range"
min="4"
max="40"
step="4"
:min="gridGapMin"
:max="gridGapMax"
:step="gridGapStep"
class="w-full accent-primary cursor-pointer"
/>
</div>
@@ -215,9 +231,9 @@ function getIconComponent(name: string) {
:value="coverSize"
@input="emit('update:coverSize', Number(($event.target as HTMLInputElement).value))"
type="range"
min="100"
max="280"
step="10"
:min="coverSizeMin"
:max="coverSizeMax"
:step="coverSizeStep"
class="w-full accent-primary cursor-pointer"
/>
</div>
@@ -230,9 +246,9 @@ function getIconComponent(name: string) {
:value="gridGap"
@input="emit('update:gridGap', Number(($event.target as HTMLInputElement).value))"
type="range"
min="4"
max="40"
step="4"
:min="gridGapMin"
:max="gridGapMax"
:step="gridGapStep"
class="w-full accent-primary cursor-pointer"
/>
</div>
@@ -754,7 +754,12 @@ watch(
<span class="text-muted-foreground">narrated by</span>
<span class="ml-1 font-medium text-foreground">{{ narratorLine }}</span>
</p>
<span v-if="seriesLine" class="inline-block text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground">{{ seriesLine }}</span>
<RouterLink
v-if="seriesLine"
:to="{ name: 'series-detail', params: { seriesName: book.seriesName! } }"
class="inline-block text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground transition-colors"
>{{ seriesLine }}</RouterLink
>
</div>
<!-- Stars: own row -->
<div class="mt-2 flex items-center gap-0.5" @mouseleave="hoverRating = null">
@@ -1111,7 +1116,11 @@ watch(
</p>
<template v-if="seriesLine">
<span class="text-muted-foreground/60 text-xs">·</span>
<span class="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground">{{ seriesLine }}</span>
<RouterLink
:to="{ name: 'series-detail', params: { seriesName: book.seriesName! } }"
class="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground transition-colors"
>{{ seriesLine }}</RouterLink
>
</template>
</div>
+42
View File
@@ -0,0 +1,42 @@
import { api } from '@/lib/api'
import type { SeriesBooksPage, SeriesPage } from '@projectx/types'
import type { CompletionStatus, SeriesBookSort, SeriesListSort, SortDirection } from '../types/series'
type ListSeriesParams = {
q?: string
page: number
size: number
sort: SeriesListSort
order: SortDirection
libraryId?: number | null
completionStatus?: CompletionStatus | null
author?: string | null
}
type ListSeriesBooksParams = {
page: number
size: number
sort: SeriesBookSort
order: SortDirection
libraryId?: number | null
}
function toQuery(params: Record<string, unknown>): string {
const entries = Object.entries(params).filter(([, v]) => v != null && v !== '')
return new URLSearchParams(entries.map(([k, v]) => [k, String(v)])).toString()
}
export async function fetchSeries(params: ListSeriesParams): Promise<SeriesPage> {
const qs = toQuery(params)
const res = await api(`/api/v1/series?${qs}`)
if (!res.ok) throw new Error(`Failed to fetch series: ${res.status}`)
return res.json()
}
export async function fetchSeriesBooks(seriesName: string, params: ListSeriesBooksParams): Promise<SeriesBooksPage> {
const qs = toQuery(params)
const encoded = encodeURIComponent(seriesName)
const res = await api(`/api/v1/series/${encoded}/books?${qs}`)
if (!res.ok) throw new Error(`Failed to fetch series books: ${res.status}`)
return res.json()
}
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
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 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 props = defineProps<{
series: SeriesSummary
}>()
const emit = defineEmits<{
open: [seriesName: string]
}>()
const { coverUrl } = useCoverVersions()
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 authorsLine = computed(() => {
const a = props.series.authors
if (a.length === 0) return ''
if (a.length <= 2) return a.join(', ')
return `${a[0]}, ${a[1]} +${a.length - 2}`
})
function handleCoverError(bookId: number) {
failedCovers.value = new Set([...failedCovers.value, bookId])
}
function handleClick() {
emit('open', props.series.name)
}
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
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)',
}))
})
</script>
<template>
<div class="group flex h-full cursor-pointer flex-col" @click="handleClick">
<div
class="flex h-full flex-col overflow-hidden rounded-xl border border-border/60 bg-card shadow-sm transition-all duration-200 hover:border-border hover:shadow-md"
>
<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"
>
<div class="absolute inset-x-[21%] bottom-[5%] h-4 rounded-full bg-black/10 blur-2xl opacity-38" />
<div class="absolute right-2.5 top-2.5 z-[100]">
<span
class="inline-flex items-center gap-1 rounded-full border border-border/60 bg-background/88 px-2 py-1 text-[11px] font-medium text-foreground shadow-sm backdrop-blur"
>
<BookCopy :size="12" class="text-muted-foreground" />
{{ series.bookCount.toLocaleString() }}
</span>
</div>
<div v-for="(bookId, i) in visibleCovers" :key="bookId" class="absolute overflow-hidden rounded-lg" :style="coverStyles[i] ?? {}">
<img :src="coverUrl(bookId)" alt="" class="h-full w-full object-cover" loading="lazy" decoding="async" @error="handleCoverError(bookId)" />
</div>
<div
v-if="visibleCovers.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: fallbackStyle.background, color: fallbackStyle.color }"
>
{{ initial }}
</div>
</div>
<div class="flex flex-1 flex-col px-4 py-3">
<h3 class="truncate text-sm font-semibold text-foreground">{{ series.name }}</h3>
<p v-if="authorsLine" class="mt-1 truncate text-xs text-muted-foreground">{{ authorsLine }}</p>
</div>
<SeriesCompletionBar :read-count="series.readCount" :total-count="series.bookCount" compact flush class="mt-auto" />
</div>
</div>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
readCount: number
totalCount: number
compact?: boolean
flush?: boolean
}>()
const percentage = computed(() => {
if (props.totalCount === 0) return 0
return Math.round((props.readCount / props.totalCount) * 100)
})
const barColorClass = computed(() => {
if (percentage.value === 0) return 'bg-muted-foreground/30'
if (percentage.value >= 100) return 'bg-green-500'
return 'bg-primary'
})
const trackClass = computed(() => (props.flush ? 'h-1.5 rounded-none bg-muted/80' : 'h-1 rounded-full bg-muted'))
const fillClass = computed(() => (props.flush ? 'rounded-none' : 'rounded-full'))
</script>
<template>
<div>
<div class="w-full overflow-hidden" :class="trackClass">
<div class="h-full transition-all duration-300" :class="[barColorClass, fillClass]" :style="{ width: `${percentage}%` }" />
</div>
<p v-if="!compact" class="mt-1 text-xs text-muted-foreground">{{ readCount }} of {{ totalCount }} read ({{ percentage }}%)</p>
</div>
</template>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { CompletionStatus } from '../types/series'
defineProps<{
libraryId: number | null
libraries: { id: number; name: string }[]
completionStatus: CompletionStatus | null
author: string | null
activeCount?: number
closable?: boolean
}>()
const emit = defineEmits<{
'update:libraryId': [value: number | null]
'update:completionStatus': [value: CompletionStatus | null]
'update:author': [value: string | null]
clear: []
close: []
}>()
function onClear() {
emit('clear')
}
function onClose() {
emit('close')
}
function onLibraryChange(event: Event) {
const value = (event.target as HTMLSelectElement).value
emit('update:libraryId', value ? Number(value) : null)
}
function onCompletionChange(event: Event) {
const value = (event.target as HTMLSelectElement).value
emit('update:completionStatus', (value as CompletionStatus) || null)
}
function onAuthorChange(event: Event) {
const value = (event.target as HTMLInputElement).value.trim()
emit('update:author', value || null)
}
</script>
<template>
<section class="mb-4 rounded-md border border-border bg-card p-3">
<div class="mb-3 flex items-center justify-between">
<span class="text-xs font-medium text-muted-foreground">Series Filters</span>
<div class="flex items-center gap-2">
<button
v-if="(activeCount ?? 0) > 0"
class="h-7 rounded-md border border-input px-2.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
@click="onClear"
>
Clear all
</button>
<button
v-if="closable"
class="h-7 rounded-md border border-input px-2.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
@click="onClose"
>
Close
</button>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<select
:value="libraryId ?? ''"
class="h-9 rounded-md border border-input bg-background px-2.5 text-sm outline-none transition-colors focus:border-primary/60"
@change="onLibraryChange"
>
<option value="">All Libraries</option>
<option v-for="library in libraries" :key="library.id" :value="library.id">{{ library.name }}</option>
</select>
<select
:value="completionStatus ?? ''"
class="h-9 rounded-md border border-input bg-background px-2.5 text-sm outline-none transition-colors focus:border-primary/60"
@change="onCompletionChange"
>
<option value="">All completion</option>
<option value="not_started">Not started</option>
<option value="in_progress">In progress</option>
<option value="complete">Complete</option>
</select>
<div class="flex h-9 items-center gap-1.5 rounded-md border border-input bg-background px-2.5">
<span class="text-sm text-muted-foreground">Author</span>
<input
type="text"
:value="author ?? ''"
placeholder="Filter by author"
class="w-32 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground/85"
@change="onAuthorChange"
/>
</div>
</div>
</section>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue'
import { AlertCircle } from 'lucide-vue-next'
const props = defineProps<{
gaps: number[]
}>()
const gapsLabel = computed(() => props.gaps.map((g) => `#${g}`).join(', '))
</script>
<template>
<div v-if="gaps.length > 0" class="flex items-start gap-2 rounded-md border border-border bg-muted/50 px-3 py-2">
<AlertCircle :size="16" class="mt-0.5 shrink-0 text-muted-foreground" />
<p class="text-xs text-muted-foreground"><span class="font-medium">Possible gaps:</span> {{ gapsLabel }}</p>
</div>
</template>
@@ -0,0 +1,189 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
import type { BookCard, SeriesBooksPage } from '@projectx/types'
vi.mock('../api/series', () => ({
fetchSeriesBooks: vi.fn<typeof import('../api/series').fetchSeriesBooks>(),
}))
import { fetchSeriesBooks } from '../api/series'
import { useSeriesDetail } from './useSeriesDetail'
const mockFetchSeriesBooks = vi.mocked(fetchSeriesBooks)
describe('useSeriesDetail', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('initializes with empty state', () => {
const name = ref('Test')
const { seriesInfo, items, total, loading, error, notFound, hasMore } = useSeriesDetail(name)
expect(seriesInfo.value).toBeNull()
expect(items.value).toEqual([])
expect(total.value).toBe(0)
expect(loading.value).toBe(false)
expect(error.value).toBeNull()
expect(notFound.value).toBe(false)
expect(hasMore.value).toBe(false)
})
it('loads series detail and books', async () => {
const name = ref('Dune')
mockFetchSeriesBooks.mockResolvedValue({
items: [{ id: 1, title: 'Dune' } as BookCard],
total: 1,
page: 0,
size: 50,
seriesInfo: { name: 'Dune', bookCount: 6, readCount: 2, authors: ['Frank Herbert'], possibleGaps: [3] },
})
const { seriesInfo, items, total, load } = useSeriesDetail(name)
await load(true)
expect(seriesInfo.value?.name).toBe('Dune')
expect(seriesInfo.value?.possibleGaps).toEqual([3])
expect(items.value).toHaveLength(1)
expect(total.value).toBe(1)
})
it('sets notFound on 404 error', async () => {
const name = ref('Nonexistent')
mockFetchSeriesBooks.mockRejectedValue(new Error('404: Not Found'))
const { notFound, error, load } = useSeriesDetail(name)
await load(true)
expect(notFound.value).toBe(true)
expect(error.value).toBeNull()
})
it('sets error on non-404 errors', async () => {
const name = ref('Test')
mockFetchSeriesBooks.mockRejectedValue(new Error('500: Server Error'))
const { notFound, error, load } = useSeriesDetail(name)
await load(true)
expect(notFound.value).toBe(false)
expect(error.value).toBe('500: Server Error')
})
it('does not load when seriesName is empty', async () => {
const name = ref('')
const { load } = useSeriesDetail(name)
await load(true)
expect(mockFetchSeriesBooks).not.toHaveBeenCalled()
})
it('appends books on subsequent loads', async () => {
const name = ref('Test')
mockFetchSeriesBooks
.mockResolvedValueOnce({
items: [{ id: 1 } as BookCard],
total: 2,
page: 0,
size: 50,
seriesInfo: { name: 'Test', bookCount: 2, readCount: 0, authors: [], possibleGaps: [] },
})
.mockResolvedValueOnce({
items: [{ id: 2 } as BookCard],
total: 2,
page: 1,
size: 50,
seriesInfo: { name: 'Test', bookCount: 2, readCount: 0, authors: [], possibleGaps: [] },
})
const { items, load } = useSeriesDetail(name)
await load(true)
await load()
expect(items.value).toHaveLength(2)
})
it('resets notFound on fresh load', async () => {
const name = ref('Missing')
mockFetchSeriesBooks.mockRejectedValue(new Error('404: Not Found'))
const { notFound, load } = useSeriesDetail(name)
await load(true)
expect(notFound.value).toBe(true)
name.value = 'Found'
mockFetchSeriesBooks.mockResolvedValue({
items: [],
total: 0,
page: 0,
size: 50,
seriesInfo: { name: 'Found', bookCount: 0, readCount: 0, authors: [], possibleGaps: [] },
})
await load(true)
expect(notFound.value).toBe(false)
})
it('clears seriesInfo and total on 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(true)
expect(seriesInfo.value).toBeNull()
expect(total.value).toBe(0)
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({
items: [],
total: 0,
page: 0,
size: 50,
seriesInfo: { name: 'Test', bookCount: 0, readCount: 0, authors: [], possibleGaps: [] },
})
const { sort, order, libraryId, load } = useSeriesDetail(name)
sort.value = 'title'
order.value = 'desc'
libraryId.value = 3
await load(true)
expect(mockFetchSeriesBooks).toHaveBeenCalledWith('Test', {
page: 0,
size: 50,
sort: 'title',
order: 'desc',
libraryId: 3,
})
})
})
@@ -0,0 +1,77 @@
import { computed, ref, type Ref } from 'vue'
import type { BookCard, SeriesDetail } from '@projectx/types'
import { fetchSeriesBooks } from '../api/series'
import type { SeriesBookSort, SortDirection } from '../types/series'
const PAGE_SIZE = 50
export function useSeriesDetail(seriesName: Ref<string>) {
const seriesInfo = ref<SeriesDetail | null>(null)
const items = ref<BookCard[]>([])
const total = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
const notFound = ref(false)
const sort = ref<SeriesBookSort>('seriesIndex')
const order = ref<SortDirection>('asc')
const libraryId = ref<number | null>(null)
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
loading.value = true
error.value = null
if (reset) {
page.value = 0
items.value = []
seriesInfo.value = null
total.value = 0
notFound.value = false
}
try {
const data = await fetchSeriesBooks(seriesName.value, {
page: page.value,
size: PAGE_SIZE,
sort: sort.value,
order: order.value,
libraryId: libraryId.value,
})
items.value = reset ? data.items : [...items.value, ...data.items]
total.value = data.total
seriesInfo.value = data.seriesInfo
page.value += 1
} catch (err) {
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
}
}
return {
seriesInfo,
items,
total,
loading,
error,
notFound,
hasMore,
sort,
order,
libraryId,
load,
}
}
@@ -0,0 +1,188 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SeriesPage } from '@projectx/types'
vi.mock('../api/series', () => ({
fetchSeries: vi.fn<typeof import('../api/series').fetchSeries>(),
}))
import { fetchSeries } from '../api/series'
import { useSeriesList } from './useSeriesList'
const mockFetchSeries = vi.mocked(fetchSeries)
describe('useSeriesList', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('initializes with empty state', () => {
const { items, total, loading, error, hasMore, q, sort, order } = useSeriesList()
expect(items.value).toEqual([])
expect(total.value).toBe(0)
expect(loading.value).toBe(false)
expect(error.value).toBeNull()
expect(hasMore.value).toBe(false)
expect(q.value).toBe('')
expect(sort.value).toBe('name')
expect(order.value).toBe('asc')
})
it('loads first page and populates items', async () => {
mockFetchSeries.mockResolvedValue({
items: [{ name: 'Harry Potter', bookCount: 7, readCount: 3, authors: ['J.K. Rowling'], coverBookIds: [1], lastAddedAt: null }],
total: 1,
page: 0,
size: 50,
})
const { items, total, loading, load } = useSeriesList()
await load(true)
expect(loading.value).toBe(false)
expect(items.value).toHaveLength(1)
expect(items.value[0]!.name).toBe('Harry Potter')
expect(total.value).toBe(1)
expect(mockFetchSeries).toHaveBeenCalledTimes(1)
})
it('appends items on subsequent loads', async () => {
mockFetchSeries
.mockResolvedValueOnce({
items: [{ name: 'Series A', bookCount: 3, readCount: 0, authors: [], coverBookIds: [], lastAddedAt: null }],
total: 2,
page: 0,
size: 50,
})
.mockResolvedValueOnce({
items: [{ name: 'Series B', bookCount: 5, readCount: 1, authors: [], coverBookIds: [], lastAddedAt: null }],
total: 2,
page: 1,
size: 50,
})
const { items, load } = useSeriesList()
await load(true)
await load()
expect(items.value).toHaveLength(2)
expect(items.value[0]!.name).toBe('Series A')
expect(items.value[1]!.name).toBe('Series B')
})
it('resets state on load(true)', async () => {
mockFetchSeries.mockResolvedValue({
items: [{ name: 'Series A', bookCount: 1, readCount: 0, authors: [], coverBookIds: [], lastAddedAt: null }],
total: 1,
page: 0,
size: 50,
})
const { items, load } = useSeriesList()
await load(true)
expect(items.value).toHaveLength(1)
mockFetchSeries.mockResolvedValue({
items: [{ name: 'Series B', bookCount: 2, readCount: 0, authors: [], coverBookIds: [], lastAddedAt: null }],
total: 1,
page: 0,
size: 50,
})
await load(true)
expect(items.value).toHaveLength(1)
expect(items.value[0]!.name).toBe('Series B')
})
it('sets error on fetch failure', async () => {
mockFetchSeries.mockRejectedValue(new Error('Network error'))
const { error, load } = useSeriesList()
await load(true)
expect(error.value).toBe('Network error')
})
it('sets generic error for non-Error rejections', async () => {
mockFetchSeries.mockRejectedValue('unknown')
const { error, load } = useSeriesList()
await load(true)
expect(error.value).toBe('Failed to load series')
})
it('does not load when already loading', async () => {
let resolveFn!: (v: SeriesPage) => void
mockFetchSeries.mockImplementation(
() =>
new Promise<SeriesPage>((resolve) => {
resolveFn = resolve
}),
)
const { loading, load } = useSeriesList()
const firstLoad = load(true)
expect(loading.value).toBe(true)
const secondLoad = load(true)
resolveFn!({ items: [], total: 0, page: 0, size: 50 })
await firstLoad
await secondLoad
expect(mockFetchSeries).toHaveBeenCalledTimes(1)
})
it('does not load more when no more items', async () => {
mockFetchSeries.mockResolvedValue({
items: [{ name: 'Only One', bookCount: 1, readCount: 0, authors: [], coverBookIds: [], lastAddedAt: null }],
total: 1,
page: 0,
size: 50,
})
const { load, hasMore } = useSeriesList()
await load(true)
expect(hasMore.value).toBe(false)
await load()
expect(mockFetchSeries).toHaveBeenCalledTimes(1)
})
it('passes query params to fetchSeries', async () => {
mockFetchSeries.mockResolvedValue({ items: [], total: 0, page: 0, size: 50 })
const { q, sort, order, libraryId, completionStatus, author, load } = useSeriesList()
q.value = 'harry'
sort.value = 'bookCount'
order.value = 'desc'
libraryId.value = 5
completionStatus.value = 'complete'
author.value = 'Rowling'
await load(true)
expect(mockFetchSeries).toHaveBeenCalledWith({
q: 'harry',
page: 0,
size: 50,
sort: 'bookCount',
order: 'desc',
libraryId: 5,
completionStatus: 'complete',
author: 'Rowling',
})
})
it('trims whitespace-only query to undefined', async () => {
mockFetchSeries.mockResolvedValue({ items: [], total: 0, page: 0, size: 50 })
const { q, load } = useSeriesList()
q.value = ' '
await load(true)
expect(mockFetchSeries).toHaveBeenCalledWith(expect.objectContaining({ q: undefined }))
})
})
@@ -0,0 +1,73 @@
import { computed, ref } from 'vue'
import type { SeriesSummary } from '@projectx/types'
import { fetchSeries } from '../api/series'
import type { CompletionStatus, SeriesListSort, SortDirection } from '../types/series'
const PAGE_SIZE = 50
export function useSeriesList() {
const items = ref<SeriesSummary[]>([])
const total = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
const q = ref('')
const sort = ref<SeriesListSort>('name')
const order = ref<SortDirection>('asc')
const libraryId = ref<number | null>(null)
const completionStatus = ref<CompletionStatus | null>(null)
const author = ref<string | null>(null)
const page = ref(0)
const hasMore = computed(() => items.value.length < total.value)
async function load(reset = false): Promise<void> {
if (loading.value) return
if (!reset && !hasMore.value) return
loading.value = true
error.value = null
if (reset) {
page.value = 0
items.value = []
}
try {
const data = await fetchSeries({
q: q.value.trim() || undefined,
page: page.value,
size: PAGE_SIZE,
sort: sort.value,
order: order.value,
libraryId: libraryId.value,
completionStatus: completionStatus.value,
author: author.value?.trim() || undefined,
})
items.value = reset ? data.items : [...items.value, ...data.items]
total.value = data.total
page.value += 1
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load series'
} finally {
loading.value = false
}
}
return {
items,
total,
loading,
error,
hasMore,
q,
sort,
order,
libraryId,
completionStatus,
author,
load,
}
}
@@ -0,0 +1,4 @@
export type SortDirection = 'asc' | 'desc'
export type SeriesListSort = 'name' | 'bookCount' | 'lastAddedAt' | 'readProgress'
export type SeriesBookSort = 'seriesIndex' | 'title' | 'addedAt'
export type CompletionStatus = 'not_started' | 'in_progress' | 'complete'
@@ -0,0 +1,270 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
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 VirtualBookGrid from '@/features/book/components/VirtualBookGrid.vue'
import BookListRow from '@/features/book/components/BookListRow.vue'
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 EntityNotFound from '@/components/EntityNotFound.vue'
import SeriesCompletionBar from '../components/SeriesCompletionBar.vue'
import SeriesGapBanner from '../components/SeriesGapBanner.vue'
import { useSeriesDetail } from '../composables/useSeriesDetail'
const route = useRoute()
const router = useRouter()
const { hasPermission } = usePermissions()
const { setBookContext } = useBookNavigation()
const { portraitCoverSize, gridGap, viewMode } = useDisplaySettings()
const { libraries, fetchLibraries } = useLibraries()
const seriesName = computed(() => {
const raw = route.params.seriesName
return typeof raw === 'string' ? raw : ''
})
const {
seriesInfo,
items: books,
total,
loading: loadingBooks,
error: booksError,
notFound,
hasMore,
sort,
order,
libraryId,
load: loadBooks,
} = useSeriesDetail(seriesName)
const pageTitle = computed(() => {
if (seriesInfo.value?.name) return `Series · ${seriesInfo.value.name}`
return seriesName.value || 'Series'
})
usePageTitle(pageTitle)
const sentinel = ref<HTMLElement | null>(null)
const openingSeriesEditor = ref(false)
let observer: IntersectionObserver | null = null
const canEditMetadata = computed(() => hasPermission('library_edit_metadata'))
function handleBookAction(book: BookCard, action: string) {
if (action === 'quick-view') {
void router.push({ name: 'book-detail', params: { bookId: book.id } })
}
}
function goBack() {
if (window.history.length > 1 && route.query.from) {
void router.push(route.query.from as string)
return
}
void router.push({ name: 'series' })
}
function onLibraryFilterChange(event: Event) {
const value = (event.target as HTMLSelectElement).value
libraryId.value = value ? Number(value) : null
}
async function editSeriesMetadata() {
if (!canEditMetadata.value || openingSeriesEditor.value || loadingBooks.value) return
if (books.value.length === 0) {
toast.error('This series has no books to edit.')
return
}
openingSeriesEditor.value = true
try {
while (hasMore.value) {
const beforeCount = books.value.length
await loadBooks()
if (booksError.value || books.value.length === beforeCount) break
}
if (booksError.value || hasMore.value) {
toast.error(booksError.value ?? 'Failed to load the full series for editing.')
return
}
const ids = books.value.map((book) => book.id)
if (ids.length === 0) {
toast.error('This series has no books to edit.')
return
}
setBookContext(ids, ids.length)
await router.push({ name: 'book-detail', params: { bookId: ids[0] }, query: { tab: 'edit' } })
} finally {
openingSeriesEditor.value = false
}
}
onMounted(async () => {
await fetchLibraries()
await loadBooks(true)
observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !loadingBooks.value) {
void loadBooks()
}
},
{ rootMargin: '280px' },
)
await nextTick()
if (sentinel.value) observer.observe(sentinel.value)
})
onUnmounted(() => {
observer?.disconnect()
})
watch(
[books, total],
([newBooks, newTotal]) => {
setBookContext(
newBooks.map((book) => book.id),
newTotal,
)
},
{ immediate: true },
)
watch([sort, order, libraryId], () => {
void loadBooks(true)
})
watch(seriesName, () => {
void loadBooks(true)
})
watch(
loadingBooks,
(isLoading) => {
if (!isLoading && sentinel.value) {
if (sentinel.value.getBoundingClientRect().top < window.innerHeight + 250) {
void loadBooks()
}
}
},
{ flush: 'post' },
)
</script>
<template>
<main class="flex-1 overflow-y-auto p-4 md:p-6">
<div class="mb-4">
<button class="flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground" @click="goBack">
<ChevronLeft :size="16" />
Back
</button>
</div>
<div v-if="notFound">
<EntityNotFound entity="Series" />
</div>
<template v-else>
<div v-if="booksError" class="mb-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{{ booksError }}
</div>
<!-- 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>
</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>
<!-- Loading state -->
<div v-if="loadingBooks && !seriesInfo" class="mb-4 rounded-xl border border-border/70 bg-card/60 p-4 text-sm text-muted-foreground">
Loading series...
</div>
<!-- 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>
<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>
</div>
</div>
<div v-if="!loadingBooks && books.length === 0" class="flex flex-col items-center justify-center gap-2 py-16 text-center">
<p class="text-sm font-medium text-foreground">No books found in this series</p>
<p class="text-xs text-muted-foreground">Try selecting another library.</p>
</div>
<VirtualBookGrid
v-if="viewMode === 'grid' && books.length > 0"
:books="books"
:cover-size="portraitCoverSize"
:grid-gap="gridGap"
@action="handleBookAction"
/>
<div v-if="viewMode === 'list' && books.length > 0" class="flex flex-col divide-y divide-border">
<BookListRow v-for="book in books" :key="book.id" :book="book" @action="handleBookAction(book, $event)" />
</div>
<div ref="sentinel" class="mt-4 flex h-8 items-center justify-center">
<span v-if="loadingBooks" class="text-xs text-muted-foreground">Loading...</span>
<span v-else-if="!hasMore && books.length > 0" class="text-xs text-muted-foreground"> All {{ total.toLocaleString() }} books loaded </span>
</div>
</section>
</template>
</main>
</template>
@@ -0,0 +1,497 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowUpDown, Filter, Search, SlidersHorizontal, X } from 'lucide-vue-next'
import ViewHeader from '@/components/ViewHeader.vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { storage } from '@/services/storage'
import SeriesCard from '../components/SeriesCard.vue'
import SeriesFilters from '../components/SeriesFilters.vue'
import { useSeriesList } from '../composables/useSeriesList'
import type { CompletionStatus, SeriesListSort, SortDirection } from '../types/series'
const router = useRouter()
const route = useRoute()
const { viewMode } = useDisplaySettings()
const { libraries, fetchLibraries } = useLibraries()
const { items, total, loading, error, hasMore, q, sort, order, libraryId, completionStatus, author, load } = useSeriesList()
const SERIES_CARD_WIDTH_STORAGE_KEY = 'projectx:seriesCardWidth'
const SERIES_GRID_GAP_STORAGE_KEY = 'projectx:seriesGridGap'
const SERIES_CARD_WIDTH_DEFAULT = 260
const SERIES_CARD_WIDTH_MIN = 240
const SERIES_CARD_WIDTH_MAX = 420
const SERIES_GRID_GAP_MIN = 8
const SERIES_GRID_GAP_MAX = 48
const hydrating = ref(true)
const suppressAutoReload = ref(false)
const filtersOpen = ref(false)
const mobileControlsExpanded = ref(false)
const mobileSearchOpen = ref(false)
const initialLoadComplete = ref(false)
const INITIAL_SKELETON_COUNT = 18
const sentinel = ref<HTMLElement | null>(null)
const mobileSearchInput = ref<HTMLInputElement | null>(null)
const seriesCardWidth = ref(
Math.min(Math.max(storage.get(SERIES_CARD_WIDTH_STORAGE_KEY, SERIES_CARD_WIDTH_DEFAULT), SERIES_CARD_WIDTH_MIN), SERIES_CARD_WIDTH_MAX),
)
const seriesGridGap = ref(Math.max(storage.get(SERIES_GRID_GAP_STORAGE_KEY, 20), SERIES_GRID_GAP_MIN))
let observer: IntersectionObserver | null = null
let searchTimer: ReturnType<typeof setTimeout> | null = null
const SORT_LABELS: Record<SeriesListSort, string> = {
name: 'Name',
bookCount: 'Book Count',
lastAddedAt: 'Recent Additions',
readProgress: 'Reading Progress',
}
const isDefaultSort = computed(() => sort.value === 'name' && order.value === 'asc')
const sortSummary = computed(() => `${SORT_LABELS[sort.value]} ${order.value === 'asc' ? '↑' : '↓'}`)
const activeFilterCount = computed(() => {
let count = 0
if (q.value.trim()) count += 1
if (libraryId.value !== null) count += 1
if (completionStatus.value !== null) count += 1
if (author.value) count += 1
return count
})
const mobileControlsBadgeCount = computed(() => activeFilterCount.value + (!isDefaultSort.value ? 1 : 0))
function parseSort(value: unknown): SeriesListSort {
return value === 'bookCount' || value === 'lastAddedAt' || value === 'readProgress' || value === 'name' ? value : 'name'
}
function parseOrder(value: unknown): SortDirection {
return value === 'desc' || value === 'asc' ? value : 'asc'
}
function parseLibraryId(value: unknown): number | null {
const raw = typeof value === 'string' ? Number(value) : NaN
return Number.isInteger(raw) && raw > 0 ? raw : null
}
function parseCompletionStatus(value: unknown): CompletionStatus | null {
if (value === 'not_started' || value === 'in_progress' || value === 'complete') return value
return null
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function syncRouteQuery() {
void router.replace({
name: 'series',
query: {
q: q.value.trim() || undefined,
sort: sort.value !== 'name' ? sort.value : undefined,
order: order.value !== 'asc' ? order.value : undefined,
libraryId: libraryId.value ? String(libraryId.value) : undefined,
completionStatus: completionStatus.value ?? undefined,
author: author.value || undefined,
},
})
}
function openSeries(seriesName: string) {
void router.push({ name: 'series-detail', params: { seriesName } })
}
function setSortField(field: SeriesListSort) {
sort.value = field
order.value = 'asc'
collapseMobileControlsIfNeeded()
}
function setSortOrder(dir: SortDirection) {
order.value = dir
collapseMobileControlsIfNeeded()
}
function resetSort() {
sort.value = 'name'
order.value = 'asc'
collapseMobileControlsIfNeeded()
}
function clearSearchQuery() {
q.value = ''
}
function toggleFiltersOpen() {
filtersOpen.value = !filtersOpen.value
}
function closeFiltersPanel() {
filtersOpen.value = false
}
function isMobileViewport() {
return typeof window !== 'undefined' && window.innerWidth < 640
}
function closeMobileControls() {
mobileControlsExpanded.value = false
mobileSearchOpen.value = false
}
function collapseMobileControlsIfNeeded() {
if (!mobileControlsExpanded.value) return
if (!isMobileViewport()) return
closeMobileControls()
}
function toggleMobileControls() {
if (mobileControlsExpanded.value) {
closeMobileControls()
return
}
mobileControlsExpanded.value = true
}
function toggleMobileSearch() {
mobileSearchOpen.value = !mobileSearchOpen.value
if (!mobileSearchOpen.value) return
void nextTick(() => {
mobileSearchInput.value?.focus()
})
}
function closeMobileSearch() {
mobileSearchOpen.value = false
}
async function clearFilters() {
if (searchTimer) clearTimeout(searchTimer)
suppressAutoReload.value = true
q.value = ''
sort.value = 'name'
order.value = 'asc'
libraryId.value = null
completionStatus.value = null
author.value = null
syncRouteQuery()
await load(true)
filtersOpen.value = false
collapseMobileControlsIfNeeded()
await nextTick()
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 loadIfSentinelVisible() {
if (loading.value || !hasMore.value || !sentinel.value) return
if (sentinel.value.getBoundingClientRect().top < window.innerHeight + 250) {
void load()
}
}
onMounted(async () => {
q.value = typeof route.query.q === 'string' ? route.query.q : ''
sort.value = parseSort(route.query.sort)
order.value = parseOrder(route.query.order)
libraryId.value = parseLibraryId(route.query.libraryId)
completionStatus.value = parseCompletionStatus(route.query.completionStatus)
author.value = typeof route.query.author === 'string' ? route.query.author : null
await fetchLibraries()
hydrating.value = false
filtersOpen.value = activeFilterCount.value > 0
await load(true)
initialLoadComplete.value = true
observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !loading.value) {
void load()
}
},
{ rootMargin: '280px' },
)
await nextTick()
if (sentinel.value) observer.observe(sentinel.value)
})
onUnmounted(() => {
observer?.disconnect()
if (searchTimer) clearTimeout(searchTimer)
})
watch([sort, order, libraryId, completionStatus, author], () => {
if (hydrating.value || suppressAutoReload.value) return
syncRouteQuery()
void load(true)
collapseMobileControlsIfNeeded()
})
watch(q, () => {
if (hydrating.value || suppressAutoReload.value) return
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
syncRouteQuery()
void load(true)
}, 250)
})
watch(
loading,
(isLoading) => {
if (!isLoading) loadIfSentinelVisible()
},
{ flush: 'post' },
)
watch(seriesCardWidth, (value) => {
const normalized = clamp(value, SERIES_CARD_WIDTH_MIN, SERIES_CARD_WIDTH_MAX)
if (normalized !== value) {
seriesCardWidth.value = normalized
return
}
storage.set(SERIES_CARD_WIDTH_STORAGE_KEY, normalized)
})
watch(seriesGridGap, (value) => {
const normalized = clamp(value, SERIES_GRID_GAP_MIN, SERIES_GRID_GAP_MAX)
if (normalized !== value) {
seriesGridGap.value = normalized
return
}
storage.set(SERIES_GRID_GAP_STORAGE_KEY, normalized)
})
</script>
<template>
<ViewHeader
title="Series"
icon="Library"
:total="total"
:view-mode="viewMode"
v-model:coverSize="seriesCardWidth"
v-model:gridGap="seriesGridGap"
:cover-size-min="SERIES_CARD_WIDTH_MIN"
:cover-size-max="SERIES_CARD_WIDTH_MAX"
:cover-size-step="20"
:grid-gap-min="SERIES_GRID_GAP_MIN"
:grid-gap-max="SERIES_GRID_GAP_MAX"
>
<template #toolbar>
<div class="hidden lg:flex h-8 w-64 items-center rounded-md border border-input bg-background px-2.5">
<Search :size="13" class="mr-1.5 shrink-0 text-muted-foreground/85" />
<input
v-model="q"
type="search"
placeholder="Search series"
class="h-full w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground/85"
/>
<button v-if="q.trim()" class="ml-1 text-muted-foreground/85 transition-colors hover:text-foreground" @click="clearSearchQuery">
<X :size="12" />
</button>
</div>
<div class="hidden lg:block h-5 w-px shrink-0 bg-border" />
<div class="hidden sm:flex items-center gap-1">
<Popover>
<PopoverTrigger as-child>
<button
class="flex h-8 items-center gap-1.5 rounded-md border px-3 text-sm transition-colors"
:class="
!isDefaultSort
? 'border-primary text-primary bg-primary/10'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
>
<ArrowUpDown :size="13" />
<span class="hidden lg:inline">{{ sortSummary }}</span>
<span class="lg:hidden">Sort</span>
</button>
</PopoverTrigger>
<PopoverContent align="start" class="w-56 p-2">
<div class="mb-2 px-1 text-xs font-medium text-muted-foreground">Sort by</div>
<div class="flex flex-col gap-0.5">
<button
v-for="field in ['name', 'bookCount', 'lastAddedAt', 'readProgress'] as const"
:key="field"
class="flex items-center justify-between rounded-sm px-2 py-1.5 text-sm transition-colors hover:bg-muted"
:class="sort === field ? 'text-foreground font-medium' : 'text-muted-foreground'"
@click="setSortField(field)"
>
{{ SORT_LABELS[field] }}
<span v-if="sort === field" class="text-xs text-primary">{{ order === 'asc' ? '↑' : '↓' }}</span>
</button>
</div>
<div class="my-2 border-t border-border" />
<div class="flex gap-1">
<button
v-for="dir in ['asc', 'desc'] as const"
:key="dir"
class="flex-1 rounded-sm px-2 py-1.5 text-sm transition-colors hover:bg-muted"
:class="order === dir ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground'"
@click="setSortOrder(dir)"
>
{{ dir === 'asc' ? 'Ascending' : 'Descending' }}
</button>
</div>
</PopoverContent>
</Popover>
<button
v-if="!isDefaultSort"
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-destructive hover:bg-muted"
@click="resetSort"
>
<X :size="13" />
</button>
</div>
<div class="hidden sm:block h-5 w-px shrink-0 bg-border" />
<button
class="hidden sm:flex h-8 items-center gap-1.5 rounded-md border px-3 text-sm transition-colors"
:class="
activeFilterCount > 0
? 'border-primary text-primary bg-primary/10'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
@click="toggleFiltersOpen"
>
<Filter :size="13" />
<span>Filters</span>
<span v-if="activeFilterCount > 0" class="text-xs font-semibold">({{ activeFilterCount }})</span>
</button>
<button
v-if="activeFilterCount > 0 || !isDefaultSort"
class="hidden sm:flex h-8 items-center gap-1 rounded-md px-2 text-sm text-muted-foreground transition-colors hover:text-destructive"
@click="clearFilters"
>
<X :size="13" />
Clear
</button>
<button
class="sm:hidden relative flex h-8 w-8 items-center justify-center rounded-md border transition-colors"
:class="
mobileControlsExpanded
? 'border-primary text-primary bg-primary/10'
: 'border-input bg-background text-muted-foreground hover:bg-muted hover:text-foreground'
"
@click="toggleMobileControls"
>
<SlidersHorizontal :size="14" />
<span
v-if="mobileControlsBadgeCount > 0"
class="absolute -right-1 -top-1 inline-flex min-h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-none text-primary-foreground"
>
{{ mobileControlsBadgeCount }}
</span>
</button>
</template>
</ViewHeader>
<!-- Mobile controls -->
<section v-if="mobileControlsExpanded" class="mb-3 space-y-2 rounded-lg border border-border/70 bg-card/70 p-2 sm:hidden">
<div class="flex items-center gap-1">
<button
class="flex h-9 flex-1 items-center justify-center gap-1.5 rounded-md border border-input text-sm text-muted-foreground transition-colors hover:bg-muted"
@click="toggleMobileSearch"
>
<Search :size="13" />
{{ q.trim() ? `"${q.trim()}"` : 'Search' }}
</button>
</div>
<div v-if="mobileSearchOpen" class="flex items-center gap-1 rounded-md border border-input bg-background px-2.5">
<Search :size="13" class="shrink-0 text-muted-foreground/85" />
<input
ref="mobileSearchInput"
v-model="q"
type="search"
placeholder="Search series"
class="h-9 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground/85"
/>
<button v-if="q.trim()" class="text-muted-foreground/85 hover:text-foreground" @click="clearSearchQuery">
<X :size="12" />
</button>
<button class="ml-1 text-xs text-muted-foreground hover:text-foreground" @click="closeMobileSearch">Done</button>
</div>
<SeriesFilters
:library-id="libraryId"
:libraries="libraries"
: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)"
@clear="clearFilters"
/>
</section>
<!-- Desktop filters panel -->
<SeriesFilters
v-if="filtersOpen && !mobileControlsExpanded"
:library-id="libraryId"
:libraries="libraries"
:completion-status="completionStatus"
:author="author"
:active-count="activeFilterCount"
closable
@update:library-id="handleFilterUpdate('libraryId', $event)"
@update:completion-status="handleFilterUpdate('completionStatus', $event)"
@update:author="handleFilterUpdate('author', $event)"
@clear="clearFilters"
@close="closeFiltersPanel"
/>
<!-- Grid -->
<div
class="grid"
:style="{
gridTemplateColumns: `repeat(auto-fill, minmax(${seriesCardWidth}px, 1fr))`,
gap: `${seriesGridGap}px`,
}"
>
<template v-if="!initialLoadComplete">
<div v-for="n in INITIAL_SKELETON_COUNT" :key="n" class="overflow-hidden rounded-xl border border-border/60 bg-card">
<div class="animate-pulse bg-muted/80" style="aspect-ratio: 11 / 8" />
<div class="space-y-2 px-4 py-3">
<div class="h-4 w-2/3 rounded bg-muted/80" />
<div class="h-3 w-1/2 rounded bg-muted/70" />
</div>
<div class="h-1.5 w-full animate-pulse bg-muted/80" />
</div>
</template>
<template v-else>
<SeriesCard v-for="series in items" :key="series.name" :series="series" @open="openSeries" />
</template>
</div>
<!-- Empty state -->
<div v-if="initialLoadComplete && items.length === 0 && !loading" class="mt-12 text-center text-sm text-muted-foreground">
<p v-if="activeFilterCount > 0">No series match your filters.</p>
<p v-else>No series found in your libraries.</p>
</div>
<!-- Error state -->
<div v-if="error" class="mt-8 text-center text-sm text-destructive">{{ error }}</div>
<!-- Infinite scroll sentinel -->
<div ref="sentinel" class="h-px" />
</template>
+12
View File
@@ -209,6 +209,18 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/features/author/views/AuthorDetailView.vue'),
meta: { title: (to) => fallbackById('Author', numericParam(to, 'id')) },
},
{
path: '/series',
name: 'series',
component: () => import('@/features/series/views/SeriesView.vue'),
meta: { title: 'Series' },
},
{
path: '/series/:seriesName',
name: 'series-detail',
component: () => import('@/features/series/views/SeriesDetailView.vue'),
meta: { title: (to) => `Series · ${firstText(to.params.seriesName) ?? 'Series'}` },
},
{
path: '/book/:bookId',
name: 'book-detail',
+1
View File
@@ -27,3 +27,4 @@ export * from "./statistics";
export * from "./user-statistics";
export * from "./migration";
export * from "./notification";
export * from "./series";
+29
View File
@@ -0,0 +1,29 @@
import type { BooksPage } from "./book";
export type SeriesSummary = {
name: string;
bookCount: number;
readCount: number;
authors: string[];
coverBookIds: number[];
lastAddedAt: string | null;
};
export type SeriesPage = {
items: SeriesSummary[];
total: number;
page: number;
size: number;
};
export type SeriesDetail = {
name: string;
bookCount: number;
readCount: number;
authors: string[];
possibleGaps: number[];
};
export type SeriesBooksPage = BooksPage & {
seriesInfo: SeriesDetail;
};
+2
View File
@@ -53,6 +53,7 @@ import { UserBookStatusModule } from './modules/user-book-status/user-book-statu
import { AuditModule } from './modules/audit/audit.module';
import { MigrationModule } from './modules/migration/migration.module';
import { NotificationModule } from './modules/notification/notification.module';
import { SeriesModule } from './modules/series/series.module';
@Module({
imports: [
@@ -114,6 +115,7 @@ import { NotificationModule } from './modules/notification/notification.module';
AuditModule,
MigrationModule,
NotificationModule,
SeriesModule,
],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
@@ -0,0 +1,36 @@
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
import { SORT_DIRECTIONS, type SortDirection } from './list-series.dto';
export const SERIES_BOOK_SORTS = ['seriesIndex', 'title', 'addedAt'] as const;
export type SeriesBookSort = (typeof SERIES_BOOK_SORTS)[number];
export class ListSeriesBooksDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
page?: number = 0;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
size?: number = 50;
@IsOptional()
@IsIn(SERIES_BOOK_SORTS)
sort?: SeriesBookSort = 'seriesIndex';
@IsOptional()
@IsIn(SORT_DIRECTIONS)
order?: SortDirection = 'asc';
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
libraryId?: number;
}
@@ -0,0 +1,56 @@
import { Transform, Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
export const SERIES_LIST_SORTS = ['name', 'bookCount', 'lastAddedAt', 'readProgress'] as const;
export type SeriesListSort = (typeof SERIES_LIST_SORTS)[number];
export const SORT_DIRECTIONS = ['asc', 'desc'] as const;
export type SortDirection = (typeof SORT_DIRECTIONS)[number];
export const COMPLETION_STATUSES = ['not_started', 'in_progress', 'complete'] as const;
export type CompletionStatus = (typeof COMPLETION_STATUSES)[number];
export class ListSeriesDto {
@IsOptional()
@IsString()
@MaxLength(500)
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
q?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
page?: number = 0;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
size?: number = 50;
@IsOptional()
@IsIn(SERIES_LIST_SORTS)
sort?: SeriesListSort = 'name';
@IsOptional()
@IsIn(SORT_DIRECTIONS)
order?: SortDirection = 'asc';
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
libraryId?: number;
@IsOptional()
@IsIn(COMPLETION_STATUSES)
completionStatus?: CompletionStatus;
@IsOptional()
@IsString()
@MaxLength(500)
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
author?: string;
}
@@ -0,0 +1,77 @@
import 'reflect-metadata';
import type { RequestUser } from '../../common/types/request-user';
import { SeriesController } from './series.controller';
function makeUser(overrides?: Partial<RequestUser>): RequestUser {
return {
id: 11,
username: 'series-reader',
name: 'Series Reader',
email: null,
active: true,
isSuperuser: false,
isDefaultPassword: false,
tokenVersion: 1,
settings: {},
avatarUrl: null,
provisioningMethod: 'local',
permissions: [],
...overrides,
};
}
function makeController() {
const seriesService = {
findAll: vi.fn(),
findBooks: vi.fn(),
};
const controller = new SeriesController(seriesService as any);
return { controller, seriesService };
}
describe('SeriesController', () => {
it('findAll delegates to service', async () => {
const { controller, seriesService } = makeController();
const user = makeUser();
const dto = { page: 0, size: 50 };
const expected = { items: [], total: 0, page: 0, size: 50 };
seriesService.findAll.mockResolvedValue(expected);
const result = await controller.findAll(user, dto as any);
expect(seriesService.findAll).toHaveBeenCalledWith(user, dto);
expect(result).toBe(expected);
});
it('findBooks delegates to service with raw param', async () => {
const { controller, seriesService } = makeController();
const user = makeUser();
const dto = { page: 0, size: 50 };
const expected = {
items: [],
total: 0,
page: 0,
size: 50,
seriesInfo: { name: 'Harry Potter', bookCount: 0, readCount: 0, authors: [], possibleGaps: [] },
};
seriesService.findBooks.mockResolvedValue(expected);
const result = await controller.findBooks(user, 'Harry Potter', dto as any);
expect(seriesService.findBooks).toHaveBeenCalledWith(user, 'Harry Potter', dto);
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: [] });
await controller.findBooks(user, 'Simple Series', dto as any);
expect(seriesService.findBooks).toHaveBeenCalledWith(user, 'Simple Series', dto);
});
});
@@ -0,0 +1,22 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { ListSeriesBooksDto } from './dto/list-series-books.dto';
import { ListSeriesDto } from './dto/list-series.dto';
import { SeriesService } from './series.service';
@Controller('series')
export class SeriesController {
constructor(private readonly seriesService: SeriesService) {}
@Get()
findAll(@CurrentUser() user: RequestUser, @Query() dto: ListSeriesDto) {
return this.seriesService.findAll(user, dto);
}
@Get(':seriesName/books')
findBooks(@CurrentUser() user: RequestUser, @Param('seriesName') seriesName: string, @Query() dto: ListSeriesBooksDto) {
return this.seriesService.findBooks(user, seriesName, dto);
}
}
@@ -0,0 +1,21 @@
import 'reflect-metadata';
vi.mock('../book/book.module', () => ({ BookModule: class BookModule {} }));
vi.mock('../library/library.module', () => ({ LibraryModule: class LibraryModule {} }));
import { MODULE_METADATA } from '@nestjs/common/constants';
import { SeriesController } from './series.controller';
import { SeriesModule } from './series.module';
import { SeriesRepository } from './series.repository';
import { SeriesService } from './series.service';
describe('SeriesModule', () => {
it('registers expected controller and provider graph', () => {
const controllers = Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, SeriesModule);
const providers = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, SeriesModule) as Array<unknown>;
expect(controllers).toEqual([SeriesController]);
expect(providers).toEqual(expect.arrayContaining([SeriesService, SeriesRepository]));
});
});
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { BookModule } from '../book/book.module';
import { LibraryModule } from '../library/library.module';
import { SeriesController } from './series.controller';
import { SeriesRepository } from './series.repository';
import { SeriesService } from './series.service';
@Module({
imports: [BookModule, LibraryModule],
controllers: [SeriesController],
providers: [SeriesService, SeriesRepository],
})
export class SeriesModule {}
@@ -0,0 +1,344 @@
import { Inject, Injectable } from '@nestjs/common';
import { SQL, and, asc, count, desc, eq, ilike, inArray, isNotNull, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { authors, bookAuthors, bookMetadata, books, userBookStatus } from '../../db/schema';
import type { SeriesListSort, SortDirection } from './dto/list-series.dto';
import type { SeriesBookSort } from './dto/list-series-books.dto';
type Db = NodePgDatabase<typeof schema>;
type SeriesSummaryRow = {
name: string;
bookCount: number;
readCount: number;
authors: string[];
coverBookIds: number[];
lastAddedAt: string | null;
};
type SeriesDetailRow = {
name: string;
bookCount: number;
readCount: number;
authors: string[];
indices: number[];
};
@Injectable()
export class SeriesRepository {
constructor(@Inject(DB) private readonly db: Db) {}
private normalizedSeriesName() {
return sql<string>`lower(btrim(${bookMetadata.seriesName}))`;
}
private buildLibraryFilter(libraryIds: number[]): SQL {
return inArray(books.libraryId, libraryIds);
}
async findPage(params: {
q?: string;
page: number;
size: number;
sort: SeriesListSort;
order: SortDirection;
libraryIds: number[];
userId: number;
completionStatus?: string;
author?: string;
}): Promise<{ items: SeriesSummaryRow[]; total: number; page: number; size: number }> {
const normalized = this.normalizedSeriesName();
const libraryFilter = this.buildLibraryFilter(params.libraryIds);
const conditions: SQL[] = [isNotNull(bookMetadata.seriesName), sql`btrim(${bookMetadata.seriesName}) != ''`, libraryFilter];
if (params.q) {
conditions.push(ilike(bookMetadata.seriesName, `%${params.q}%`));
}
if (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}%`)}
)`,
);
}
const baseWhere = and(...conditions)!;
const bookCountExpr = sql<number>`count(distinct ${books.id})::int`;
const readCountExpr = sql<number>`count(distinct CASE WHEN ${userBookStatus.status} = 'read' THEN ${books.id} END)::int`;
const lastAddedExpr = sql<string | null>`max(${books.addedAt})::text`;
const readProgressExpr = sql<number>`
CASE WHEN count(distinct ${books.id}) = 0 THEN 0
ELSE (count(distinct CASE WHEN ${userBookStatus.status} = 'read' THEN ${books.id} END)::float / count(distinct ${books.id})::float)
END`;
const displayNameExpr = sql<string>`min(btrim(${bookMetadata.seriesName}))`;
const completionHaving = this.buildCompletionHaving(params.completionStatus, bookCountExpr, readCountExpr);
const sortExpr = this.buildSortExpression(params.sort, params.order, displayNameExpr, bookCountExpr, lastAddedExpr, readProgressExpr);
const baseQuery = this.db
.select({
name: displayNameExpr,
bookCount: bookCountExpr,
readCount: readCountExpr,
lastAddedAt: lastAddedExpr,
})
.from(books)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.leftJoin(userBookStatus, and(eq(userBookStatus.bookId, books.id), eq(userBookStatus.userId, params.userId)))
.where(baseWhere)
.groupBy(normalized);
const countQuery = this.db
.select({ total: sql<number>`count(*)::int` })
.from((completionHaving ? baseQuery.having(completionHaving) : baseQuery).as('series_groups'));
const dataQuery = this.db
.select({
name: displayNameExpr,
bookCount: bookCountExpr,
readCount: readCountExpr,
lastAddedAt: lastAddedExpr,
})
.from(books)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.leftJoin(userBookStatus, and(eq(userBookStatus.bookId, books.id), eq(userBookStatus.userId, params.userId)))
.where(baseWhere)
.groupBy(normalized)
.$dynamic();
if (completionHaving) {
dataQuery.having(completionHaving);
}
dataQuery.orderBy(...sortExpr);
dataQuery.limit(params.size);
dataQuery.offset(params.page * params.size);
const [countResult, seriesRows] = await Promise.all([countQuery, dataQuery]);
const total = countResult[0]?.total ?? 0;
if (seriesRows.length === 0) {
return { items: [], total, page: params.page, size: params.size };
}
const seriesNames = seriesRows.map((r) => r.name);
const [authorData, coverData] = await Promise.all([
this.fetchAuthorsForSeries(seriesNames, params.libraryIds),
this.fetchCoverBookIds(seriesNames, params.libraryIds),
]);
const items: SeriesSummaryRow[] = seriesRows.map((row) => ({
name: row.name,
bookCount: row.bookCount,
readCount: row.readCount,
authors: authorData.get(row.name.toLowerCase().trim()) ?? [],
coverBookIds: coverData.get(row.name.toLowerCase().trim()) ?? [],
lastAddedAt: row.lastAddedAt,
}));
return { items, total, page: params.page, size: params.size };
}
async findDetail(params: { seriesName: string; userId: number; libraryIds: number[] }): Promise<SeriesDetailRow | null> {
const libraryFilter = this.buildLibraryFilter(params.libraryIds);
const rows = await this.db
.select({
displayName: sql<string>`min(btrim(${bookMetadata.seriesName}))`,
bookCount: sql<number>`count(distinct ${books.id})::int`,
readCount: sql<number>`count(distinct CASE WHEN ${userBookStatus.status} = 'read' THEN ${books.id} END)::int`,
})
.from(books)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.leftJoin(userBookStatus, and(eq(userBookStatus.bookId, books.id), eq(userBookStatus.userId, params.userId)))
.where(and(sql`lower(btrim(${bookMetadata.seriesName})) = lower(btrim(${params.seriesName}))`, libraryFilter))
.groupBy(this.normalizedSeriesName());
if (rows.length === 0) return null;
const row = rows[0];
const normalizedName = params.seriesName.toLowerCase().trim();
const [authorsMap, indicesRows] = await Promise.all([
this.fetchAuthorsForSeries([row.displayName], params.libraryIds),
this.db
.select({ idx: bookMetadata.seriesIndex })
.from(books)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(
and(sql`lower(btrim(${bookMetadata.seriesName})) = lower(btrim(${params.seriesName}))`, libraryFilter, isNotNull(bookMetadata.seriesIndex)),
),
]);
const indices = indicesRows.map((r) => r.idx!);
return {
name: row.displayName,
bookCount: row.bookCount,
readCount: row.readCount,
authors: authorsMap.get(normalizedName) ?? [],
indices,
};
}
async findBookIds(params: {
seriesName: string;
page: number;
size: number;
sort: SeriesBookSort;
order: SortDirection;
libraryIds: number[];
}): Promise<{ bookIds: number[]; total: number }> {
const libraryFilter = this.buildLibraryFilter(params.libraryIds);
const seriesMatch = sql`lower(btrim(${bookMetadata.seriesName})) = lower(btrim(${params.seriesName}))`;
const where = and(seriesMatch, libraryFilter)!;
const orderBy = this.buildBookSortExpression(params.sort, params.order);
const [dataRows, [{ total }]] = await Promise.all([
this.db
.select({ id: books.id })
.from(books)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(where)
.orderBy(...orderBy)
.limit(params.size)
.offset(params.page * params.size),
this.db.select({ total: count() }).from(books).innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id)).where(where),
]);
return { bookIds: dataRows.map((r) => r.id), total: Number(total) };
}
private async fetchAuthorsForSeries(seriesNames: string[], libraryIds: number[]): Promise<Map<string, string[]>> {
if (seriesNames.length === 0) return new Map();
const normalized = seriesNames.map((n) => n.toLowerCase().trim());
const rows = await this.db
.select({
normalizedSeries: sql<string>`lower(btrim(${bookMetadata.seriesName}))`,
authorName: authors.name,
})
.from(books)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.innerJoin(bookAuthors, eq(bookAuthors.bookId, books.id))
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(
and(
sql`lower(btrim(${bookMetadata.seriesName})) IN (${sql.join(
normalized.map((n) => sql`${n}`),
sql`, `,
)})`,
this.buildLibraryFilter(libraryIds),
),
)
.groupBy(sql`lower(btrim(${bookMetadata.seriesName}))`, authors.name);
const result = new Map<string, string[]>();
for (const row of rows) {
const key = row.normalizedSeries;
const list = result.get(key) ?? [];
list.push(row.authorName);
result.set(key, list);
}
return result;
}
private async fetchCoverBookIds(seriesNames: string[], libraryIds: number[]): Promise<Map<string, number[]>> {
if (seriesNames.length === 0) return new Map();
const normalized = seriesNames.map((n) => n.toLowerCase().trim());
const rows = await this.db
.select({
normalizedSeries: sql<string>`lower(btrim(${bookMetadata.seriesName}))`,
bookId: books.id,
seriesIndex: bookMetadata.seriesIndex,
})
.from(books)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(
and(
sql`lower(btrim(${bookMetadata.seriesName})) IN (${sql.join(
normalized.map((n) => sql`${n}`),
sql`, `,
)})`,
this.buildLibraryFilter(libraryIds),
isNotNull(bookMetadata.coverSource),
),
)
.orderBy(sql`lower(btrim(${bookMetadata.seriesName}))`, asc(bookMetadata.seriesIndex), asc(books.addedAt));
const result = new Map<string, number[]>();
for (const row of rows) {
const key = row.normalizedSeries;
const list = result.get(key) ?? [];
if (list.length < 9) {
list.push(row.bookId);
}
result.set(key, list);
}
return result;
}
private buildCompletionHaving(status: string | undefined, bookCountExpr: SQL<number>, readCountExpr: SQL<number>): SQL | undefined {
if (!status) return undefined;
switch (status) {
case 'not_started':
return sql`${readCountExpr} = 0`;
case 'in_progress':
return sql`${readCountExpr} > 0 AND ${readCountExpr} < ${bookCountExpr}`;
case 'complete':
return sql`${readCountExpr} = ${bookCountExpr}`;
default:
return undefined;
}
}
private buildSortExpression(
sort: SeriesListSort,
order: SortDirection,
nameExpr: SQL<string>,
bookCountExpr: SQL<number>,
lastAddedExpr: SQL<string | null>,
readProgressExpr: SQL<number>,
): SQL[] {
const dir = order === 'asc' ? asc : desc;
const tiebreaker = asc(nameExpr);
switch (sort) {
case 'bookCount':
return [dir(bookCountExpr), tiebreaker];
case 'lastAddedAt':
return [dir(lastAddedExpr), tiebreaker];
case 'readProgress':
return [dir(readProgressExpr), tiebreaker];
case 'name':
default:
return [dir(nameExpr)];
}
}
private buildBookSortExpression(sort: SeriesBookSort, order: SortDirection): SQL[] {
const dir = order === 'asc' ? asc : desc;
switch (sort) {
case 'title':
return [dir(bookMetadata.title), asc(books.id)];
case 'addedAt':
return [dir(books.addedAt), asc(books.id)];
case 'seriesIndex':
default:
return [order === 'asc' ? sql`${bookMetadata.seriesIndex} ASC NULLS LAST` : sql`${bookMetadata.seriesIndex} DESC NULLS LAST`, asc(books.id)];
}
}
}
@@ -0,0 +1,278 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { SeriesService } from './series.service';
function reqUser(id = 7, superuser = false) {
return { id, isSuperuser: superuser, permissions: [] } as any;
}
describe('SeriesService', () => {
const seriesRepo = {
findPage: vi.fn(),
findDetail: vi.fn(),
findBookIds: vi.fn(),
};
const bookReadService = {
findCardsByBookIds: vi.fn(),
};
const libraryService = {
findAll: vi.fn(),
};
let service: SeriesService;
beforeEach(() => {
vi.resetAllMocks();
service = new SeriesService(seriesRepo as any, bookReadService as any, libraryService as any);
libraryService.findAll.mockResolvedValue([{ id: 1 }, { id: 2 }]);
});
describe('findAll', () => {
it('returns empty page when user has no library access', async () => {
libraryService.findAll.mockResolvedValue([]);
const result = await service.findAll(reqUser(), { page: 0, size: 50 });
expect(result).toEqual({ items: [], total: 0, page: 0, size: 50 });
expect(seriesRepo.findPage).not.toHaveBeenCalled();
});
it('delegates to repository with correct params', async () => {
seriesRepo.findPage.mockResolvedValue({
items: [
{
name: 'Harry Potter',
bookCount: 7,
readCount: 3,
authors: ['J.K. Rowling'],
coverBookIds: [1, 2, 3, 4],
lastAddedAt: '2024-01-01 00:00:00',
},
],
total: 1,
page: 0,
size: 50,
});
const result = await service.findAll(reqUser(), { sort: 'bookCount', order: 'desc' });
expect(seriesRepo.findPage).toHaveBeenCalledWith(
expect.objectContaining({
sort: 'bookCount',
order: 'desc',
libraryIds: [1, 2],
userId: 7,
}),
);
expect(result.items).toHaveLength(1);
expect(result.items[0]!.name).toBe('Harry Potter');
expect(result.items[0]!.lastAddedAt).toBe('2024-01-01 00:00:00');
});
it('scopes to specific library when libraryId provided', async () => {
seriesRepo.findPage.mockResolvedValue({ items: [], total: 0, page: 0, size: 50 });
await service.findAll(reqUser(), { libraryId: 2 });
expect(seriesRepo.findPage).toHaveBeenCalledWith(expect.objectContaining({ libraryIds: [2] }));
});
it('returns empty when scoped library is inaccessible', async () => {
const result = await service.findAll(reqUser(), { libraryId: 99 });
expect(result).toEqual({ items: [], total: 0, page: 0, size: 50 });
});
it('rejects deep pagination', async () => {
await expect(service.findAll(reqUser(), { page: 10000, size: 100 })).rejects.toBeInstanceOf(BadRequestException);
});
it('converts null lastAddedAt to null', async () => {
seriesRepo.findPage.mockResolvedValue({
items: [{ name: 'Test', bookCount: 1, readCount: 0, authors: [], coverBookIds: [], lastAddedAt: null }],
total: 1,
page: 0,
size: 50,
});
const result = await service.findAll(reqUser(), {});
expect(result.items[0]!.lastAddedAt).toBeNull();
});
});
describe('findBooks', () => {
it('throws NotFoundException when no libraries accessible', async () => {
libraryService.findAll.mockResolvedValue([]);
await expect(service.findBooks(reqUser(), 'Harry Potter', {})).rejects.toBeInstanceOf(NotFoundException);
});
it('throws NotFoundException when series not found', async () => {
seriesRepo.findDetail.mockResolvedValue(null);
seriesRepo.findBookIds.mockResolvedValue({ bookIds: [], total: 0 });
await expect(service.findBooks(reqUser(), 'Nonexistent', {})).rejects.toBeInstanceOf(NotFoundException);
});
it('returns books with series info and gap detection', async () => {
seriesRepo.findDetail.mockResolvedValue({
name: 'Dune',
bookCount: 3,
readCount: 1,
authors: ['Frank Herbert'],
indices: [1, 2, 4],
});
seriesRepo.findBookIds.mockResolvedValue({ bookIds: [10, 11, 12], total: 3 });
bookReadService.findCardsByBookIds.mockResolvedValue({
rows: [
{
id: 10,
status: 'present',
folderPath: '/a',
addedAt: new Date(),
title: 'Dune',
seriesName: 'Dune',
seriesIndex: 1,
publishedYear: null,
language: null,
rating: null,
coverSource: null,
lockedFields: null,
},
{
id: 11,
status: 'present',
folderPath: '/b',
addedAt: new Date(),
title: 'Dune Messiah',
seriesName: 'Dune',
seriesIndex: 2,
publishedYear: null,
language: null,
rating: null,
coverSource: null,
lockedFields: null,
},
{
id: 12,
status: 'present',
folderPath: '/c',
addedAt: new Date(),
title: 'Children of Dune',
seriesName: 'Dune',
seriesIndex: 4,
publishedYear: null,
language: null,
rating: null,
coverSource: null,
lockedFields: null,
},
],
authorRows: [],
fileRows: [],
genreRows: [],
progressRows: [],
statusRows: [],
total: 3,
});
const result = await service.findBooks(reqUser(), 'Dune', {});
expect(result.seriesInfo.possibleGaps).toEqual([3]);
expect(result.seriesInfo.authors).toEqual(['Frank Herbert']);
expect(result.items).toHaveLength(3);
expect(result.total).toBe(3);
});
it('preserves book order from repository', async () => {
seriesRepo.findDetail.mockResolvedValue({ name: 'Test', bookCount: 2, readCount: 0, authors: [], indices: [1, 2] });
seriesRepo.findBookIds.mockResolvedValue({ bookIds: [20, 10], total: 2 });
bookReadService.findCardsByBookIds.mockResolvedValue({
rows: [
{
id: 10,
status: 'present',
folderPath: '/a',
addedAt: new Date(),
title: 'B',
seriesName: 'Test',
seriesIndex: 2,
publishedYear: null,
language: null,
rating: null,
coverSource: null,
lockedFields: null,
},
{
id: 20,
status: 'present',
folderPath: '/b',
addedAt: new Date(),
title: 'A',
seriesName: 'Test',
seriesIndex: 1,
publishedYear: null,
language: null,
rating: null,
coverSource: null,
lockedFields: null,
},
],
authorRows: [],
fileRows: [],
genreRows: [],
progressRows: [],
statusRows: [],
total: 2,
});
const result = await service.findBooks(reqUser(), 'Test', {});
expect(result.items[0]!.id).toBe(20);
expect(result.items[1]!.id).toBe(10);
});
it('handles empty book list gracefully', async () => {
seriesRepo.findDetail.mockResolvedValue({ name: 'Empty', bookCount: 0, readCount: 0, authors: [], indices: [] });
seriesRepo.findBookIds.mockResolvedValue({ bookIds: [], total: 0 });
const result = await service.findBooks(reqUser(), 'Empty', {});
expect(result.items).toEqual([]);
expect(result.seriesInfo.possibleGaps).toEqual([]);
});
it('rejects deep pagination', async () => {
await expect(service.findBooks(reqUser(), 'Test', { page: 10000, size: 100 })).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('computeGaps (via findBooks)', () => {
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] });
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] });
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] });
const result = await service.findBooks(reqUser(), 'S', {});
expect(result.seriesInfo.possibleGaps).toEqual([2]);
});
});
});
+144
View File
@@ -0,0 +1,144 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { BooksPage, SeriesBooksPage, SeriesDetail, SeriesPage, SeriesSummary } from '@projectx/types';
import { MAX_OFFSET_ROWS, isOffsetWithinLimit } from '../../common/constants/pagination.constants';
import type { RequestUser } from '../../common/types/request-user';
import { assembleBookCards } from '../book/utils/assemble-book-cards';
import { BookReadService } from '../book/book-read.service';
import { LibraryService } from '../library/library.service';
import { ListSeriesBooksDto } from './dto/list-series-books.dto';
import { ListSeriesDto } from './dto/list-series.dto';
import { SeriesRepository } from './series.repository';
@Injectable()
export class SeriesService {
private readonly logger = new Logger(SeriesService.name);
constructor(
private readonly seriesRepo: SeriesRepository,
private readonly bookReadService: BookReadService,
private readonly libraryService: LibraryService,
) {}
private assertPaginationWindow(page: number, size: number): void {
if (!isOffsetWithinLimit(page * size)) {
throw new BadRequestException(`pagination window is too deep; page * size must be <= ${MAX_OFFSET_ROWS}`);
}
}
async findAll(user: RequestUser, dto: ListSeriesDto): Promise<SeriesPage> {
const page = dto.page ?? 0;
const size = dto.size ?? 50;
this.assertPaginationWindow(page, size);
const libraryIds = await this.resolveLibraryIds(user, dto.libraryId);
if (libraryIds.length === 0) {
return { items: [], total: 0, page, size };
}
const result = await this.seriesRepo.findPage({
q: dto.q,
page,
size,
sort: dto.sort ?? 'name',
order: dto.order ?? 'asc',
libraryIds,
userId: user.id,
completionStatus: dto.completionStatus,
author: dto.author,
});
const items: SeriesSummary[] = result.items.map((row) => ({
name: row.name,
bookCount: row.bookCount,
readCount: row.readCount,
authors: row.authors,
coverBookIds: row.coverBookIds,
lastAddedAt: row.lastAddedAt ?? null,
}));
return { items, total: result.total, page: result.page, size: result.size };
}
async findBooks(user: RequestUser, seriesName: string, dto: ListSeriesBooksDto): Promise<SeriesBooksPage> {
const page = dto.page ?? 0;
const size = dto.size ?? 50;
this.assertPaginationWindow(page, size);
const libraryIds = await this.resolveLibraryIds(user, dto.libraryId);
if (libraryIds.length === 0) {
throw new NotFoundException('Series not found');
}
const [detail, bookPage] = await Promise.all([
this.seriesRepo.findDetail({ seriesName, userId: user.id, libraryIds }),
this.seriesRepo.findBookIds({
seriesName,
page,
size,
sort: dto.sort ?? 'seriesIndex',
order: dto.order ?? 'asc',
libraryIds,
}),
]);
if (!detail) {
throw new NotFoundException('Series not found');
}
const possibleGaps = this.computeGaps(detail.indices);
let items: BooksPage['items'] = [];
if (bookPage.bookIds.length > 0) {
const cardData = await this.bookReadService.findCardsByBookIds(bookPage.bookIds, user.id);
const cards = assembleBookCards(
cardData.rows,
cardData.authorRows,
cardData.fileRows,
cardData.genreRows,
cardData.progressRows,
cardData.statusRows,
);
const orderMap = new Map(bookPage.bookIds.map((id, i) => [id, i]));
items = cards.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
}
const seriesInfo: SeriesDetail = {
name: detail.name,
bookCount: detail.bookCount,
readCount: detail.readCount,
authors: detail.authors,
possibleGaps,
};
return { items, total: bookPage.total, page, size, seriesInfo };
}
private computeGaps(indices: number[]): number[] {
const integerIndices = indices.filter((idx) => Math.abs(Math.round(idx) - idx) < 0.01).map((idx) => Math.round(idx));
if (integerIndices.length < 2) return [];
const min = Math.min(...integerIndices);
const max = Math.max(...integerIndices);
if (min < 1 || max > 10_000) return [];
const present = new Set(integerIndices);
const gaps: number[] = [];
for (let i = min; i <= max; i++) {
if (!present.has(i)) {
gaps.push(i);
}
}
return gaps;
}
private async resolveLibraryIds(user: RequestUser, scopedLibraryId?: number): Promise<number[]> {
const libraries = await this.libraryService.findAll(user);
const accessibleIds = libraries.map((library) => library.id);
if (!scopedLibraryId) return accessibleIds;
return accessibleIds.includes(scopedLibraryId) ? [scopedLibraryId] : [];
}
}