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 @@