mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -27,3 +27,4 @@ export * from "./statistics";
|
||||
export * from "./user-statistics";
|
||||
export * from "./migration";
|
||||
export * from "./notification";
|
||||
export * from "./series";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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] : [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user