From 7a1b0120b83d6ecd29a8d6c9e4311ed70db87e09 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Sun, 31 May 2026 15:10:00 +0000 Subject: [PATCH] feat(series): add selectable cover modes for collapsed series cards Allow readers to choose which representative series cover appears in collapsed grid view so browsing can prioritize first, latest, or first unread volumes. Closes #160 --- .../__tests__/useDisplaySettings.spec.ts | 27 +++ .../__tests__/useDisplaySettingsSync.spec.ts | 31 +++ client/src/composables/useDisplaySettings.ts | 18 ++ .../src/composables/useDisplaySettingsSync.ts | 1 + .../book/components/CollapsedSeriesCard.vue | 47 +++- .../__tests__/CollapsedSeriesCard.spec.ts | 223 +++++++++++++++++- .../settings/AppearanceLayoutSettings.vue | 67 +++++- .../__tests__/AppearanceSettings.spec.ts | 1 + packages/types/src/display-preferences.ts | 4 + packages/types/src/series-collapse.ts | 3 + server/src/modules/book/book.repository.ts | 62 ++++- .../book/utils/assemble-book-cards.test.ts | 119 ++++++++++ .../modules/book/utils/assemble-book-cards.ts | 12 + .../user-preferences.service.test.ts | 1 + .../user-preferences.service.ts | 2 + 15 files changed, 610 insertions(+), 8 deletions(-) diff --git a/client/src/composables/__tests__/useDisplaySettings.spec.ts b/client/src/composables/__tests__/useDisplaySettings.spec.ts index 4225d4654..1988a9577 100644 --- a/client/src/composables/__tests__/useDisplaySettings.spec.ts +++ b/client/src/composables/__tests__/useDisplaySettings.spec.ts @@ -20,6 +20,7 @@ function resetDisplaySettings() { settings.bookSpineOverlay.value = 'off' settings.bookShadowStrength.value = 'default' settings.bookCoverDisplayMode.value = 'blurred-fit' + settings.seriesCardCoverMode.value = 'mosaic' } afterEach(() => { @@ -88,4 +89,30 @@ describe('useDisplaySettings preferences helpers', () => { expect(sanitizeDisplayPreferences(null)).toEqual({}) expect(sanitizeDisplayPreferences('bad')).toEqual({}) }) + + it('includes seriesCardCoverMode in snapshot', () => { + settings.seriesCardCoverMode.value = 'first-volume' + const snap = getDisplayPreferencesSnapshot() + expect(snap.seriesCardCoverMode).toBe('first-volume') + }) + + it('sanitizes valid seriesCardCoverMode values', () => { + const sanitized = sanitizeDisplayPreferences({ seriesCardCoverMode: 'latest-volume' }) + expect(sanitized).toEqual({ seriesCardCoverMode: 'latest-volume' }) + }) + + it('drops invalid seriesCardCoverMode values', () => { + const sanitized = sanitizeDisplayPreferences({ seriesCardCoverMode: 'unknown-mode' }) + expect(sanitized).toEqual({}) + }) + + it('applies seriesCardCoverMode from preferences', () => { + applyDisplayPreferences({ seriesCardCoverMode: 'first-unread' }) + expect(settings.seriesCardCoverMode.value).toBe('first-unread') + }) + + it('defaults seriesCardCoverMode to mosaic', () => { + resetDisplaySettings() + expect(settings.seriesCardCoverMode.value).toBe('mosaic') + }) }) diff --git a/client/src/composables/__tests__/useDisplaySettingsSync.spec.ts b/client/src/composables/__tests__/useDisplaySettingsSync.spec.ts index aaf7242c9..12c1b9f51 100644 --- a/client/src/composables/__tests__/useDisplaySettingsSync.spec.ts +++ b/client/src/composables/__tests__/useDisplaySettingsSync.spec.ts @@ -20,6 +20,7 @@ function validDisplayPreferences(overrides: Partial = {}): D bookSpineOverlay: 'subtle', bookShadowStrength: 'strong', bookCoverDisplayMode: 'blurred-fit', + seriesCardCoverMode: 'mosaic', ...overrides, } } @@ -221,4 +222,34 @@ describe('useDisplaySettingsSync', () => { expect(fetchMock).not.toHaveBeenCalled() }) + + it('syncs seriesCardCoverMode changes to the server', async () => { + vi.useFakeTimers() + const { apiMock, displaySettings, sync } = await loadModules() + apiMock.mockResolvedValue({ ok: true }) + + sync.initDisplaySettingsSync() + displaySettings.useDisplaySettings().seriesCardCoverMode.value = 'first-volume' + await vi.advanceTimersByTimeAsync(1500) + + expect(apiMock).toHaveBeenCalledWith( + '/api/v1/user-preferences/display', + expect.objectContaining({ + method: 'PUT', + body: expect.stringContaining('"seriesCardCoverMode":"first-volume"'), + }), + ) + }) + + it('loads seriesCardCoverMode from the server', async () => { + const { apiMock, displaySettings, sync } = await loadModules() + apiMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ settings: validDisplayPreferences({ seriesCardCoverMode: 'latest-volume' }) }), + }) + + await sync.loadDisplaySettingsFromServer() + + expect(displaySettings.useDisplaySettings().seriesCardCoverMode.value).toBe('latest-volume') + }) }) diff --git a/client/src/composables/useDisplaySettings.ts b/client/src/composables/useDisplaySettings.ts index aec7c744a..d3bd5377b 100644 --- a/client/src/composables/useDisplaySettings.ts +++ b/client/src/composables/useDisplaySettings.ts @@ -7,6 +7,7 @@ import { BOOK_VIEW_MODES, CARD_OVERLAY_KEYS, COVER_SIZE_SCOPES, + SERIES_CARD_COVER_MODES, TABLE_DENSITIES, type AuthorCoverShape, type BookCoverDisplayMode, @@ -16,6 +17,7 @@ import { type CardOverlayKey, type CoverSizeScope, type DisplayPreferences, + type SeriesCardCoverMode, type TableDensity, } from '@bookorbit/types' import { storage } from '@/services/storage' @@ -29,6 +31,7 @@ export type { CardOverlayKey, CoverSizeScope, DisplayPreferences, + SeriesCardCoverMode, TableDensity, } from '@bookorbit/types' @@ -38,6 +41,7 @@ const DEFAULT_GRID_GAP = 28 const DEFAULT_BOOK_SPINE_OVERLAY: BookSpineOverlay = 'off' const DEFAULT_BOOK_SHADOW_STRENGTH: BookShadowStrength = 'default' const DEFAULT_BOOK_COVER_DISPLAY_MODE: BookCoverDisplayMode = 'blurred-fit' +const DEFAULT_SERIES_CARD_COVER_MODE: SeriesCardCoverMode = 'mosaic' const DEFAULT_CARD_OVERLAYS: CardOverlayKey[] = ['progress-bar', 'format', 'rating', 'read-status', 'series-position'] function normalizeBookSpineOverlay(value: unknown): BookSpineOverlay { @@ -58,6 +62,12 @@ function normalizeBookCoverDisplayMode(value: unknown): BookCoverDisplayMode { : DEFAULT_BOOK_COVER_DISPLAY_MODE } +function normalizeSeriesCardCoverMode(value: unknown): SeriesCardCoverMode { + return typeof value === 'string' && SERIES_CARD_COVER_MODES.includes(value as SeriesCardCoverMode) + ? (value as SeriesCardCoverMode) + : DEFAULT_SERIES_CARD_COVER_MODE +} + function normalizeCoverSizeScope(value: unknown): CoverSizeScope { return typeof value === 'string' && COVER_SIZE_SCOPES.includes(value as CoverSizeScope) ? (value as CoverSizeScope) : 'per-view' } @@ -116,6 +126,7 @@ const bookShadowStrength = ref(normalizeBookShadowStrength(s const bookCoverDisplayMode = ref( normalizeBookCoverDisplayMode(storage.get('bookCoverDisplayMode', DEFAULT_BOOK_COVER_DISPLAY_MODE)), ) +const seriesCardCoverMode = ref(normalizeSeriesCardCoverMode(storage.get('seriesCardCoverMode', DEFAULT_SERIES_CARD_COVER_MODE))) watch(portraitCoverSize, (v) => storage.set('portraitCoverSize', v)) watch(squareCoverSize, (v) => storage.set('squareCoverSize', v)) @@ -133,6 +144,7 @@ watch(tableDensity, (v) => storage.set('tableDensity', normalizeTableDensity(v)) watch(bookSpineOverlay, (value) => storage.set('bookSpineOverlay', normalizeBookSpineOverlay(value))) watch(bookShadowStrength, (value) => storage.set('bookShadowStrength', normalizeBookShadowStrength(value))) watch(bookCoverDisplayMode, (value) => storage.set('bookCoverDisplayMode', normalizeBookCoverDisplayMode(value))) +watch(seriesCardCoverMode, (value) => storage.set('seriesCardCoverMode', normalizeSeriesCardCoverMode(value))) export function getDisplayPreferencesSnapshot(): DisplayPreferences { return { @@ -152,6 +164,7 @@ export function getDisplayPreferencesSnapshot(): DisplayPreferences { bookSpineOverlay: normalizeBookSpineOverlay(bookSpineOverlay.value), bookShadowStrength: normalizeBookShadowStrength(bookShadowStrength.value), bookCoverDisplayMode: normalizeBookCoverDisplayMode(bookCoverDisplayMode.value), + seriesCardCoverMode: normalizeSeriesCardCoverMode(seriesCardCoverMode.value), } } @@ -181,6 +194,9 @@ export function sanitizeDisplayPreferences(raw: unknown): Partial { if (isApplyingServerPrefs || !isSyncEnabled()) return diff --git a/client/src/features/book/components/CollapsedSeriesCard.vue b/client/src/features/book/components/CollapsedSeriesCard.vue index a0b692dd4..ec1c607e9 100644 --- a/client/src/features/book/components/CollapsedSeriesCard.vue +++ b/client/src/features/book/components/CollapsedSeriesCard.vue @@ -1,11 +1,12 @@