feat(authors): collapse series on author pages (#1271)

Reduce large author listings to one card per series while preserving book counts, scoped visibility, and row-based pagination. Compute both totals in the collapsed query so deep pages remain consistent without a second author-wide scan.

Closes #301

Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
This commit is contained in:
SergeiAK
2026-09-10 21:39:14 +03:00
committed by GitHub
parent b7e1e21d07
commit 73ac0bb722
23 changed files with 953 additions and 59 deletions
+4 -2
View File
@@ -1,10 +1,10 @@
import { api } from '@/lib/api'
import type {
AuthorsPage,
AuthorBooksPage,
AuthorDetail,
AuthorMetadataCandidate,
AuthorMetadataProviderKey,
BooksPage,
JumpBucketsResponse,
MergeAuthorsResult,
} from '@bookorbit/types'
@@ -34,6 +34,7 @@ type ListAuthorBooksParams = {
sort: AuthorBookSort
order: SortDirection
libraryId?: number | null
collapseSeries?: boolean
}
export type UpdateAuthorPayload = {
@@ -141,13 +142,14 @@ export async function fetchAuthor(id: number): Promise<AuthorDetail | null> {
return res.json()
}
export async function fetchAuthorBooks(authorId: number, params: ListAuthorBooksParams): Promise<BooksPage> {
export async function fetchAuthorBooks(authorId: number, params: ListAuthorBooksParams): Promise<AuthorBooksPage> {
const query = toQuery({
page: params.page,
size: params.size,
sort: params.sort,
order: params.order,
libraryId: params.libraryId ?? undefined,
collapseSeries: params.collapseSeries ? 'true' : undefined,
})
const res = await api(`/api/v1/authors/${authorId}/books${query}`)
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
import type { BookCard, BooksPage } from '@bookorbit/types'
import type { AuthorBooksPage, BookCard } from '@bookorbit/types'
vi.mock('../api/author', () => ({
fetchAuthorBooks: vi.fn<typeof import('../api/author').fetchAuthorBooks>(),
@@ -15,8 +15,8 @@ function makeBook(id: number): BookCard {
return { id, title: `Book ${id}` } as BookCard
}
function makePage(items: BookCard[], total = items.length): BooksPage {
return { items, total, page: 0, size: 50 }
function makePage(items: BookCard[], total = items.length, bookTotal = total): AuthorBooksPage {
return { items, total, bookTotal, page: 0, size: 50 }
}
describe('useAuthorBooks', () => {
@@ -56,19 +56,19 @@ describe('useAuthorBooks', () => {
expect(mockFetchAuthorBooks).not.toHaveBeenCalled()
})
it('skips fetch when already loading', async () => {
it('does not fire a second request while one is in flight', async () => {
const authorId = ref(1)
let resolveFirst!: (v: BooksPage) => void
let resolveFirst!: (v: AuthorBooksPage) => void
mockFetchAuthorBooks.mockImplementation(
() =>
new Promise<BooksPage>((resolve) => {
new Promise<AuthorBooksPage>((resolve) => {
resolveFirst = resolve
}),
)
const { load } = useAuthorBooks(authorId)
const firstLoad = load(true)
const secondLoad = load(true)
const secondLoad = load(false)
resolveFirst(makePage([makeBook(1)]))
await Promise.all([firstLoad, secondLoad])
@@ -76,6 +76,64 @@ describe('useAuthorBooks', () => {
expect(mockFetchAuthorBooks).toHaveBeenCalledTimes(1)
})
it('reloads with the new flag when collapseSeries changes during an unresolved load', async () => {
const authorId = ref(1)
let resolveFirst!: (v: AuthorBooksPage) => void
mockFetchAuthorBooks
.mockImplementationOnce(
() =>
new Promise<AuthorBooksPage>((resolve) => {
resolveFirst = resolve
}),
)
.mockResolvedValueOnce(makePage([makeBook(50)], 1, 4))
const { load, items, total, bookTotal, collapseSeries } = useAuthorBooks(authorId)
const flatLoad = load(true)
// The toggle flips while the flat request is still out, so the reset it asks for is the one
// that has to win.
collapseSeries.value = true
void load(true)
resolveFirst(makePage([makeBook(1), makeBook(2), makeBook(3), makeBook(4)], 4, 4))
await flatLoad
await vi.waitFor(() => expect(mockFetchAuthorBooks).toHaveBeenCalledTimes(2))
expect(mockFetchAuthorBooks).toHaveBeenNthCalledWith(1, 1, expect.objectContaining({ collapseSeries: false }))
expect(mockFetchAuthorBooks).toHaveBeenNthCalledWith(2, 1, expect.objectContaining({ collapseSeries: true, page: 0 }))
expect(items.value).toHaveLength(1)
expect(items.value[0]!.id).toBe(50)
expect(total.value).toBe(1)
expect(bookTotal.value).toBe(4)
})
it('keeps the error of a stale request off the page when a reset is queued behind it', async () => {
const authorId = ref(1)
let rejectFirst!: (e: unknown) => void
mockFetchAuthorBooks
.mockImplementationOnce(
() =>
new Promise<AuthorBooksPage>((_resolve, reject) => {
rejectFirst = reject
}),
)
.mockResolvedValueOnce(makePage([makeBook(7)], 1))
const { load, error, items, collapseSeries } = useAuthorBooks(authorId)
const flatLoad = load(true)
collapseSeries.value = true
void load(true)
rejectFirst(new Error('stale request failed'))
await flatLoad
await vi.waitFor(() => expect(items.value).toHaveLength(1))
expect(error.value).toBeNull()
expect(items.value[0]!.id).toBe(7)
})
it('skips non-reset load when no more items to load', async () => {
const authorId = ref(1)
mockFetchAuthorBooks.mockResolvedValue(makePage([makeBook(1), makeBook(2)], 2))
@@ -103,6 +161,46 @@ describe('useAuthorBooks', () => {
expect(hasMore.value).toBe(true)
})
it('sends collapseSeries only once it is turned on', async () => {
const authorId = ref(1)
mockFetchAuthorBooks.mockResolvedValue(makePage([makeBook(1)]))
const { load, collapseSeries } = useAuthorBooks(authorId)
await load(true)
expect(mockFetchAuthorBooks).toHaveBeenNthCalledWith(1, 1, expect.objectContaining({ collapseSeries: false }))
collapseSeries.value = true
await load(true)
expect(mockFetchAuthorBooks).toHaveBeenNthCalledWith(2, 1, expect.objectContaining({ collapseSeries: true }))
})
it('paginates on rows while keeping the book count separately', async () => {
const authorId = ref(1)
// 30 collapsed rows standing for 200 books: the row count is what says there is more to load,
// and the book count must not be the thing driving it.
mockFetchAuthorBooks.mockResolvedValueOnce(makePage([makeBook(1), makeBook(2)], 30, 200))
const { load, items, total, bookTotal, hasMore } = useAuthorBooks(authorId)
await load(true)
expect(items.value).toHaveLength(2)
expect(total.value).toBe(30)
expect(bookTotal.value).toBe(200)
expect(hasMore.value).toBe(true)
})
it('stops loading once every row has arrived, however many books they stand for', async () => {
const authorId = ref(1)
mockFetchAuthorBooks.mockResolvedValueOnce(makePage([makeBook(1), makeBook(2)], 2, 200))
const { load, hasMore, bookTotal } = useAuthorBooks(authorId)
await load(true)
expect(hasMore.value).toBe(false)
expect(bookTotal.value).toBe(200)
})
it('resets items and page on reset=true', async () => {
const authorId = ref(1)
mockFetchAuthorBooks.mockResolvedValueOnce(makePage([makeBook(1)], 3)).mockResolvedValueOnce(makePage([makeBook(99)], 1))
@@ -199,14 +297,15 @@ describe('useAuthorBooks', () => {
expect(loading.value).toBe(false)
})
it('passes sort, order, and libraryId to fetchAuthorBooks', async () => {
it('passes sort, order, libraryId, and collapseSeries to fetchAuthorBooks', async () => {
const authorId = ref(5)
mockFetchAuthorBooks.mockResolvedValue(makePage([]))
const { load, sort, order, libraryId } = useAuthorBooks(authorId)
const { load, sort, order, libraryId, collapseSeries } = useAuthorBooks(authorId)
sort.value = 'title'
order.value = 'asc'
libraryId.value = 7
collapseSeries.value = true
await load(true)
@@ -216,6 +315,7 @@ describe('useAuthorBooks', () => {
sort: 'title',
order: 'asc',
libraryId: 7,
collapseSeries: true,
})
})
@@ -270,10 +370,10 @@ describe('useAuthorBooks', () => {
await load(true)
expect(items.value).toHaveLength(1)
let resolve!: (v: BooksPage) => void
let resolve!: (v: AuthorBooksPage) => void
mockFetchAuthorBooks.mockImplementation(
() =>
new Promise<BooksPage>((r) => {
new Promise<AuthorBooksPage>((r) => {
resolve = r
}),
)
@@ -8,20 +8,33 @@ const PAGE_SIZE = 50
export function useAuthorBooks(authorId: Ref<number>) {
const items = ref<BookCard[]>([])
/** Rows, so it keeps matching `items` once a series arrives as a single collapsed card. */
const total = ref(0)
/** Books behind those rows. Equal to `total` while the list is flat. */
const bookTotal = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
const sort = ref<AuthorBookSort>('addedAt')
const order = ref<SortDirection>('desc')
const libraryId = ref<number | null>(null)
const collapseSeries = ref(false)
const page = ref(0)
const hasMore = computed(() => items.value.length < total.value)
/**
* Set when a reset arrives while a request is in flight: the query changed under it, so the
* pending response is stale and the list has to be rebuilt once that request settles.
*/
let resetQueued = false
async function load(reset = false): Promise<void> {
if (!authorId.value || Number.isNaN(authorId.value)) return
if (loading.value) return
if (loading.value) {
if (reset) resetQueued = true
return
}
if (!reset && !hasMore.value) return
loading.value = true
@@ -39,27 +52,38 @@ export function useAuthorBooks(authorId: Ref<number>) {
sort: sort.value,
order: order.value,
libraryId: libraryId.value,
collapseSeries: collapseSeries.value,
})
items.value = reset ? data.items : [...items.value, ...data.items]
total.value = data.total
page.value += 1
// Dropped on purpose when a reset is queued: these rows answer the previous query.
if (!resetQueued) {
items.value = reset ? data.items : [...items.value, ...data.items]
total.value = data.total
bookTotal.value = data.bookTotal
page.value += 1
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load books'
if (!resetQueued) error.value = err instanceof Error ? err.message : 'Failed to load books'
} finally {
loading.value = false
if (resetQueued) {
resetQueued = false
void load(true)
}
}
}
return {
items,
total,
bookTotal,
loading,
error,
hasMore,
sort,
order,
libraryId,
collapseSeries,
load,
}
}
@@ -29,12 +29,16 @@ const mocks = vi.hoisted(() => ({
authorNotFound: null as unknown as Ref<boolean>,
books: null as unknown as Ref<BookCard[]>,
total: null as unknown as Ref<number>,
bookTotal: null as unknown as Ref<number>,
loadingBooks: null as unknown as Ref<boolean>,
booksError: null as unknown as Ref<string | null>,
hasMore: null as unknown as Ref<boolean>,
sort: null as unknown as Ref<'addedAt' | 'title' | 'publishedYear'>,
order: null as unknown as Ref<'asc' | 'desc'>,
libraryId: null as unknown as Ref<number | null>,
collapseSeries: null as unknown as Ref<boolean>,
collapsePreference: false,
setCollapsePreference: vi.fn<(ctx: unknown, value: boolean | null) => Promise<void>>(),
}))
vi.mock('vue-router', () => ({
@@ -102,16 +106,26 @@ vi.mock('../composables/useAuthorBooks', () => ({
useAuthorBooks: () => ({
items: mocks.books,
total: mocks.total,
bookTotal: mocks.bookTotal,
loading: mocks.loadingBooks,
error: mocks.booksError,
hasMore: mocks.hasMore,
sort: mocks.sort,
order: mocks.order,
libraryId: mocks.libraryId,
collapseSeries: mocks.collapseSeries,
load: mocks.loadBooks,
}),
}))
vi.mock('@/features/book/composables/useSeriesCollapsePreference', () => ({
useSeriesCollapsePreference: () => ({
getEffectivePreference: () => mocks.collapsePreference,
setPreference: mocks.setCollapsePreference,
prefs: ref(undefined),
}),
}))
vi.mock('../composables/useAuthorMetadataPreview', () => ({
useAuthorMetadataPreview: () => ({
preview: ref(null),
@@ -228,12 +242,16 @@ describe('AuthorDetailView', () => {
mocks.authorNotFound = ref(false)
mocks.books = ref([makeBook(101), makeBook(102)])
mocks.total = ref(2)
mocks.bookTotal = ref(2)
mocks.loadingBooks = ref(false)
mocks.booksError = ref(null)
mocks.hasMore = ref(false)
mocks.sort = ref('addedAt')
mocks.order = ref('desc')
mocks.libraryId = ref(null)
mocks.collapseSeries = ref(false)
mocks.collapsePreference = false
mocks.setCollapsePreference.mockResolvedValue()
})
afterEach(() => {
@@ -275,6 +293,49 @@ describe('AuthorDetailView', () => {
await flushPromises()
}
describe('series collapse toggle', () => {
it('starts from the saved author pages preference', async () => {
mocks.collapsePreference = true
const wrapper = await mountView()
expect(mocks.collapseSeries.value).toBe(true)
expect(wrapper.get('[data-testid="author-collapse-series-toggle"]').attributes('aria-pressed')).toBe('true')
})
it('turns collapsing on and remembers it for every author page', async () => {
const wrapper = await mountView()
await wrapper.get('[data-testid="author-collapse-series-toggle"]').trigger('click')
await flushPromises()
expect(mocks.collapseSeries.value).toBe(true)
expect(mocks.setCollapsePreference).toHaveBeenCalledWith({ authorPages: true }, true)
})
it('turns collapsing back off', async () => {
mocks.collapsePreference = true
const wrapper = await mountView()
await wrapper.get('[data-testid="author-collapse-series-toggle"]').trigger('click')
await flushPromises()
expect(mocks.collapseSeries.value).toBe(false)
expect(mocks.setCollapsePreference).toHaveBeenCalledWith({ authorPages: true }, false)
})
it('counts books rather than rows in the all-loaded label', async () => {
// 2 collapsed rows standing for 200 books: the reader should be told about the books.
mocks.total = ref(2)
mocks.bookTotal = ref(200)
mocks.hasMore = ref(false)
const wrapper = await mountView()
expect(wrapper.text()).toContain(i18n.global.t('author.detail.books.allLoaded', { total: '200' }))
})
})
it('keeps unsaved edit fields when an author image is uploaded', async () => {
vi.mocked(uploadAuthorImage).mockResolvedValue(makeAuthor({ imageUrl: '/api/v1/authors/7/image' }))
@@ -4,7 +4,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatNumber } from '@/i18n/formatters'
import { useRoute, useRouter } from 'vue-router'
import { ArrowUpDown, ChevronDown, ChevronLeft, ImageMinus, LayoutGrid, List, Upload } from '@lucide/vue'
import { ArrowUpDown, ChevronDown, ChevronLeft, ImageMinus, Layers, LayoutGrid, List, Upload } from '@lucide/vue'
import { toast } from 'vue-sonner'
import type { AuthorDetail, AuthorSummary, BookCard } from '@bookorbit/types'
@@ -12,6 +12,7 @@ import VirtualBookGrid from '@/features/book/components/VirtualBookGrid.vue'
import BookListRow from '@/features/book/components/BookListRow.vue'
import DeleteBookDialog from '@/features/book/components/DeleteBookDialog.vue'
import { useScrollRestoreOnActivate } from '@/features/book/composables/useScrollRestoreOnActivate'
import { useSeriesCollapsePreference } from '@/features/book/composables/useSeriesCollapsePreference'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { usePermissions } from '@/features/auth/composables/usePermissions'
@@ -49,7 +50,30 @@ const authorBooksViewMode = ref<'grid' | 'list'>('grid')
const authorId = computed(() => Number(route.params.id))
const { author, loading: loadingAuthor, error: authorError, notFound: authorNotFound, load: loadAuthor } = useAuthorDetail(authorId)
const { items: books, total, loading: loadingBooks, error: booksError, hasMore, sort, order, libraryId, load: loadBooks } = useAuthorBooks(authorId)
const {
items: books,
total,
bookTotal,
loading: loadingBooks,
error: booksError,
hasMore,
sort,
order,
libraryId,
collapseSeries,
load: loadBooks,
} = useAuthorBooks(authorId)
// One flag for every author page, so the state carries from one author to the next rather than
// being re-chosen per author; the toggle below writes it back. Tracked rather than read once,
// because on a cold load of an author URL the signed-in user - and so the preference - can
// arrive after this component is set up.
const { getEffectivePreference, setPreference } = useSeriesCollapsePreference()
watch(
() => getEffectivePreference({ authorPages: true }),
(value) => (collapseSeries.value = value),
{ immediate: true },
)
const authorName = computed(() => author.value?.name ?? '')
const pageTitle = computed(() => {
if (author.value?.name) return t('author.detail.pageTitleNamed', { name: author.value.name })
@@ -129,7 +153,10 @@ const {
books.value = books.value.filter((b) => b.id !== id)
if (books.value.length === previousLength) return
// Only a plain book card carries a delete action - a collapsed series row opens the series
// instead - so the card that just went is worth exactly one row and one book.
total.value = Math.max(0, total.value - 1)
bookTotal.value = Math.max(0, bookTotal.value - 1)
if (author.value) {
author.value = {
...author.value,
@@ -209,6 +236,14 @@ function handleBookUpdate(updated: BookCard) {
if (idx !== -1) books.value = books.value.map((b, i) => (i === idx ? updated : b))
}
const collapseToggleLabel = computed(() => (collapseSeries.value ? t('views.bookView.expandSeries') : t('views.bookView.collapseSeries')))
async function handleToggleCollapse() {
const next = !collapseSeries.value
collapseSeries.value = next
await setPreference({ authorPages: true }, next)
}
function loadIfSentinelVisible() {
if (loadingBooks.value || !hasMore.value || !sentinel.value) return
if (sentinel.value.getBoundingClientRect().top < window.innerHeight + 250) {
@@ -448,7 +483,7 @@ watch(authorId, () => {
void Promise.all([loadAuthor(), loadBooks(true)]).then(() => loadMetadataPreview())
})
watch([sort, order, libraryId], () => {
watch([sort, order, libraryId, collapseSeries], () => {
void loadBooks(true)
})
@@ -618,7 +653,7 @@ defineOptions({ name: 'AuthorDetailView' })
<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">{{ t('author.detail.books.heading') }}</h2>
<div class="w-full space-y-2 sm:hidden">
<div class="grid grid-cols-[minmax(0,1fr)_auto_auto] gap-2">
<div class="grid grid-cols-[minmax(0,1fr)_auto_auto_auto] gap-2">
<div class="relative min-w-0">
<select
:value="sort"
@@ -639,6 +674,22 @@ defineOptions({ name: 'AuthorDetailView' })
{{ order === 'asc' ? t('author.detail.books.asc') : t('author.detail.books.desc') }}
</button>
<button
data-testid="author-collapse-series-toggle-mobile"
class="flex h-8 w-8 items-center justify-center rounded-md border transition-colors"
:class="
collapseSeries
? 'border-primary text-primary bg-primary/10'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
:aria-label="collapseToggleLabel"
:aria-pressed="collapseSeries"
:title="collapseToggleLabel"
@click="handleToggleCollapse"
>
<Layers :size="14" />
</button>
<div class="flex items-center rounded-md border border-input bg-background">
<button
class="flex h-8 w-8 items-center justify-center rounded-l-md transition-colors"
@@ -703,6 +754,22 @@ defineOptions({ name: 'AuthorDetailView' })
<option v-for="library in libraries" :key="library.id" :value="library.id">{{ library.name }}</option>
</select>
<button
data-testid="author-collapse-series-toggle"
class="flex h-8 w-8 items-center justify-center rounded-md border transition-colors"
:class="
collapseSeries
? 'border-primary text-primary bg-primary/10'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
:aria-label="collapseToggleLabel"
:aria-pressed="collapseSeries"
:title="collapseToggleLabel"
@click="handleToggleCollapse"
>
<Layers :size="14" />
</button>
<div class="flex items-center rounded-md border border-input bg-background">
<button
class="flex h-8 w-8 items-center justify-center rounded-l-md transition-colors"
@@ -751,7 +818,7 @@ defineOptions({ name: 'AuthorDetailView' })
<div ref="sentinel" class="mt-4 flex h-8 items-center justify-center">
<span v-if="loadingBooks" class="text-xs text-muted-foreground">{{ t('common.loading') }}</span>
<span v-else-if="!hasMore && books.length > 0" class="text-xs text-muted-foreground">{{
t('author.detail.books.allLoaded', { total: formatNumber(total) })
t('author.detail.books.allLoaded', { total: formatNumber(bookTotal) })
}}</span>
</div>
</section>
@@ -238,7 +238,38 @@ describe('useSeriesCollapsePreference', () => {
await setPreference('global', true)
expect(prefs.value).toEqual({ global: true, libraries: {}, collections: {}, smartScopes: {} })
expect(prefs.value).toEqual({ global: true, libraries: {}, collections: {}, smartScopes: {}, authorPages: false })
})
it('writes the author pages flag without touching the other scopes', async () => {
const userRef = ref(makeUser({ seriesCollapsePreferences: { global: true, libraries: { '2': true }, collections: {} } }))
mockUseAuth.mockReturnValue({ user: userRef } as ReturnType<typeof useAuth>)
const { setPreference, prefs } = useSeriesCollapsePreference()
await setPreference({ authorPages: true }, true)
expect(prefs.value!.authorPages).toBe(true)
expect(prefs.value!.global).toBe(true)
expect(prefs.value!.libraries).toEqual({ '2': true })
expect(mockApi).toHaveBeenCalledWith(
'/api/v1/users/me/series-collapse-preferences',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ authorPages: true }),
}),
)
})
it('turns the author pages flag back off', async () => {
const userRef = ref(makeUser({ seriesCollapsePreferences: { global: false, libraries: {}, collections: {}, authorPages: true } }))
mockUseAuth.mockReturnValue({ user: userRef } as ReturnType<typeof useAuth>)
const { setPreference, prefs } = useSeriesCollapsePreference()
await setPreference({ authorPages: true }, false)
expect(prefs.value!.authorPages).toBe(false)
})
it('preserves existing library overrides when setting global', async () => {
@@ -4,6 +4,12 @@ import { resolveCollapsePreference } from '@bookorbit/types'
import { useAuth } from '@/features/auth/composables/useAuth'
import { api } from '@/lib/api'
/**
* A place whose collapse state is remembered. `authorPages` is a single flag rather than an id,
* so every author page shares it - see the type in `@bookorbit/types` for why.
*/
export type CollapseScope = { libraryId?: number; collectionId?: number; smartScopeId?: number; authorPages?: boolean }
export function useSeriesCollapsePreference() {
const { user } = useAuth()
@@ -11,28 +17,24 @@ export function useSeriesCollapsePreference() {
return user.value?.settings.seriesCollapsePreferences
})
function getEffectivePreference(ctx: { libraryId?: number; collectionId?: number; smartScopeId?: number }): boolean {
function getEffectivePreference(ctx: CollapseScope): boolean {
return resolveCollapsePreference(prefs.value, ctx)
}
let pendingUpdate: Promise<void> = Promise.resolve()
async function setPreference(
ctx: { libraryId?: number; collectionId?: number; smartScopeId?: number } | 'global',
value: boolean | null,
): Promise<void> {
async function setPreference(ctx: CollapseScope | 'global', value: boolean | null): Promise<void> {
const op = () => doSetPreference(ctx, value)
pendingUpdate = pendingUpdate.then(op, op)
return pendingUpdate
}
async function doSetPreference(
ctx: { libraryId?: number; collectionId?: number; smartScopeId?: number } | 'global',
value: boolean | null,
): Promise<void> {
async function doSetPreference(ctx: CollapseScope | 'global', value: boolean | null): Promise<void> {
let body: Record<string, unknown>
if (ctx === 'global') {
body = { global: value === null ? false : value }
} else if (ctx.authorPages) {
body = { authorPages: value === null ? false : value }
} else if (ctx.smartScopeId !== undefined) {
body = { smartScopes: { [String(ctx.smartScopeId)]: value } }
} else if (ctx.collectionId !== undefined) {
@@ -64,6 +66,7 @@ export function useSeriesCollapsePreference() {
libraries: nextLibraries as Record<string, boolean>,
collections: nextCollections as Record<string, boolean>,
smartScopes: nextSmartScopes as Record<string, boolean>,
authorPages: body.authorPages !== undefined ? (body.authorPages as boolean) : (current.authorPages ?? false),
}
const previous = user.value?.settings ? { ...user.value.settings } : undefined