From 2ac7bf822d0951ffb653745c22b0f863dc38d64c Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:06:53 -0600 Subject: [PATCH] feat: overhaul series detail view and improve card interactions --- client/src/components/ViewHeader.vue | 23 +- .../book/components/CollapsedSeriesCard.vue | 5 +- .../__tests__/CollapsedSeriesCard.spec.ts | 18 +- .../useSeriesCollapsePreference.ts | 29 +- .../features/series/components/SeriesCard.vue | 79 ++-- .../series/composables/useCoverStack.test.ts | 82 ++++ .../series/composables/useCoverStack.ts | 53 +++ .../composables/useSeriesDetail.test.ts | 119 ++++++ .../series/composables/useSeriesDetail.ts | 37 +- .../series/composables/useSeriesList.test.ts | 103 +++++- .../series/composables/useSeriesList.ts | 15 +- .../series/views/SeriesDetailView.vue | 349 +++++++++++++++--- .../src/features/series/views/SeriesView.vue | 30 +- .../modules/collection/collection.service.ts | 20 + .../modules/series/series.controller.test.ts | 66 +++- .../src/modules/series/series.controller.ts | 13 +- .../src/modules/series/series.repository.ts | 9 +- .../src/modules/series/series.service.test.ts | 70 +++- server/src/modules/series/series.service.ts | 14 + ...te-series-collapse-preferences.dto.test.ts | 65 ++++ .../update-series-collapse-preferences.dto.ts | 16 +- server/src/modules/user/user.service.ts | 9 + 22 files changed, 1056 insertions(+), 168 deletions(-) create mode 100644 client/src/features/series/composables/useCoverStack.test.ts create mode 100644 client/src/features/series/composables/useCoverStack.ts create mode 100644 server/src/modules/user/dto/update-series-collapse-preferences.dto.test.ts diff --git a/client/src/components/ViewHeader.vue b/client/src/components/ViewHeader.vue index 1694ff74d..cddfcf954 100644 --- a/client/src/components/ViewHeader.vue +++ b/client/src/components/ViewHeader.vue @@ -24,6 +24,8 @@ withDefaults( gridGap: number viewMode: 'grid' | 'list' selectionMode?: boolean + showSelection?: boolean + showViewModeToggle?: boolean coverShape?: 'square' | 'circle' coverSizeMin?: number coverSizeMax?: number @@ -41,6 +43,8 @@ withDefaults( gridGapMin: 4, gridGapMax: 40, gridGapStep: 4, + showSelection: true, + showViewModeToggle: true, }, ) @@ -144,6 +148,7 @@ function handleSearchInput(event: Event) { +
+
+
+

{{ seriesInfo.name }}

+
+ {{ seriesInfo.bookCount }} {{ seriesInfo.bookCount === 1 ? 'book' : 'books' }} + + by {{ visibleSeriesAuthors.join(', ') }} + +{{ hiddenSeriesAuthorsCount }} more + +
+
+ + +
+ + + + +
+
+

First in Series

+

+ {{ leadBook.title ?? 'Untitled' }} + #{{ leadBook.seriesIndex }} +

+

+ {{ leadMetaItems.join(' • ') }} +

+
+ +
Loading first book preview...
+
+ {{ leadBookError }} +
+
+
+ + {{ genre }} + + +
+ +
+

Synopsis

+
+
+ +
+

No description available.

+
+

Updating preview...

+
+
No books available to preview.
+
+
- - @@ -212,34 +461,38 @@ watch(
-
-

Books

-
- +
+
+

Books

+
+ - + - + +
@@ -251,7 +504,7 @@ watch( diff --git a/client/src/features/series/views/SeriesView.vue b/client/src/features/series/views/SeriesView.vue index 44077d725..07089efb3 100644 --- a/client/src/features/series/views/SeriesView.vue +++ b/client/src/features/series/views/SeriesView.vue @@ -101,7 +101,7 @@ function syncRouteQuery() { } function openSeries(seriesName: string) { - void router.push({ name: 'series-detail', params: { seriesName } }) + void router.push({ name: 'series-detail', params: { seriesName }, query: { from: route.fullPath } }) } function setSortField(field: SeriesListSort) { @@ -188,10 +188,16 @@ async function clearFilters() { suppressAutoReload.value = false } -function handleFilterUpdate(field: 'libraryId' | 'completionStatus' | 'author', value: unknown) { - if (field === 'libraryId') libraryId.value = value as number | null - else if (field === 'completionStatus') completionStatus.value = value as CompletionStatus | null - else if (field === 'author') author.value = value as string | null +function handleLibraryIdUpdate(value: number | null) { + libraryId.value = value +} + +function handleCompletionStatusUpdate(value: CompletionStatus | null) { + completionStatus.value = value +} + +function handleAuthorUpdate(value: string | null) { + author.value = value } function loadIfSentinelVisible() { @@ -283,6 +289,8 @@ watch(seriesGridGap, (value) => { icon="Library" :total="total" :view-mode="viewMode" + :show-selection="false" + :show-view-mode-toggle="false" v-model:coverSize="seriesCardWidth" v-model:gridGap="seriesGridGap" :cover-size-min="SERIES_CARD_WIDTH_MIN" @@ -437,9 +445,9 @@ watch(seriesGridGap, (value) => { :completion-status="completionStatus" :author="author" :active-count="activeFilterCount" - @update:library-id="handleFilterUpdate('libraryId', $event)" - @update:completion-status="handleFilterUpdate('completionStatus', $event)" - @update:author="handleFilterUpdate('author', $event)" + @update:library-id="handleLibraryIdUpdate" + @update:completion-status="handleCompletionStatusUpdate" + @update:author="handleAuthorUpdate" @clear="clearFilters" />
@@ -453,9 +461,9 @@ watch(seriesGridGap, (value) => { :author="author" :active-count="activeFilterCount" closable - @update:library-id="handleFilterUpdate('libraryId', $event)" - @update:completion-status="handleFilterUpdate('completionStatus', $event)" - @update:author="handleFilterUpdate('author', $event)" + @update:library-id="handleLibraryIdUpdate" + @update:completion-status="handleCompletionStatusUpdate" + @update:author="handleAuthorUpdate" @clear="clearFilters" @close="closeFiltersPanel" /> diff --git a/server/src/modules/collection/collection.service.ts b/server/src/modules/collection/collection.service.ts index 3f3e65b13..ebc81d1ec 100644 --- a/server/src/modules/collection/collection.service.ts +++ b/server/src/modules/collection/collection.service.ts @@ -215,6 +215,7 @@ export class CollectionService { const qWhere = q?.trim() ? this.queryBuilder.buildQuickSearch(q.trim()) : undefined; if (collapseSeries) { + const MAX_COLLAPSE_BOOKS = 5000; const allBookIds = await this.collectionRepo.findAllBookIds(id, libraryIds, qWhere); if (allBookIds.length === 0) { this.logger.log( @@ -223,6 +224,25 @@ export class CollectionService { return { items: [], total: 0, page, size }; } + if (allBookIds.length > MAX_COLLAPSE_BOOKS) { + this.logger.warn( + `[${event}] [end] collectionId=${id} durationMs=${Date.now() - startedAt} bookCount=${allBookIds.length} cap=${MAX_COLLAPSE_BOOKS} - falling back to non-collapsed pagination`, + ); + const bookPage = await this.collectionRepo.findBookIdsPage(id, libraryIds, page, size, qWhere); + if (bookPage.bookIds.length === 0) { + return { items: [], total: bookPage.total, page, size }; + } + const { rows, authorRows, fileRows, genreRows, progressRows, statusRows } = await this.bookReadService.findCardsByBookIds( + bookPage.bookIds, + user.id, + ); + const orderMap = new Map(bookPage.bookIds.map((bookId, index) => [bookId, index])); + const items = assembleBookCards(rows, authorRows, fileRows, genreRows, progressRows, statusRows).sort( + (left, right) => (orderMap.get(left.id) ?? 0) - (orderMap.get(right.id) ?? 0), + ); + return { items, total: bookPage.total, page, size }; + } + const { rows, authorRows, fileRows, genreRows, progressRows, statusRows } = await this.bookReadService.findCardsByBookIds( allBookIds, user.id, diff --git a/server/src/modules/series/series.controller.test.ts b/server/src/modules/series/series.controller.test.ts index 106f174c5..83e116253 100644 --- a/server/src/modules/series/series.controller.test.ts +++ b/server/src/modules/series/series.controller.test.ts @@ -1,5 +1,7 @@ import 'reflect-metadata'; +import { BadRequestException } from '@nestjs/common'; + import type { RequestUser } from '../../common/types/request-user'; import { SeriesController } from './series.controller'; @@ -45,7 +47,7 @@ describe('SeriesController', () => { expect(result).toBe(expected); }); - it('findBooks delegates to service with raw param', async () => { + it('findBooks delegates to service with decoded param', async () => { const { controller, seriesService } = makeController(); const user = makeUser(); const dto = { page: 0, size: 50 }; @@ -64,14 +66,62 @@ describe('SeriesController', () => { expect(result).toBe(expected); }); - it('findBooks passes param as-is without decoding', async () => { - const { controller, seriesService } = makeController(); - const user = makeUser(); - const dto = { page: 0, size: 50 }; - seriesService.findBooks.mockResolvedValue({ items: [] }); + describe('seriesName validation', () => { + it('trims whitespace from seriesName', async () => { + const { controller, seriesService } = makeController(); + seriesService.findBooks.mockResolvedValue({ items: [] }); - await controller.findBooks(user, 'Simple Series', dto as any); + await controller.findBooks(makeUser(), ' Harry Potter ', {} as any); + expect(seriesService.findBooks).toHaveBeenCalledWith(expect.anything(), 'Harry Potter', expect.anything()); + }); - expect(seriesService.findBooks).toHaveBeenCalledWith(user, 'Simple Series', dto); + it('passes already-decoded param as-is', async () => { + const { controller, seriesService } = makeController(); + seriesService.findBooks.mockResolvedValue({ items: [] }); + + await controller.findBooks(makeUser(), 'Harry Potter', {} as any); + expect(seriesService.findBooks).toHaveBeenCalledWith(expect.anything(), 'Harry Potter', expect.anything()); + }); + + it('handles names with special characters', async () => { + const { controller, seriesService } = makeController(); + seriesService.findBooks.mockResolvedValue({ items: [] }); + + await controller.findBooks(makeUser(), 'A & B / C', {} as any); + expect(seriesService.findBooks).toHaveBeenCalledWith(expect.anything(), 'A & B / C', expect.anything()); + }); + + it('handles names with percent sign', async () => { + const { controller, seriesService } = makeController(); + seriesService.findBooks.mockResolvedValue({ items: [] }); + + await controller.findBooks(makeUser(), '100% Complete', {} as any); + expect(seriesService.findBooks).toHaveBeenCalledWith(expect.anything(), '100% Complete', expect.anything()); + }); + + it('throws BadRequestException for empty seriesName', () => { + const { controller } = makeController(); + expect(() => controller.findBooks(makeUser(), '', {} as any)).toThrow(BadRequestException); + }); + + it('throws BadRequestException for whitespace-only seriesName', () => { + const { controller } = makeController(); + expect(() => controller.findBooks(makeUser(), ' ', {} as any)).toThrow(BadRequestException); + }); + + it('throws BadRequestException for seriesName exceeding max length', () => { + const { controller } = makeController(); + const longName = 'A'.repeat(501); + expect(() => controller.findBooks(makeUser(), longName, {} as any)).toThrow(BadRequestException); + }); + + it('accepts seriesName at max length boundary', async () => { + const { controller, seriesService } = makeController(); + seriesService.findBooks.mockResolvedValue({ items: [] }); + const name = 'A'.repeat(500); + + await controller.findBooks(makeUser(), name, {} as any); + expect(seriesService.findBooks).toHaveBeenCalledWith(expect.anything(), name, expect.anything()); + }); }); }); diff --git a/server/src/modules/series/series.controller.ts b/server/src/modules/series/series.controller.ts index 3542b5065..82df704e0 100644 --- a/server/src/modules/series/series.controller.ts +++ b/server/src/modules/series/series.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, Query } from '@nestjs/common'; +import { BadRequestException, Controller, Get, Param, Query } from '@nestjs/common'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import type { RequestUser } from '../../common/types/request-user'; @@ -6,6 +6,8 @@ import { ListSeriesBooksDto } from './dto/list-series-books.dto'; import { ListSeriesDto } from './dto/list-series.dto'; import { SeriesService } from './series.service'; +const MAX_SERIES_NAME_LENGTH = 500; + @Controller('series') export class SeriesController { constructor(private readonly seriesService: SeriesService) {} @@ -16,7 +18,14 @@ export class SeriesController { } @Get(':seriesName/books') - findBooks(@CurrentUser() user: RequestUser, @Param('seriesName') seriesName: string, @Query() dto: ListSeriesBooksDto) { + findBooks(@CurrentUser() user: RequestUser, @Param('seriesName') rawSeriesName: string, @Query() dto: ListSeriesBooksDto) { + const seriesName = rawSeriesName.trim(); + if (!seriesName) { + throw new BadRequestException('seriesName must not be empty'); + } + if (seriesName.length > MAX_SERIES_NAME_LENGTH) { + throw new BadRequestException(`seriesName must not exceed ${MAX_SERIES_NAME_LENGTH} characters`); + } return this.seriesService.findBooks(user, seriesName, dto); } } diff --git a/server/src/modules/series/series.repository.ts b/server/src/modules/series/series.repository.ts index 978b46db4..4ca440dad 100644 --- a/server/src/modules/series/series.repository.ts +++ b/server/src/modules/series/series.repository.ts @@ -39,6 +39,10 @@ export class SeriesRepository { return inArray(books.libraryId, libraryIds); } + private escapeLikePattern(s: string): string { + return s.replace(/[\\%_]/g, '\\$&'); + } + async findPage(params: { q?: string; page: number; @@ -56,15 +60,16 @@ export class SeriesRepository { const conditions: SQL[] = [isNotNull(bookMetadata.seriesName), sql`btrim(${bookMetadata.seriesName}) != ''`, libraryFilter]; if (params.q) { - conditions.push(ilike(bookMetadata.seriesName, `%${params.q}%`)); + conditions.push(ilike(bookMetadata.seriesName, `%${this.escapeLikePattern(params.q)}%`)); } if (params.author) { + const authorPattern = `%${this.escapeLikePattern(params.author)}%`; conditions.push( sql`${books.id} IN ( SELECT ${bookAuthors.bookId} FROM ${bookAuthors} INNER JOIN ${authors} ON ${authors.id} = ${bookAuthors.authorId} - WHERE ${ilike(authors.name, `%${params.author}%`)} + WHERE ${ilike(authors.name, authorPattern)} )`, ); } diff --git a/server/src/modules/series/series.service.test.ts b/server/src/modules/series/series.service.test.ts index 652672dd2..503336f3f 100644 --- a/server/src/modules/series/series.service.test.ts +++ b/server/src/modules/series/series.service.test.ts @@ -240,39 +240,71 @@ describe('SeriesService', () => { }); }); - describe('computeGaps (via findBooks)', () => { + describe('findBooks - library filter empty state', () => { + it('returns empty state when series exists in another library', async () => { + seriesRepo.findDetail + .mockResolvedValueOnce(null) // first call with scoped library [2] + .mockResolvedValueOnce({ name: 'Dune', bookCount: 5, readCount: 2, authors: ['Frank Herbert'], indices: [1, 2, 3, 4, 5] }); // second call with all libraries [1, 2] + seriesRepo.findBookIds.mockResolvedValue({ bookIds: [], total: 0 }); + + const result = await service.findBooks(reqUser(), 'Dune', { libraryId: 2 }); + + expect(result.items).toEqual([]); + expect(result.total).toBe(0); + expect(result.seriesInfo.name).toBe('Dune'); + expect(result.seriesInfo.authors).toEqual(['Frank Herbert']); + expect(result.seriesInfo.possibleGaps).toEqual([]); + expect(result.seriesInfo.bookCount).toBe(0); + }); + + it('throws 404 when series does not exist in any library', async () => { + seriesRepo.findDetail.mockResolvedValue(null); + seriesRepo.findBookIds.mockResolvedValue({ bookIds: [], total: 0 }); + + await expect(service.findBooks(reqUser(), 'Nonexistent', { libraryId: 1 })).rejects.toBeInstanceOf(NotFoundException); + }); + + it('throws 404 when no library filter and series not found', async () => { + seriesRepo.findDetail.mockResolvedValue(null); + seriesRepo.findBookIds.mockResolvedValue({ bookIds: [], total: 0 }); + + await expect(service.findBooks(reqUser(), 'Nonexistent', {})).rejects.toBeInstanceOf(NotFoundException); + }); + }); + + describe('computeGaps edge cases', () => { beforeEach(() => { seriesRepo.findBookIds.mockResolvedValue({ bookIds: [], total: 0 }); }); - it('detects integer gaps', async () => { - seriesRepo.findDetail.mockResolvedValue({ name: 'S', bookCount: 3, readCount: 0, authors: [], indices: [1, 2, 5] }); - const result = await service.findBooks(reqUser(), 'S', {}); - expect(result.seriesInfo.possibleGaps).toEqual([3, 4]); - }); - - it('ignores decimal indices for gap detection', async () => { - seriesRepo.findDetail.mockResolvedValue({ name: 'S', bookCount: 3, readCount: 0, authors: [], indices: [1, 1.5, 3] }); - const result = await service.findBooks(reqUser(), 'S', {}); - expect(result.seriesInfo.possibleGaps).toEqual([2]); - }); - - it('returns empty gaps for single-book series', async () => { - seriesRepo.findDetail.mockResolvedValue({ name: 'S', bookCount: 1, readCount: 0, authors: [], indices: [1] }); + it('returns empty gaps when all indices are non-integer', async () => { + seriesRepo.findDetail.mockResolvedValue({ name: 'S', bookCount: 3, readCount: 0, authors: [], indices: [0.5, 1.5, 2.5] }); const result = await service.findBooks(reqUser(), 'S', {}); expect(result.seriesInfo.possibleGaps).toEqual([]); }); - it('returns empty gaps for continuous series', async () => { - seriesRepo.findDetail.mockResolvedValue({ name: 'S', bookCount: 3, readCount: 0, authors: [], indices: [1, 2, 3] }); + it('returns empty gaps when min index < 1', async () => { + seriesRepo.findDetail.mockResolvedValue({ name: 'S', bookCount: 2, readCount: 0, authors: [], indices: [0, 5] }); const result = await service.findBooks(reqUser(), 'S', {}); expect(result.seriesInfo.possibleGaps).toEqual([]); }); - it('handles near-integer floating point values', async () => { - seriesRepo.findDetail.mockResolvedValue({ name: 'S', bookCount: 2, readCount: 0, authors: [], indices: [1.001, 3.002] }); + it('returns empty gaps when max index > 10000', async () => { + seriesRepo.findDetail.mockResolvedValue({ name: 'S', bookCount: 2, readCount: 0, authors: [], indices: [1, 10001] }); + const result = await service.findBooks(reqUser(), 'S', {}); + expect(result.seriesInfo.possibleGaps).toEqual([]); + }); + + it('handles duplicate indices', async () => { + seriesRepo.findDetail.mockResolvedValue({ name: 'S', bookCount: 3, readCount: 0, authors: [], indices: [1, 1, 3] }); const result = await service.findBooks(reqUser(), 'S', {}); expect(result.seriesInfo.possibleGaps).toEqual([2]); }); + + it('handles empty indices array', async () => { + seriesRepo.findDetail.mockResolvedValue({ name: 'S', bookCount: 0, readCount: 0, authors: [], indices: [] }); + const result = await service.findBooks(reqUser(), 'S', {}); + expect(result.seriesInfo.possibleGaps).toEqual([]); + }); }); }); diff --git a/server/src/modules/series/series.service.ts b/server/src/modules/series/series.service.ts index ed00c38ec..05b81a43e 100644 --- a/server/src/modules/series/series.service.ts +++ b/server/src/modules/series/series.service.ts @@ -83,6 +83,20 @@ export class SeriesService { ]); if (!detail) { + if (dto.libraryId) { + const allLibraryIds = await this.resolveLibraryIds(user); + const existsInAnyLibrary = await this.seriesRepo.findDetail({ seriesName, userId: user.id, libraryIds: allLibraryIds }); + if (existsInAnyLibrary) { + const emptyInfo: SeriesDetail = { + name: existsInAnyLibrary.name, + bookCount: 0, + readCount: 0, + authors: existsInAnyLibrary.authors, + possibleGaps: [], + }; + return { items: [], total: 0, page, size, seriesInfo: emptyInfo }; + } + } throw new NotFoundException('Series not found'); } diff --git a/server/src/modules/user/dto/update-series-collapse-preferences.dto.test.ts b/server/src/modules/user/dto/update-series-collapse-preferences.dto.test.ts new file mode 100644 index 000000000..34d0afa1f --- /dev/null +++ b/server/src/modules/user/dto/update-series-collapse-preferences.dto.test.ts @@ -0,0 +1,65 @@ +import 'reflect-metadata'; + +import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; +import { UpdateSeriesCollapsePreferencesDto } from './update-series-collapse-preferences.dto'; + +function toDto(plain: Record): UpdateSeriesCollapsePreferencesDto { + return plainToInstance(UpdateSeriesCollapsePreferencesDto, plain); +} + +describe('UpdateSeriesCollapsePreferencesDto', () => { + it('accepts valid boolean global', async () => { + const dto = toDto({ global: true }); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('accepts valid boolean libraries', async () => { + const dto = toDto({ libraries: { '1': true, '2': false } }); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('accepts null values in libraries for deletion', async () => { + const dto = toDto({ libraries: { '1': null, '2': true } }); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('accepts null values in collections for deletion', async () => { + const dto = toDto({ collections: { '5': null } }); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('rejects non-boolean/non-null values in libraries', async () => { + const dto = toDto({ libraries: { '1': 'yes' } }); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + + it('rejects number values in collections', async () => { + const dto = toDto({ collections: { '1': 42 } }); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + + it('rejects array for libraries', async () => { + const dto = toDto({ libraries: [true, false] }); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + + it('accepts empty dto (all optional)', async () => { + const dto = toDto({}); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('rejects non-boolean global', async () => { + const dto = toDto({ global: 'true' }); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); +}); diff --git a/server/src/modules/user/dto/update-series-collapse-preferences.dto.ts b/server/src/modules/user/dto/update-series-collapse-preferences.dto.ts index 20b6c8a26..5ea80084a 100644 --- a/server/src/modules/user/dto/update-series-collapse-preferences.dto.ts +++ b/server/src/modules/user/dto/update-series-collapse-preferences.dto.ts @@ -1,19 +1,19 @@ import { IsBoolean, IsOptional, registerDecorator, ValidationOptions } from 'class-validator'; -function IsBooleanRecord(validationOptions?: ValidationOptions) { +function IsBooleanOrNullRecord(validationOptions?: ValidationOptions) { return (object: object, propertyName: string) => { registerDecorator({ - name: 'isBooleanRecord', + name: 'isBooleanOrNullRecord', target: object.constructor, propertyName, options: validationOptions, validator: { validate(value: unknown) { if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; - return Object.values(value as Record).every((v) => typeof v === 'boolean'); + return Object.values(value as Record).every((v) => typeof v === 'boolean' || v === null); }, defaultMessage() { - return `${propertyName} must be a record with string keys and boolean values`; + return `${propertyName} must be a record with string keys and boolean or null values`; }, }, }); @@ -26,10 +26,10 @@ export class UpdateSeriesCollapsePreferencesDto { global?: boolean; @IsOptional() - @IsBooleanRecord() - libraries?: Record; + @IsBooleanOrNullRecord() + libraries?: Record; @IsOptional() - @IsBooleanRecord() - collections?: Record; + @IsBooleanOrNullRecord() + collections?: Record; } diff --git a/server/src/modules/user/user.service.ts b/server/src/modules/user/user.service.ts index 77f4bea92..c014ef388 100644 --- a/server/src/modules/user/user.service.ts +++ b/server/src/modules/user/user.service.ts @@ -265,6 +265,15 @@ export class UserService { libraries: { ...currentPrefs.libraries, ...(dto.libraries ?? {}) }, collections: { ...currentPrefs.collections, ...(dto.collections ?? {}) }, }; + + // Remove entries set to null (deletion of overrides) + for (const [k, v] of Object.entries(merged.libraries)) { + if (v === null) delete merged.libraries[k]; + } + for (const [k, v] of Object.entries(merged.collections)) { + if (v === null) delete merged.collections[k]; + } + await this.userRepo.update(userId, { settings: { seriesCollapsePreferences: merged } }); } }