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:
neon
2026-05-31 15:10:00 +00:00
parent 0b3a96cbe3
commit 7a1b0120b8
15 changed files with 610 additions and 8 deletions
@@ -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;
}
+3
View File
@@ -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 {
+61 -1
View File
@@ -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) => {