mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,7 @@ function validDisplayPreferences(overrides: Partial<DisplayPreferences> = {}): 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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<BookShadowStrength>(normalizeBookShadowStrength(s
|
||||
const bookCoverDisplayMode = ref<BookCoverDisplayMode>(
|
||||
normalizeBookCoverDisplayMode(storage.get('bookCoverDisplayMode', DEFAULT_BOOK_COVER_DISPLAY_MODE)),
|
||||
)
|
||||
const seriesCardCoverMode = ref<SeriesCardCoverMode>(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<DisplayPrefere
|
||||
if (BOOK_COVER_DISPLAY_MODES.includes(obj.bookCoverDisplayMode as BookCoverDisplayMode)) {
|
||||
out.bookCoverDisplayMode = obj.bookCoverDisplayMode as BookCoverDisplayMode
|
||||
}
|
||||
if (SERIES_CARD_COVER_MODES.includes(obj.seriesCardCoverMode as SeriesCardCoverMode)) {
|
||||
out.seriesCardCoverMode = obj.seriesCardCoverMode as SeriesCardCoverMode
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
@@ -203,6 +219,7 @@ export function applyDisplayPreferences(raw: unknown): void {
|
||||
if (prefs.bookSpineOverlay !== undefined) bookSpineOverlay.value = prefs.bookSpineOverlay
|
||||
if (prefs.bookShadowStrength !== undefined) bookShadowStrength.value = prefs.bookShadowStrength
|
||||
if (prefs.bookCoverDisplayMode !== undefined) bookCoverDisplayMode.value = prefs.bookCoverDisplayMode
|
||||
if (prefs.seriesCardCoverMode !== undefined) seriesCardCoverMode.value = prefs.seriesCardCoverMode
|
||||
}
|
||||
|
||||
export function useDisplaySettings() {
|
||||
@@ -223,5 +240,6 @@ export function useDisplaySettings() {
|
||||
bookSpineOverlay,
|
||||
bookShadowStrength,
|
||||
bookCoverDisplayMode,
|
||||
seriesCardCoverMode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,7 @@ export function initDisplaySettingsSync(): void {
|
||||
settings.bookSpineOverlay.value,
|
||||
settings.bookShadowStrength.value,
|
||||
settings.bookCoverDisplayMode.value,
|
||||
settings.seriesCardCoverMode.value,
|
||||
] as const,
|
||||
() => {
|
||||
if (isApplyingServerPrefs || !isSyncEnabled()) return
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { FORMAT_TO_GROUP, type BookCard } from '@bookorbit/types'
|
||||
import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
|
||||
import BookCoverSurface from './BookCoverSurface.vue'
|
||||
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
|
||||
import { useCoverVersions } from '../composables/useCoverVersions'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
|
||||
const props = defineProps<{
|
||||
book: BookCard
|
||||
@@ -15,16 +16,39 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
||||
const { coverUrl } = useCoverVersions()
|
||||
const { seriesCardCoverMode } = useDisplaySettings()
|
||||
|
||||
const collapsed = computed(() => props.book.collapsedSeries!)
|
||||
const seriesName = computed(() => props.book.seriesName ?? '')
|
||||
const authorLine = computed(() => props.book.authors.join(', ') || null)
|
||||
const hoverTitleClampClass = computed(() => (coverAspectRatio.value === '1/1' ? 'line-clamp-1' : 'line-clamp-2'))
|
||||
|
||||
const isMosaic = computed(() => seriesCardCoverMode.value === 'mosaic')
|
||||
|
||||
const coverIds = computed(() => collapsed.value.coverBookIds.filter((bookId) => bookId > 0).slice(0, 4))
|
||||
const tileCount = computed(() => Math.max(coverIds.value.length, 1))
|
||||
|
||||
const resolvedCoverId = computed<number | null>(() => {
|
||||
const c = collapsed.value
|
||||
const fallback = coverIds.value[0] ?? null
|
||||
switch (seriesCardCoverMode.value) {
|
||||
case 'first-volume':
|
||||
return c.firstVolumeBookId ?? fallback
|
||||
case 'latest-volume':
|
||||
return c.latestVolumeBookId ?? fallback
|
||||
case 'first-unread':
|
||||
return c.firstUnreadBookId ?? c.firstVolumeBookId ?? fallback
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const failedCovers = ref(new Set<number>())
|
||||
const singleCoverFailed = ref(false)
|
||||
|
||||
watch(resolvedCoverId, () => {
|
||||
singleCoverFailed.value = false
|
||||
})
|
||||
const primaryFile = computed(() => props.book.files.find((file) => file.role === 'primary') ?? props.book.files[0] ?? null)
|
||||
const isAudiobook = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
|
||||
|
||||
@@ -32,6 +56,10 @@ function handleCoverError(bookId: number) {
|
||||
failedCovers.value = new Set([...failedCovers.value, bookId])
|
||||
}
|
||||
|
||||
function handleSingleCoverError() {
|
||||
singleCoverFailed.value = true
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
router.push({ name: 'series-detail', params: { seriesName: seriesName.value }, query: { from: route.fullPath } })
|
||||
}
|
||||
@@ -53,8 +81,21 @@ function tileClass(index: number): string {
|
||||
:disable-spine="isAudiobook"
|
||||
:style="{ aspectRatio: coverAspectRatio }"
|
||||
>
|
||||
<!-- Adaptive cover mosaic -->
|
||||
<div class="absolute inset-0 grid grid-cols-2 grid-rows-2">
|
||||
<!-- Single cover mode -->
|
||||
<div v-if="!isMosaic && resolvedCoverId != null" class="absolute inset-0" data-testid="series-single-cover">
|
||||
<img
|
||||
v-if="!singleCoverFailed"
|
||||
:src="coverUrl(resolvedCoverId)"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
alt=""
|
||||
@error="handleSingleCoverError"
|
||||
/>
|
||||
<BookCoverPlaceholder v-else title="" author-line="" :is-audio="false" :seed="`series-${resolvedCoverId}`" />
|
||||
</div>
|
||||
|
||||
<!-- Adaptive cover mosaic (default mode) -->
|
||||
<div v-else class="absolute inset-0 grid grid-cols-2 grid-rows-2">
|
||||
<template v-for="(bookId, i) in coverIds" :key="bookId">
|
||||
<div class="relative overflow-hidden" :class="tileClass(i)" data-testid="series-cover-tile">
|
||||
<img
|
||||
|
||||
@@ -56,15 +56,17 @@ function makeBook(overrides?: Partial<BookCard>): BookCard {
|
||||
}
|
||||
}
|
||||
|
||||
const { bookSpineOverlay } = useDisplaySettings()
|
||||
const { bookSpineOverlay, seriesCardCoverMode } = useDisplaySettings()
|
||||
|
||||
describe('CollapsedSeriesCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
seriesCardCoverMode.value = 'mosaic'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
bookSpineOverlay.value = 'off'
|
||||
seriesCardCoverMode.value = 'mosaic'
|
||||
})
|
||||
|
||||
it('renders four cover tiles when all books are represented by covers', () => {
|
||||
@@ -214,4 +216,223 @@ describe('CollapsedSeriesCard', () => {
|
||||
const cover = wrapper.find('.book-cover-surface')
|
||||
expect(cover.attributes('data-cover-spine')).toBe('off')
|
||||
})
|
||||
|
||||
describe('single cover mode (non-mosaic)', () => {
|
||||
it('renders single cover for first-volume mode', () => {
|
||||
seriesCardCoverMode.value = 'first-volume'
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: {
|
||||
book: makeBook({
|
||||
collapsedSeries: {
|
||||
bookCount: 5,
|
||||
readCount: 0,
|
||||
coverBookIds: [1, 2, 3, 4],
|
||||
seriesLatestAddedAt: null,
|
||||
firstVolumeBookId: 10,
|
||||
latestVolumeBookId: 40,
|
||||
firstUnreadBookId: 20,
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="series-single-cover"]').exists()).toBe(true)
|
||||
expect(wrapper.findAll('[data-testid="series-cover-tile"]')).toHaveLength(0)
|
||||
const img = wrapper.find('[data-testid="series-single-cover"] img')
|
||||
expect(img.exists()).toBe(true)
|
||||
expect(img.attributes('src')).toBe('/api/v1/books/10/thumbnail')
|
||||
})
|
||||
|
||||
it('renders single cover for latest-volume mode', () => {
|
||||
seriesCardCoverMode.value = 'latest-volume'
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: {
|
||||
book: makeBook({
|
||||
collapsedSeries: {
|
||||
bookCount: 5,
|
||||
readCount: 0,
|
||||
coverBookIds: [1, 2, 3, 4],
|
||||
seriesLatestAddedAt: null,
|
||||
firstVolumeBookId: 10,
|
||||
latestVolumeBookId: 40,
|
||||
firstUnreadBookId: 20,
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const img = wrapper.find('[data-testid="series-single-cover"] img')
|
||||
expect(img.attributes('src')).toBe('/api/v1/books/40/thumbnail')
|
||||
})
|
||||
|
||||
it('renders single cover for first-unread mode', () => {
|
||||
seriesCardCoverMode.value = 'first-unread'
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: {
|
||||
book: makeBook({
|
||||
collapsedSeries: {
|
||||
bookCount: 5,
|
||||
readCount: 2,
|
||||
coverBookIds: [1, 2, 3, 4],
|
||||
seriesLatestAddedAt: null,
|
||||
firstVolumeBookId: 10,
|
||||
latestVolumeBookId: 40,
|
||||
firstUnreadBookId: 20,
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const img = wrapper.find('[data-testid="series-single-cover"] img')
|
||||
expect(img.attributes('src')).toBe('/api/v1/books/20/thumbnail')
|
||||
})
|
||||
|
||||
it('falls back to first coverBookId when volume ID is missing', () => {
|
||||
seriesCardCoverMode.value = 'first-volume'
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: {
|
||||
book: makeBook({
|
||||
collapsedSeries: {
|
||||
bookCount: 3,
|
||||
readCount: 0,
|
||||
coverBookIds: [77, 88],
|
||||
seriesLatestAddedAt: null,
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const img = wrapper.find('[data-testid="series-single-cover"] img')
|
||||
expect(img.attributes('src')).toBe('/api/v1/books/77/thumbnail')
|
||||
})
|
||||
|
||||
it('first-unread falls back to firstVolumeBookId when firstUnreadBookId is missing', () => {
|
||||
seriesCardCoverMode.value = 'first-unread'
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: {
|
||||
book: makeBook({
|
||||
collapsedSeries: {
|
||||
bookCount: 3,
|
||||
readCount: 3,
|
||||
coverBookIds: [1, 2, 3],
|
||||
seriesLatestAddedAt: null,
|
||||
firstVolumeBookId: 10,
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const img = wrapper.find('[data-testid="series-single-cover"] img')
|
||||
expect(img.attributes('src')).toBe('/api/v1/books/10/thumbnail')
|
||||
})
|
||||
|
||||
it('first-unread falls back to first coverBookId when both unread and first volume are missing', () => {
|
||||
seriesCardCoverMode.value = 'first-unread'
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: {
|
||||
book: makeBook({
|
||||
collapsedSeries: {
|
||||
bookCount: 3,
|
||||
readCount: 3,
|
||||
coverBookIds: [77, 88],
|
||||
seriesLatestAddedAt: null,
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const img = wrapper.find('[data-testid="series-single-cover"] img')
|
||||
expect(img.attributes('src')).toBe('/api/v1/books/77/thumbnail')
|
||||
})
|
||||
|
||||
it('shows count badge in single cover mode', () => {
|
||||
seriesCardCoverMode.value = 'first-volume'
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: {
|
||||
book: makeBook({
|
||||
collapsedSeries: {
|
||||
bookCount: 7,
|
||||
readCount: 0,
|
||||
coverBookIds: [1, 2],
|
||||
seriesLatestAddedAt: null,
|
||||
firstVolumeBookId: 1,
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const badge = wrapper.find('[data-testid="series-count-badge"]')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.text()).toBe('7')
|
||||
})
|
||||
|
||||
it('shows hover overlay with series name and author in single cover mode', () => {
|
||||
seriesCardCoverMode.value = 'latest-volume'
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: {
|
||||
book: makeBook({
|
||||
seriesName: 'Dune Saga',
|
||||
authors: ['Frank Herbert'],
|
||||
collapsedSeries: {
|
||||
bookCount: 3,
|
||||
readCount: 0,
|
||||
coverBookIds: [1, 2, 3],
|
||||
seriesLatestAddedAt: null,
|
||||
latestVolumeBookId: 3,
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Dune Saga')
|
||||
expect(wrapper.text()).toContain('Frank Herbert')
|
||||
})
|
||||
|
||||
it('shows placeholder after single cover image error', async () => {
|
||||
seriesCardCoverMode.value = 'first-volume'
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: {
|
||||
book: makeBook({
|
||||
collapsedSeries: {
|
||||
bookCount: 2,
|
||||
readCount: 0,
|
||||
coverBookIds: [10, 20],
|
||||
seriesLatestAddedAt: null,
|
||||
firstVolumeBookId: 10,
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const img = wrapper.find('[data-testid="series-single-cover"] img')
|
||||
expect(img.exists()).toBe(true)
|
||||
|
||||
await img.trigger('error')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.find('[data-testid="series-single-cover"] img').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="series-single-cover"] .book-cover-placeholder').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders mosaic when mode is mosaic (default)', () => {
|
||||
seriesCardCoverMode.value = 'mosaic'
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: {
|
||||
book: makeBook({
|
||||
collapsedSeries: {
|
||||
bookCount: 4,
|
||||
readCount: 0,
|
||||
coverBookIds: [1, 2, 3, 4],
|
||||
seriesLatestAddedAt: null,
|
||||
firstVolumeBookId: 1,
|
||||
latestVolumeBookId: 4,
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="series-single-cover"]').exists()).toBe(false)
|
||||
expect(wrapper.findAll('[data-testid="series-cover-tile"]')).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,10 +2,19 @@
|
||||
import { computed } from 'vue'
|
||||
import { Circle, Square } from 'lucide-vue-next'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
import { useDisplaySettings, type AuthorCoverShape, type CoverSizeScope } from '@/composables/useDisplaySettings'
|
||||
import { useDisplaySettings, type AuthorCoverShape, type CoverSizeScope, type SeriesCardCoverMode } from '@/composables/useDisplaySettings'
|
||||
|
||||
const { portraitCoverSize, squareCoverSize, coverSizeScope, portraitGridGap, squareGridGap, authorCoverSize, authorCoverShape, tableZebraStriping } =
|
||||
useDisplaySettings()
|
||||
const {
|
||||
portraitCoverSize,
|
||||
squareCoverSize,
|
||||
coverSizeScope,
|
||||
portraitGridGap,
|
||||
squareGridGap,
|
||||
authorCoverSize,
|
||||
authorCoverShape,
|
||||
tableZebraStriping,
|
||||
seriesCardCoverMode,
|
||||
} = useDisplaySettings()
|
||||
|
||||
const syncModeEnabled = computed(() => coverSizeScope.value === 'synced')
|
||||
|
||||
@@ -17,6 +26,10 @@ function setAuthorCoverShape(shape: AuthorCoverShape) {
|
||||
authorCoverShape.value = shape
|
||||
}
|
||||
|
||||
function setSeriesCardCoverMode(mode: SeriesCardCoverMode) {
|
||||
seriesCardCoverMode.value = mode
|
||||
}
|
||||
|
||||
function handlePortraitCoverSizeInput(event: Event) {
|
||||
portraitCoverSize.value = Number((event.target as HTMLInputElement).value)
|
||||
}
|
||||
@@ -173,6 +186,54 @@ function handleAuthorCoverSizeInput(event: Event) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="settings-group-label">Series Display</p>
|
||||
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card">
|
||||
<div>
|
||||
<p class="settings-label">Collapsed series cover</p>
|
||||
<p class="settings-hint">Choose which cover to show when series are collapsed in the book grid</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 p-1 rounded-lg border border-border bg-muted/50 self-start">
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
|
||||
:class="seriesCardCoverMode === 'mosaic' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
|
||||
@click="setSeriesCardCoverMode('mosaic')"
|
||||
>
|
||||
Mosaic
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
|
||||
:class="
|
||||
seriesCardCoverMode === 'first-volume' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click="setSeriesCardCoverMode('first-volume')"
|
||||
>
|
||||
First
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
|
||||
:class="
|
||||
seriesCardCoverMode === 'latest-volume' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click="setSeriesCardCoverMode('latest-volume')"
|
||||
>
|
||||
Latest
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
|
||||
:class="
|
||||
seriesCardCoverMode === 'first-unread' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click="setSeriesCardCoverMode('first-unread')"
|
||||
>
|
||||
First Unread
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="settings-group-label">Author Grid</p>
|
||||
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
|
||||
|
||||
@@ -191,6 +191,7 @@ describe('AppearanceSettings', () => {
|
||||
expect(routerReplaceMock).toHaveBeenLastCalledWith({ name: 'settings-appearance', query: { tab: 'layout' } })
|
||||
expect(wrapper.findAll('.settings-group-label').map((label) => label.text())).toEqual([
|
||||
'Library Grid Layout',
|
||||
'Series Display',
|
||||
'Author Grid',
|
||||
'List and Table Views',
|
||||
])
|
||||
|
||||
@@ -22,6 +22,9 @@ export type BookShadowStrength = (typeof BOOK_SHADOW_STRENGTHS)[number];
|
||||
export const BOOK_COVER_DISPLAY_MODES = ["blurred-fit", "fill-crop", "natural-bottom"] as const;
|
||||
export type BookCoverDisplayMode = (typeof BOOK_COVER_DISPLAY_MODES)[number];
|
||||
|
||||
export const SERIES_CARD_COVER_MODES = ["mosaic", "first-volume", "latest-volume", "first-unread"] as const;
|
||||
export type SeriesCardCoverMode = (typeof SERIES_CARD_COVER_MODES)[number];
|
||||
|
||||
export interface DisplayPreferences {
|
||||
portraitCoverSize: number;
|
||||
squareCoverSize: number;
|
||||
@@ -39,4 +42,5 @@ export interface DisplayPreferences {
|
||||
bookSpineOverlay: BookSpineOverlay;
|
||||
bookShadowStrength: BookShadowStrength;
|
||||
bookCoverDisplayMode: BookCoverDisplayMode;
|
||||
seriesCardCoverMode: SeriesCardCoverMode;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ export type CollapsedSeriesInfo = {
|
||||
readCount: number;
|
||||
coverBookIds: number[];
|
||||
seriesLatestAddedAt: string | null;
|
||||
firstVolumeBookId?: number | null;
|
||||
latestVolumeBookId?: number | null;
|
||||
firstUnreadBookId?: number | null;
|
||||
};
|
||||
|
||||
export function resolveCollapsePreference(prefs: SeriesCollapsePreferences | undefined, ctx: { libraryId?: number; collectionId?: number }): boolean {
|
||||
|
||||
@@ -63,6 +63,9 @@ type CollapsedRawRow = {
|
||||
book_count: string | null;
|
||||
read_count: string | null;
|
||||
cover_book_ids: number[] | null;
|
||||
first_volume_book_id: number | null;
|
||||
latest_volume_book_id: number | null;
|
||||
first_unread_book_id: number | null;
|
||||
total_count: string;
|
||||
};
|
||||
type PatternMetadataRow = {
|
||||
@@ -285,6 +288,9 @@ export class BookRepository {
|
||||
readCount: number | null;
|
||||
coverBookIds: number[] | null;
|
||||
seriesLatestAddedAt: Date | null;
|
||||
firstVolumeBookId: number | null;
|
||||
latestVolumeBookId: number | null;
|
||||
firstUnreadBookId: number | null;
|
||||
}>;
|
||||
authorRows: { bookId: number; name: string }[];
|
||||
fileRows: { bookId: number; id: number; format: string | null; role: string; sizeBytes: number | null }[];
|
||||
@@ -370,6 +376,45 @@ export class BookRepository {
|
||||
FROM series_cover_candidates scc
|
||||
GROUP BY scc.norm_series, scc.library_id
|
||||
),
|
||||
series_first_volume AS (
|
||||
SELECT scc.norm_series, scc.library_id, scc.id AS first_volume_book_id
|
||||
FROM series_cover_candidates scc
|
||||
WHERE scc.rn = 1
|
||||
),
|
||||
series_latest_volume AS (
|
||||
SELECT slv.norm_series, slv.library_id, slv.id AS latest_volume_book_id
|
||||
FROM (
|
||||
SELECT
|
||||
base.norm_series,
|
||||
base.library_id,
|
||||
base.id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY base.norm_series, base.library_id
|
||||
ORDER BY base.series_index DESC NULLS LAST, base.added_at DESC, base.id DESC
|
||||
) AS rn
|
||||
FROM base_rows base
|
||||
WHERE base.norm_series IS NOT NULL
|
||||
) slv
|
||||
WHERE slv.rn = 1
|
||||
),
|
||||
series_first_unread AS (
|
||||
SELECT sfu.norm_series, sfu.library_id, sfu.id AS first_unread_book_id
|
||||
FROM (
|
||||
SELECT
|
||||
base.norm_series,
|
||||
base.library_id,
|
||||
base.id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY base.norm_series, base.library_id
|
||||
ORDER BY base.series_index ASC NULLS LAST, base.added_at ASC, base.id ASC
|
||||
) AS rn
|
||||
FROM base_rows base
|
||||
LEFT JOIN user_book_status ubs ON ubs.book_id = base.id AND ubs.user_id = ${userId}
|
||||
WHERE base.norm_series IS NOT NULL
|
||||
AND ubs.status IS DISTINCT FROM 'read'
|
||||
) sfu
|
||||
WHERE sfu.rn = 1
|
||||
),
|
||||
representatives AS (
|
||||
SELECT DISTINCT ON (base.library_id, COALESCE(base.norm_series, 'book_' || base.id::text))
|
||||
base.id,
|
||||
@@ -395,7 +440,10 @@ export class BookRepository {
|
||||
COALESCE(sa.latest_added_at, base.added_at) AS sort_added_at,
|
||||
sa.book_count,
|
||||
sa.read_count,
|
||||
sc.cover_book_ids
|
||||
sc.cover_book_ids,
|
||||
sfv.first_volume_book_id,
|
||||
slv2.latest_volume_book_id,
|
||||
sfu2.first_unread_book_id
|
||||
FROM base_rows base
|
||||
LEFT JOIN user_book_ratings ubr ON ubr.book_id = base.id AND ubr.user_id = ${userId}
|
||||
LEFT JOIN series_agg sa
|
||||
@@ -404,6 +452,15 @@ export class BookRepository {
|
||||
LEFT JOIN series_covers sc
|
||||
ON sc.norm_series = sa.norm_series
|
||||
AND sc.library_id = sa.library_id
|
||||
LEFT JOIN series_first_volume sfv
|
||||
ON sfv.norm_series = base.norm_series
|
||||
AND sfv.library_id = base.library_id
|
||||
LEFT JOIN series_latest_volume slv2
|
||||
ON slv2.norm_series = base.norm_series
|
||||
AND slv2.library_id = base.library_id
|
||||
LEFT JOIN series_first_unread sfu2
|
||||
ON sfu2.norm_series = base.norm_series
|
||||
AND sfu2.library_id = base.library_id
|
||||
ORDER BY
|
||||
base.library_id,
|
||||
COALESCE(base.norm_series, 'book_' || base.id::text),
|
||||
@@ -445,6 +502,9 @@ export class BookRepository {
|
||||
readCount: r.read_count !== null ? Number(r.read_count) : null,
|
||||
coverBookIds: r.cover_book_ids,
|
||||
seriesLatestAddedAt: r.sort_added_at ? new Date(r.sort_added_at) : null,
|
||||
firstVolumeBookId: r.first_volume_book_id ?? null,
|
||||
latestVolumeBookId: r.latest_volume_book_id ?? null,
|
||||
firstUnreadBookId: r.first_unread_book_id ?? null,
|
||||
}));
|
||||
|
||||
const bookRefs = mappedRows.map((row) => ({ id: row.id, primaryFileId: row.primaryFileId ?? null }));
|
||||
|
||||
@@ -321,6 +321,9 @@ function makeCollapsedRow(id: number, overrides?: Record<string, unknown>) {
|
||||
readCount: 1,
|
||||
coverBookIds: [id],
|
||||
seriesLatestAddedAt: new Date('2024-06-01T00:00:00.000Z'),
|
||||
firstVolumeBookId: id,
|
||||
latestVolumeBookId: id,
|
||||
firstUnreadBookId: id,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -362,6 +365,9 @@ describe('assembleCollapsedBookCards', () => {
|
||||
expect(card!.collapsedSeries!.bookCount).toBe(3);
|
||||
expect(card!.collapsedSeries!.readCount).toBe(1);
|
||||
expect(card!.collapsedSeries!.coverBookIds).toEqual([1]);
|
||||
expect(card!.collapsedSeries!.firstVolumeBookId).toBe(1);
|
||||
expect(card!.collapsedSeries!.latestVolumeBookId).toBe(1);
|
||||
expect(card!.collapsedSeries!.firstUnreadBookId).toBe(1);
|
||||
});
|
||||
|
||||
it('does not set collapsedSeries on rows with null bookCount (standalones)', () => {
|
||||
@@ -393,6 +399,24 @@ describe('assembleCollapsedBookCards', () => {
|
||||
expect(card!.collapsedSeries!.coverBookIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes through firstVolumeBookId, latestVolumeBookId, and firstUnreadBookId', () => {
|
||||
const rows = [makeCollapsedRow(5, { firstVolumeBookId: 10, latestVolumeBookId: 30, firstUnreadBookId: 20 })];
|
||||
const [card] = assembleCollapsedBookCards(rows, [], [], [], []);
|
||||
|
||||
expect(card!.collapsedSeries!.firstVolumeBookId).toBe(10);
|
||||
expect(card!.collapsedSeries!.latestVolumeBookId).toBe(30);
|
||||
expect(card!.collapsedSeries!.firstUnreadBookId).toBe(20);
|
||||
});
|
||||
|
||||
it('sets volume IDs to null when source values are null', () => {
|
||||
const rows = [makeCollapsedRow(1, { firstVolumeBookId: null, latestVolumeBookId: null, firstUnreadBookId: null })];
|
||||
const [card] = assembleCollapsedBookCards(rows, [], [], [], []);
|
||||
|
||||
expect(card!.collapsedSeries!.firstVolumeBookId).toBeNull();
|
||||
expect(card!.collapsedSeries!.latestVolumeBookId).toBeNull();
|
||||
expect(card!.collapsedSeries!.firstUnreadBookId).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves base card fields alongside collapsedSeries', () => {
|
||||
const rows = [makeCollapsedRow(10, { title: 'Book Ten' })];
|
||||
const authorRows = [{ bookId: 10, name: 'Author X' }];
|
||||
@@ -548,4 +572,99 @@ describe('collapseBookCards', () => {
|
||||
expect(alpha!.collapsedSeries!.bookCount).toBe(2);
|
||||
expect(beta!.collapsedSeries!.bookCount).toBe(3);
|
||||
});
|
||||
|
||||
it('sets firstVolumeBookId to the first book by series index', () => {
|
||||
const cards = [
|
||||
makeBookCard(3, { seriesName: 'S', seriesIndex: 3 }),
|
||||
makeBookCard(1, { seriesName: 'S', seriesIndex: 1 }),
|
||||
makeBookCard(2, { seriesName: 'S', seriesIndex: 2 }),
|
||||
];
|
||||
const result = collapseBookCards(cards);
|
||||
|
||||
expect(result[0]!.collapsedSeries!.firstVolumeBookId).toBe(1);
|
||||
});
|
||||
|
||||
it('sets latestVolumeBookId to the last book by series index', () => {
|
||||
const cards = [
|
||||
makeBookCard(1, { seriesName: 'S', seriesIndex: 1 }),
|
||||
makeBookCard(3, { seriesName: 'S', seriesIndex: 3 }),
|
||||
makeBookCard(2, { seriesName: 'S', seriesIndex: 2 }),
|
||||
];
|
||||
const result = collapseBookCards(cards);
|
||||
|
||||
expect(result[0]!.collapsedSeries!.latestVolumeBookId).toBe(3);
|
||||
});
|
||||
|
||||
it('sets firstUnreadBookId to the first unread book by series index', () => {
|
||||
const cards = [
|
||||
makeBookCard(1, { seriesName: 'S', seriesIndex: 1, readStatus: { status: 'read' } }),
|
||||
makeBookCard(2, { seriesName: 'S', seriesIndex: 2, readStatus: { status: 'reading' } }),
|
||||
makeBookCard(3, { seriesName: 'S', seriesIndex: 3, readStatus: null }),
|
||||
];
|
||||
const result = collapseBookCards(cards);
|
||||
|
||||
expect(result[0]!.collapsedSeries!.firstUnreadBookId).toBe(2);
|
||||
});
|
||||
|
||||
it('sets firstUnreadBookId to null when all books are read', () => {
|
||||
const cards = [
|
||||
makeBookCard(1, { seriesName: 'S', seriesIndex: 1, readStatus: { status: 'read' } }),
|
||||
makeBookCard(2, { seriesName: 'S', seriesIndex: 2, readStatus: { status: 'read' } }),
|
||||
];
|
||||
const result = collapseBookCards(cards);
|
||||
|
||||
expect(result[0]!.collapsedSeries!.firstUnreadBookId).toBeNull();
|
||||
});
|
||||
|
||||
it('sets firstUnreadBookId to first volume when no read statuses exist', () => {
|
||||
const cards = [
|
||||
makeBookCard(1, { seriesName: 'S', seriesIndex: 1, readStatus: null }),
|
||||
makeBookCard(2, { seriesName: 'S', seriesIndex: 2, readStatus: null }),
|
||||
];
|
||||
const result = collapseBookCards(cards);
|
||||
|
||||
expect(result[0]!.collapsedSeries!.firstUnreadBookId).toBe(1);
|
||||
});
|
||||
|
||||
it('sets all three volume IDs to the same book for single-book series', () => {
|
||||
const cards = [makeBookCard(42, { seriesName: 'Solo', seriesIndex: 1 })];
|
||||
const result = collapseBookCards(cards);
|
||||
|
||||
expect(result[0]!.collapsedSeries!.firstVolumeBookId).toBe(42);
|
||||
expect(result[0]!.collapsedSeries!.latestVolumeBookId).toBe(42);
|
||||
expect(result[0]!.collapsedSeries!.firstUnreadBookId).toBe(42);
|
||||
});
|
||||
|
||||
it('handles null series indices correctly for volume IDs', () => {
|
||||
const cards = [
|
||||
makeBookCard(2, { seriesName: 'S', seriesIndex: null, addedAt: '2024-03-01T00:00:00.000Z' }),
|
||||
makeBookCard(1, { seriesName: 'S', seriesIndex: 1, addedAt: '2024-01-01T00:00:00.000Z' }),
|
||||
];
|
||||
const result = collapseBookCards(cards);
|
||||
|
||||
expect(result[0]!.collapsedSeries!.firstVolumeBookId).toBe(1);
|
||||
expect(result[0]!.collapsedSeries!.latestVolumeBookId).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to last sorted book when all series indices are null', () => {
|
||||
const cards = [
|
||||
makeBookCard(1, { seriesName: 'S', seriesIndex: null, addedAt: '2024-01-01T00:00:00.000Z' }),
|
||||
makeBookCard(2, { seriesName: 'S', seriesIndex: null, addedAt: '2024-03-01T00:00:00.000Z' }),
|
||||
];
|
||||
const result = collapseBookCards(cards);
|
||||
|
||||
expect(result[0]!.collapsedSeries!.firstVolumeBookId).toBe(1);
|
||||
expect(result[0]!.collapsedSeries!.latestVolumeBookId).toBe(2);
|
||||
});
|
||||
|
||||
it('does not treat null-index book as latest volume when numeric indexes exist', () => {
|
||||
const cards = [
|
||||
makeBookCard(1, { seriesName: 'S', seriesIndex: 1 }),
|
||||
makeBookCard(2, { seriesName: 'S', seriesIndex: 5 }),
|
||||
makeBookCard(3, { seriesName: 'S', seriesIndex: null }),
|
||||
];
|
||||
const result = collapseBookCards(cards);
|
||||
|
||||
expect(result[0]!.collapsedSeries!.latestVolumeBookId).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,9 @@ type CollapsedBookRow = BookRow & {
|
||||
readCount: number | null;
|
||||
coverBookIds: number[] | null;
|
||||
seriesLatestAddedAt: Date | null;
|
||||
firstVolumeBookId: number | null;
|
||||
latestVolumeBookId: number | null;
|
||||
firstUnreadBookId: number | null;
|
||||
};
|
||||
|
||||
type NameRow = { bookId: number; name: string };
|
||||
@@ -179,6 +182,9 @@ export function assembleCollapsedBookCards(
|
||||
readCount: row.readCount ?? 0,
|
||||
coverBookIds: row.coverBookIds ?? [],
|
||||
seriesLatestAddedAt: row.seriesLatestAddedAt?.toISOString() ?? null,
|
||||
firstVolumeBookId: row.firstVolumeBookId ?? null,
|
||||
latestVolumeBookId: row.latestVolumeBookId ?? null,
|
||||
firstUnreadBookId: row.firstUnreadBookId ?? null,
|
||||
};
|
||||
base[i] = { ...base[i]!, collapsedSeries: collapsed };
|
||||
}
|
||||
@@ -230,11 +236,17 @@ export function collapseBookCards(cards: BookCard[]): BookCard[] {
|
||||
.map((b) => b.id);
|
||||
const fallbackIds = sorted.slice(0, 4).map((b) => b.id);
|
||||
|
||||
const firstUnread = sorted.find((b) => b.readStatus?.status !== 'read');
|
||||
const lastWithIndex = sorted.findLast((b) => b.seriesIndex !== null);
|
||||
|
||||
representative.collapsedSeries = {
|
||||
bookCount: group.length,
|
||||
readCount,
|
||||
coverBookIds: coverIds.length > 0 ? coverIds : fallbackIds,
|
||||
seriesLatestAddedAt,
|
||||
firstVolumeBookId: sorted[0]!.id,
|
||||
latestVolumeBookId: (lastWithIndex ?? sorted[sorted.length - 1]!).id,
|
||||
firstUnreadBookId: firstUnread?.id ?? null,
|
||||
};
|
||||
result.push({ index: firstIndex, card: representative });
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ const validDisplayPreferences: DisplayPreferences = {
|
||||
bookSpineOverlay: 'subtle',
|
||||
bookShadowStrength: 'strong',
|
||||
bookCoverDisplayMode: 'natural-bottom',
|
||||
seriesCardCoverMode: 'mosaic',
|
||||
};
|
||||
|
||||
const repo = {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CARD_OVERLAY_KEYS,
|
||||
COVER_SIZE_SCOPES,
|
||||
RADIUS_IDS,
|
||||
SERIES_CARD_COVER_MODES,
|
||||
TABLE_DENSITIES,
|
||||
THEME_IDS,
|
||||
type DisplayPreferences,
|
||||
@@ -48,6 +49,7 @@ const DISPLAY_PREFERENCES_SCHEMA = z
|
||||
bookSpineOverlay: z.enum(BOOK_SPINE_OVERLAYS),
|
||||
bookShadowStrength: z.enum(BOOK_SHADOW_STRENGTHS),
|
||||
bookCoverDisplayMode: z.enum(BOOK_COVER_DISPLAY_MODES),
|
||||
seriesCardCoverMode: z.enum(SERIES_CARD_COVER_MODES).default('mosaic'),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((data, ctx) => {
|
||||
|
||||
Reference in New Issue
Block a user